Plugin Directory

Changeset 3754379


Ignore:
Timestamp:
10/19/2026 09:14:46 AM (3 weeks ago)
Author:
vtables
Message:

v0.3

Location:
vtables/trunk
Files:
158 added
24 deleted
21 edited

Legend:

Unmodified
Added
Removed
  • vtables/trunk/dist/spa/css/vtables.css

    r3576926 r3754379  
     1:root {
     2  --red: #f44336;
     3  --red-1: #ffebee;
     4  --red-2: #ffcdd2;
     5  --red-3: #ef9a9a;
     6  --red-4: #e57373;
     7  --red-5: #ef5350;
     8  --red-6: #f44336;
     9  --red-7: #e53935;
     10  --red-8: #d32f2f;
     11  --red-9: #c62828;
     12  --red-10: #b71c1c;
     13  --red-11: #ff8a80;
     14  --red-12: #ff5252;
     15  --red-13: #ff1744;
     16  --red-14: #d50000;
     17  --pink: #e91e63;
     18  --pink-1: #fce4ec;
     19  --pink-2: #f8bbd0;
     20  --pink-3: #f48fb1;
     21  --pink-4: #f06292;
     22  --pink-5: #ec407a;
     23  --pink-6: #e91e63;
     24  --pink-7: #d81b60;
     25  --pink-8: #c2185b;
     26  --pink-9: #ad1457;
     27  --pink-10: #880e4f;
     28  --pink-11: #ff80ab;
     29  --pink-12: #ff4081;
     30  --pink-13: #f50057;
     31  --pink-14: #c51162;
     32  --purple: #9c27b0;
     33  --purple-1: #f3e5f5;
     34  --purple-2: #e1bee7;
     35  --purple-3: #ce93d8;
     36  --purple-4: #ba68c8;
     37  --purple-5: #ab47bc;
     38  --purple-6: #9c27b0;
     39  --purple-7: #8e24aa;
     40  --purple-8: #7b1fa2;
     41  --purple-9: #6a1b9a;
     42  --purple-10: #4a148c;
     43  --purple-11: #ea80fc;
     44  --purple-12: #e040fb;
     45  --purple-13: #d500f9;
     46  --purple-14: #aa00ff;
     47  --deep-purple: #673ab7;
     48  --deep-purple-1: #ede7f6;
     49  --deep-purple-2: #d1c4e9;
     50  --deep-purple-3: #b39ddb;
     51  --deep-purple-4: #9575cd;
     52  --deep-purple-5: #7e57c2;
     53  --deep-purple-6: #673ab7;
     54  --deep-purple-7: #5e35b1;
     55  --deep-purple-8: #512da8;
     56  --deep-purple-9: #4527a0;
     57  --deep-purple-10: #311b92;
     58  --deep-purple-11: #b388ff;
     59  --deep-purple-12: #7c4dff;
     60  --deep-purple-13: #651fff;
     61  --deep-purple-14: #6200ea;
     62  --indigo: #3f51b5;
     63  --indigo-1: #e8eaf6;
     64  --indigo-2: #c5cae9;
     65  --indigo-3: #9fa8da;
     66  --indigo-4: #7986cb;
     67  --indigo-5: #5c6bc0;
     68  --indigo-6: #3f51b5;
     69  --indigo-7: #3949ab;
     70  --indigo-8: #303f9f;
     71  --indigo-9: #283593;
     72  --indigo-10: #1a237e;
     73  --indigo-11: #8c9eff;
     74  --indigo-12: #536dfe;
     75  --indigo-13: #3d5afe;
     76  --indigo-14: #304ffe;
     77  --blue: #2196f3;
     78  --blue-1: #e3f2fd;
     79  --blue-2: #bbdefb;
     80  --blue-3: #90caf9;
     81  --blue-4: #64b5f6;
     82  --blue-5: #42a5f5;
     83  --blue-6: #2196f3;
     84  --blue-7: #1e88e5;
     85  --blue-8: #1976d2;
     86  --blue-9: #1565c0;
     87  --blue-10: #0d47a1;
     88  --blue-11: #82b1ff;
     89  --blue-12: #448aff;
     90  --blue-13: #2979ff;
     91  --blue-14: #2962ff;
     92  --light-blue: #03a9f4;
     93  --light-blue-1: #e1f5fe;
     94  --light-blue-2: #b3e5fc;
     95  --light-blue-3: #81d4fa;
     96  --light-blue-4: #4fc3f7;
     97  --light-blue-5: #29b6f6;
     98  --light-blue-6: #03a9f4;
     99  --light-blue-7: #039be5;
     100  --light-blue-8: #0288d1;
     101  --light-blue-9: #0277bd;
     102  --light-blue-10: #01579b;
     103  --light-blue-11: #80d8ff;
     104  --light-blue-12: #40c4ff;
     105  --light-blue-13: #00b0ff;
     106  --light-blue-14: #0091ea;
     107  --cyan: #00bcd4;
     108  --cyan-1: #e0f7fa;
     109  --cyan-2: #b2ebf2;
     110  --cyan-3: #80deea;
     111  --cyan-4: #4dd0e1;
     112  --cyan-5: #26c6da;
     113  --cyan-6: #00bcd4;
     114  --cyan-7: #00acc1;
     115  --cyan-8: #0097a7;
     116  --cyan-9: #00838f;
     117  --cyan-10: #006064;
     118  --cyan-11: #84ffff;
     119  --cyan-12: #18ffff;
     120  --cyan-13: #00e5ff;
     121  --cyan-14: #00b8d4;
     122  --teal: #009688;
     123  --teal-1: #e0f2f1;
     124  --teal-2: #b2dfdb;
     125  --teal-3: #80cbc4;
     126  --teal-4: #4db6ac;
     127  --teal-5: #26a69a;
     128  --teal-6: #009688;
     129  --teal-7: #00897b;
     130  --teal-8: #00796b;
     131  --teal-9: #00695c;
     132  --teal-10: #004d40;
     133  --teal-11: #a7ffeb;
     134  --teal-12: #64ffda;
     135  --teal-13: #1de9b6;
     136  --teal-14: #00bfa5;
     137  --green: #4caf50;
     138  --green-1: #e8f5e9;
     139  --green-2: #c8e6c9;
     140  --green-3: #a5d6a7;
     141  --green-4: #81c784;
     142  --green-5: #66bb6a;
     143  --green-6: #4caf50;
     144  --green-7: #43a047;
     145  --green-8: #388e3c;
     146  --green-9: #2e7d32;
     147  --green-10: #1b5e20;
     148  --green-11: #b9f6ca;
     149  --green-12: #69f0ae;
     150  --green-13: #00e676;
     151  --green-14: #00c853;
     152  --light-green: #8bc34a;
     153  --light-green-1: #f1f8e9;
     154  --light-green-2: #dcedc8;
     155  --light-green-3: #c5e1a5;
     156  --light-green-4: #aed581;
     157  --light-green-5: #9ccc65;
     158  --light-green-6: #8bc34a;
     159  --light-green-7: #7cb342;
     160  --light-green-8: #689f38;
     161  --light-green-9: #558b2f;
     162  --light-green-10: #33691e;
     163  --light-green-11: #ccff90;
     164  --light-green-12: #b2ff59;
     165  --light-green-13: #76ff03;
     166  --light-green-14: #64dd17;
     167  --lime: #cddc39;
     168  --lime-1: #f9fbe7;
     169  --lime-2: #f0f4c3;
     170  --lime-3: #e6ee9c;
     171  --lime-4: #dce775;
     172  --lime-5: #d4e157;
     173  --lime-6: #cddc39;
     174  --lime-7: #c0ca33;
     175  --lime-8: #afb42b;
     176  --lime-9: #9e9d24;
     177  --lime-10: #827717;
     178  --lime-11: #f4ff81;
     179  --lime-12: #eeff41;
     180  --lime-13: #c6ff00;
     181  --lime-14: #aeea00;
     182  --yellow: #ffeb3b;
     183  --yellow-1: #fffde7;
     184  --yellow-2: #fff9c4;
     185  --yellow-3: #fff59d;
     186  --yellow-4: #fff176;
     187  --yellow-5: #ffee58;
     188  --yellow-6: #ffeb3b;
     189  --yellow-7: #fdd835;
     190  --yellow-8: #fbc02d;
     191  --yellow-9: #f9a825;
     192  --yellow-10: #f57f17;
     193  --yellow-11: #ffff8d;
     194  --yellow-12: #ffff00;
     195  --yellow-13: #ffea00;
     196  --yellow-14: #ffd600;
     197  --amber: #ffc107;
     198  --amber-1: #fff8e1;
     199  --amber-2: #ffecb3;
     200  --amber-3: #ffe082;
     201  --amber-4: #ffd54f;
     202  --amber-5: #ffca28;
     203  --amber-6: #ffc107;
     204  --amber-7: #ffb300;
     205  --amber-8: #ffa000;
     206  --amber-9: #ff8f00;
     207  --amber-10: #ff6f00;
     208  --amber-11: #ffe57f;
     209  --amber-12: #ffd740;
     210  --amber-13: #ffc400;
     211  --amber-14: #ffab00;
     212  --orange: #ff9800;
     213  --orange-1: #fff3e0;
     214  --orange-2: #ffe0b2;
     215  --orange-3: #ffcc80;
     216  --orange-4: #ffb74d;
     217  --orange-5: #ffa726;
     218  --orange-6: #ff9800;
     219  --orange-7: #fb8c00;
     220  --orange-8: #f57c00;
     221  --orange-9: #ef6c00;
     222  --orange-10: #e65100;
     223  --orange-11: #ffd180;
     224  --orange-12: #ffab40;
     225  --orange-13: #ff9100;
     226  --orange-14: #ff6d00;
     227  --deep-orange: #ff5722;
     228  --deep-orange-1: #fbe9e7;
     229  --deep-orange-2: #ffccbc;
     230  --deep-orange-3: #ffab91;
     231  --deep-orange-4: #ff8a65;
     232  --deep-orange-5: #ff7043;
     233  --deep-orange-6: #ff5722;
     234  --deep-orange-7: #f4511e;
     235  --deep-orange-8: #e64a19;
     236  --deep-orange-9: #d84315;
     237  --deep-orange-10: #bf360c;
     238  --deep-orange-11: #ff9e80;
     239  --deep-orange-12: #ff6e40;
     240  --deep-orange-13: #ff3d00;
     241  --deep-orange-14: #dd2c00;
     242  --brown: #795548;
     243  --brown-1: #efebe9;
     244  --brown-2: #d7ccc8;
     245  --brown-3: #bcaaa4;
     246  --brown-4: #a1887f;
     247  --brown-5: #8d6e63;
     248  --brown-6: #795548;
     249  --brown-7: #6d4c41;
     250  --brown-8: #5d4037;
     251  --brown-9: #4e342e;
     252  --brown-10: #3e2723;
     253  --brown-11: #d7ccc8;
     254  --brown-12: #bcaaa4;
     255  --brown-13: #8d6e63;
     256  --brown-14: #5d4037;
     257  --grey: #9e9e9e;
     258  --grey-1: #fafafa;
     259  --grey-2: #f5f5f5;
     260  --grey-3: #eeeeee;
     261  --grey-4: #e0e0e0;
     262  --grey-5: #bdbdbd;
     263  --grey-6: #9e9e9e;
     264  --grey-7: #757575;
     265  --grey-8: #616161;
     266  --grey-9: #424242;
     267  --grey-10: #212121;
     268  --grey-11: #f5f5f5;
     269  --grey-12: #eeeeee;
     270  --grey-13: #bdbdbd;
     271  --grey-14: #616161;
     272  --blue-grey: #607d8b;
     273  --blue-grey-1: #eceff1;
     274  --blue-grey-2: #cfd8dc;
     275  --blue-grey-3: #b0bec5;
     276  --blue-grey-4: #90a4ae;
     277  --blue-grey-5: #78909c;
     278  --blue-grey-6: #607d8b;
     279  --blue-grey-7: #546e7a;
     280  --blue-grey-8: #455a64;
     281  --blue-grey-9: #37474f;
     282  --blue-grey-10: #263238;
     283  --blue-grey-11: #cfd8dc;
     284  --blue-grey-12: #b0bec5;
     285  --blue-grey-13: #78909c;
     286  --blue-grey-14: #455a64;
     287}
     288
     289body.q-body--prevent-scroll {
     290  position: initial !important;
     291}
     292
    1293#wpcontent {
    2294  padding: 20px;
    … …  
    5297.gap-5 {
    6298  gap: 5px;
     299}
     300
     301.gap-10 {
     302  gap: 10px;
    7303}
    8304
    … …  
    40336}
    41337
    42 #adminmenu #toplevel_page_vtables .wp-menu-image img {
    43   opacity: 1;
    44 }
     338.q-table td,
     339.q-table th {
     340  white-space: normal;
     341  word-wrap: break-word;
     342}
     343
     344#q-notify .row {
     345  padding-top: 0;
     346  padding-bottom: 0;
     347}
     348
     349/* ===== TheTable styles ===== */
     350
     351.q-item__section--avatar {
     352  min-width: unset;
     353}
     354
     355.settings-open {
     356  margin-right: 325px;
     357}
     358
     359.fullscreen .q-table__top {
     360  padding: 0;
     361}
     362
     363.fullscreen .q-table__top .vtable__top {
     364  padding: 12px 16px;
     365}
     366
     367.fullscreen.q-table--dense .vtable__top {
     368  padding: 8px 16px;
     369}
     370
     371.cell-value {
     372  display: block;
     373  white-space: initial;
     374  width: inherit;
     375}
     376
     377.q-table--cell-separator .q-table__top {
     378  border-bottom: none !important;
     379}
     380
     381.q-table--cell-separator .q-table__top .vtable__top {
     382  border-bottom: 1px solid rgba(0, 0, 0, 0.12);
     383}
     384
     385.vtable {
     386  margin: 10px 0;
     387}
     388
     389.vtable .q-table__bottom {
     390  padding: 0;
     391}
     392
     393.vtable .q-table__bottom > div {
     394  border-radius: 0 0 4px 4px;
     395  padding: 4px 10px 4px 10px;
     396  min-height: inherit;
     397}
     398
     399.vtable .q-table__bottom--nodata {
     400  padding-left: 10px;
     401  padding-right: 10px;
     402}
     403
     404.vtable .q-field__bottom {
     405  padding-left: 0;
     406  padding-right: 0;
     407  position: absolute;
     408  background: #fff;
     409}
     410
     411.vtable .q-table__top {
     412  padding: 0;
     413  overflow: hidden;
     414}
     415
     416.vtable .q-table__top > div {
     417  padding: 12px 16px;
     418  min-height: inherit;
     419}
     420
     421.vtable .q-table--dense .q-table__top {
     422  padding: 0;
     423  overflow: hidden;
     424}
     425
     426.vtable .q-table--dense .q-table__top > div {
     427  padding: 8px 16px;
     428  min-height: inherit;
     429}
     430
     431.edit-icon {
     432  position: absolute;
     433}
     434.q-td .edit-icon {
     435  top: -8px;
     436}
     437/* Quasar makes body cells positioned, header cells not — the edit icon needs
     438   the th as its containing block. */
     439.q-table thead th {
     440  position: relative;
     441}
     442.q-table thead th .edit-icon {
     443  top: -6px;
     444}
     445.vtable__top h1 {
     446  display: inline-block;
     447  position: relative;
     448}
     449.vtable__top h1 .edit-icon {
     450  top: -10px;
     451  right: -45px;
     452}
     453.vtable__top:hover .edit-icon,
     454.q-td:hover .edit-icon,
     455.q-table thead th:hover .edit-icon,
     456.q-item__section:hover .edit-icon {
     457  display: block !important;
     458}
     459
     460.admin-bar-presented-on-fullscreen {
     461  margin-top: 30px;
     462  margin-left: 160px;
     463}
     464
     465.fullscreen-btn {
     466  height: 40px;
     467  width: 40px;
     468}
     469
     470.v-enter-active,
     471.v-leave-active {
     472  transition: opacity 0.5s ease;
     473}
     474
     475.v-enter-from,
     476.v-leave-to {
     477  opacity: 0;
     478}
     479
     480.q-table th.sortable {
     481  cursor: pointer;
     482  min-width: 116px;
     483}
     484
     485.cell-raw-html > * {
     486  cursor: pointer;
     487}
     488
     489.q-table__container {
     490  margin: 2px;
     491}
     492
     493.pre-wrap {
     494  white-space: pre-wrap;
     495}
     496
     497.flex-no-wrap {
     498  flex-wrap: nowrap;
     499}
  • vtables/trunk/dist/spa/index.html

    r3576926 r3754379  
    1 <!DOCTYPE html><html><head><title>vTables</title><meta charset=utf-8><meta name=description content="Create stunning, interactive tables effortlessly. No coding required. The ultimate WordPress plugin, lets you design powerful tables in a few clicks. Transform your data visualization now."><meta name=format-detection content="telephone=no"><meta name=msapplication-tap-highlight content=no><meta name=viewport content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width">  <script type="module" crossorigin src="/./wp-content/plugins/vtables/dist/spa/assets/index.fd5ac4ef.js"></script>
    2   <link rel="stylesheet" href="/./wp-content/plugins/vtables/dist/spa/assets/index.92adfbea.css">
     1<!DOCTYPE html><html><head><title>vTables</title><meta charset=utf-8><meta name=description content="Build interactive data tables from CSV, Excel and JSON files, or from scratch, and embed them anywhere with a shortcode."><meta name=format-detection content="telephone=no"><meta name=msapplication-tap-highlight content=no><meta name=viewport content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width">  <script type="module" crossorigin src="./assets/index.88166ef6.js"></script>
     2  <link rel="stylesheet" href="./assets/index.64481c02.css">
    33</head><body><div id=q-app></div></body></html>
  • vtables/trunk/includes/Classes/Activator.php

    r3133736 r3754379  
    77/**
    88 * ACTIVATOR CLASS
    9  * @since 1.0.0
     9 *
     10 * Owns the schema shared with vTables Pro: wp_vtables (one row per table) and
     11 * wp_vtables_source (one row per data row). Definitions are kept identical to
     12 * Pro's Activator so either plugin can create or upgrade them and the other
     13 * keeps working on the result. Pro-only tables (the WooCommerce product
     14 * index) are left to Pro. Every step is idempotent.
    1015 */
    1116class Activator {
    … …  
    1520     */
    1621    public function vtables_migrate_databases($network_wide = false) {
    17         global $wpdb;
    18         if ($network_wide) {
    19             // Retrieve all site IDs from this network (WordPress >= 4.6 provides easy to use functions for that).
    20             if (function_exists('get_sites') && function_exists('get_current_network_id')) {
    21                 $site_ids = get_sites(array('fields' => 'ids', 'network_id' => get_current_network_id()));
    22             } else {
    23                 $site_ids = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs WHERE site_id = $wpdb->siteid;");
    24             }
    25             // Install the plugin for all these sites.
     22        if ($network_wide && is_multisite()) {
     23            $site_ids = get_sites(array('fields' => 'ids', 'network_id' => get_current_network_id()));
    2624            foreach ($site_ids as $site_id) {
    2725                switch_to_blog($site_id);
    … …  
    3836     */
    3937    private function vtables_migrate() {
    40       $this->vtables_create_main_table();
     38        $this->vtables_create_main_table();
     39        $this->vtables_create_source_table();
     40        $this->vtables_update_table_type_values();
    4141    }
    4242
    4343    /**
    4444     * CREATE MAIN TABLE
     45     * 0.1.x/0.2.x created wp_vtables without table_type, woocommerce_categories
     46     * and plan; they are added in place. 'plan' is NOT backfilled here: rows
     47     * that predate it stay NULL so vTables Pro can grandfather them by their
     48     * source type and license (vtables_pro_backfill_table_plans). Tables
     49     * created by this plugin are stamped 'free' on insert.
    4550     */
    4651    public function vtables_create_main_table() {
    … …  
    5156            id int(10) NOT NULL AUTO_INCREMENT,
    5257            title text NULL DEFAULT NULL,
     58            table_type text NULL DEFAULT NULL,
     59            woocommerce_categories JSON,
    5360            settings json NULL DEFAULT NULL,
    5461            file_size text NULL DEFAULT NULL,
    … …  
    5764            updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
    5865            source longtext NULL DEFAULT NULL,
     66            plan text NULL DEFAULT NULL,
    5967            PRIMARY KEY (id)
    60             ) $charset_collate;";
     68          ) $charset_collate;";
    6169
    6270        $this->vtables_run_sql($sql, $table_name);
    6371        $this->vtables_add_new_column($table_name, 'table_type', 'TEXT');
    6472        $this->vtables_add_new_column($table_name, 'woocommerce_categories', 'JSON');
     73        $this->vtables_add_new_column($table_name, 'plan', 'TEXT');
     74    }
     75
     76    /**
     77     * CREATE SOURCE TABLE
     78     * One row per imported data row, keyed by table_id, so large datasets stay
     79     * out of the single wp_vtables.source longtext cell (and under the
     80     * max_allowed_packet ceiling).
     81     */
     82    public function vtables_create_source_table() {
     83        global $wpdb;
     84        $charset_collate = $wpdb->get_charset_collate();
     85        $table_name = $wpdb->prefix . 'vtables_source';
     86        $sql = "CREATE TABLE $table_name (
     87            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
     88            table_id int(10) NOT NULL,
     89            row_index int(10) unsigned NOT NULL,
     90            row_json longtext NULL DEFAULT NULL,
     91            search_blob longtext NULL DEFAULT NULL,
     92            PRIMARY KEY (id),
     93            KEY table_id_row_index (table_id, row_index)
     94          ) $charset_collate;";
     95
     96        $this->vtables_run_sql($sql, $table_name);
     97
     98        // For tables created by early vTables Pro 0.3.x, before search_blob existed.
     99        $this->vtables_add_new_column($table_name, 'search_blob', 'TEXT');
     100
     101        $this->vtables_upgrade_source_indexes($table_name);
     102    }
     103
     104    /**
     105     * UPGRADE SOURCE INDEX SLOTS
     106     * 5 typed "slot" sub-columns (text + numeric) with composite indexes, plus
     107     * a FULLTEXT index on search_blob. This plugin writes search_blob and the
     108     * slot values on every row write (vtables_replace_source_rows), so the rows
     109     * are ready for vTables Pro's server-side search/sort without a reindex.
     110     */
     111    private function vtables_upgrade_source_indexes($table_name) {
     112        for ($k = 1; $k <= 5; $k++) {
     113            $this->vtables_add_typed_column($table_name, "idx_{$k}_txt", 'VARCHAR(191) NULL DEFAULT NULL');
     114            $this->vtables_add_typed_column($table_name, "idx_{$k}_num", 'DECIMAL(30,10) NULL DEFAULT NULL');
     115        }
     116
     117        for ($k = 1; $k <= 5; $k++) {
     118            $this->vtables_add_index($table_name, "idx_{$k}_txt", "(table_id, idx_{$k}_txt)");
     119            $this->vtables_add_index($table_name, "idx_{$k}_num", "(table_id, idx_{$k}_num)");
     120        }
     121
     122        $this->vtables_add_fulltext_index($table_name, 'ft_search_blob', 'search_blob');
     123    }
     124
     125    /**
     126     * UPDATE LEGACY TABLE_TYPE VALUES
     127     * 0.1.0–0.1.4 stored 'local' (now 'csv') and 'remote' (now
     128     * 'google_sheets'). The frontend and vTables Pro only know the current names.
     129     */
     130    private function vtables_update_table_type_values() {
     131        global $wpdb;
     132
     133        $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     134            "UPDATE {$wpdb->prefix}vtables SET table_type = %s WHERE table_type = %s",
     135            'csv',
     136            'local'
     137        ));
     138
     139        $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     140            "UPDATE {$wpdb->prefix}vtables SET table_type = %s WHERE table_type = %s",
     141            'google_sheets',
     142            'remote'
     143        ));
     144    }
     145
     146    /**
     147     * ADD TYPED COLUMN
     148     * maybe_add_column() only takes the column name, so we guard with SHOW
     149     * COLUMNS ourselves to keep DECIMAL/VARCHAR definitions intact.
     150     */
     151    private function vtables_add_typed_column($table_name, $column, $definition) {
     152        global $wpdb;
     153        $exists = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     154            $wpdb->prepare('SHOW COLUMNS FROM `' . esc_sql($table_name) . '` LIKE %s', $wpdb->esc_like($column))
     155        );
     156        if ($exists) {
     157            return;
     158        }
     159        $wpdb->query("ALTER TABLE `$table_name` ADD `$column` $definition"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL; the table, column and definition are fixed values from vtables_upgrade_source_indexes()
     160    }
     161
     162    /**
     163     * ADD INDEX (idempotent)
     164     */
     165    private function vtables_add_index($table_name, $index_name, $columns_sql) {
     166        global $wpdb;
     167        $exists = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     168            $wpdb->prepare('SHOW INDEX FROM `' . esc_sql($table_name) . '` WHERE Key_name = %s', $index_name)
     169        );
     170        if ($exists) {
     171            return;
     172        }
     173        $wpdb->query("ALTER TABLE `$table_name` ADD KEY `$index_name` $columns_sql"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL; the table, index and columns are fixed values from vtables_upgrade_source_indexes()
     174    }
     175
     176    /**
     177     * ADD FULLTEXT INDEX (idempotent)
     178     * May fail on very old / non-InnoDB engines; the rest of the migration
     179     * still completes (LIKE search remains the correctness path).
     180     */
     181    private function vtables_add_fulltext_index($table_name, $index_name, $column) {
     182        global $wpdb;
     183        $exists = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     184            $wpdb->prepare('SHOW INDEX FROM `' . esc_sql($table_name) . '` WHERE Key_name = %s', $index_name)
     185        );
     186        if ($exists) {
     187            return;
     188        }
     189        $wpdb->query("ALTER TABLE `$table_name` ADD FULLTEXT KEY `$index_name` (`$column`)"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL; the table, index and column are fixed values from vtables_upgrade_source_indexes()
    65190    }
    66191
    … …  
    68193     * RUN SQL
    69194     */
    70     private function vtables_run_sql($sql, $tableName) {
    71         global $wpdb;
    72         if ($wpdb->get_var("SHOW TABLES LIKE '$tableName'") != $tableName) {
    73             require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
     195    private function vtables_run_sql($sql, $table_name) {
     196        global $wpdb;
     197        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     198            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    74199            dbDelta($sql);
    75200        }
    … …  
    79204     * ADD NEW COLUMN
    80205     */
    81     function vtables_add_new_column($tableName, $tableColumn, $columnType) {
    82       if ($columnType === 'JSON') {
    83         $create_ddl = "ALTER TABLE $tableName ADD $tableColumn JSON;";
    84       } else {
    85         $create_ddl = "ALTER TABLE $tableName ADD $tableColumn TEXT NULL DEFAULT NULL;";
    86       }
    87 
    88       require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
    89       maybe_add_column($tableName, $tableColumn, $create_ddl);
     206    private function vtables_add_new_column($table_name, $column, $column_type) {
     207        if ($column_type === 'JSON') {
     208            $create_ddl = "ALTER TABLE $table_name ADD $column JSON;";
     209        } else {
     210            $create_ddl = "ALTER TABLE $table_name ADD $column TEXT NULL DEFAULT NULL;";
     211        }
     212
     213        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     214        maybe_add_column($table_name, $column, $create_ddl);
    90215    }
    91216}
  • vtables/trunk/includes/Classes/LoadAssets.php

    r3576926 r3754379  
    55if ( ! defined( 'ABSPATH' ) ) exit;
    66
    7 /**
    8  * GET HASHED VUE FILES
    9  */
    10 function vtables_get_hashed_file($name, $type) {
    11   if ($name === 'index') {
    12     $index_html = VTABLES_DIR . 'dist/spa/index.html';
    13     if (is_file($index_html)) {
    14       $html = file_get_contents($index_html);
    15       if ($html !== false && preg_match('#assets/(index\.[A-Za-z0-9]+\.' . preg_quote($type, '#') . ')#', $html, $m)) {
    16         return $m[1];
    17       }
     7class LoadAssets {
     8
     9  /**
     10   * DEV MODE
     11   * env.php (never shipped, see release.sh) switches the admin screen and the
     12   * embed document to the Quasar dev server:
     13   *
     14   *   define( 'VTABLES_MODE', 'dev' );
     15   *   define( 'VTABLES_BASE_URL', 'http://localhost:9000' );
     16   */
     17  public static function is_dev_mode() {
     18    $env_file_path = VTABLES_DIR . 'env.php';
     19
     20    if ( file_exists( $env_file_path ) ) {
     21      include_once $env_file_path;
    1822    }
     23
     24    return defined( 'VTABLES_MODE' ) && constant( 'VTABLES_MODE' ) === 'dev' && defined( 'VTABLES_BASE_URL' );
    1925  }
    2026
    21   $folder = VTABLES_DIR . 'dist/spa/assets/';
    22   $files = list_files($folder);
    23   $matchedFiles = [];
    24 
    25   // Filter and store all matching files
    26   foreach ( $files as $file ) {
    27       if ( is_file( $file ) && strpos(basename($file), $name) !== false ) {
    28           $filetype = wp_check_filetype($file);
    29           if ($filetype['ext'] === $type) {
    30               $matchedFiles[] = $file;
    31           }
    32       }
    33   }
    34 
    35   // If multiple files match, sort them to get a consistent result
    36   if (count($matchedFiles) > 1) {
    37       usort($matchedFiles, function($a, $b) {
    38           return strcmp(basename($b), basename($a));
    39       });
    40   }
    41 
    42   // Return the first matched file
    43   return isset($matchedFiles[0]) ? basename($matchedFiles[0]) : null;
    44 }
    45 
    46 /**
    47  * LOAD ASSETS CLASS
    48  */
    49 class LoadAssets {
     27  /**
     28   * ADD TYPE ATTR
     29   * The SPA entry is an ES module.
     30   */
    5031  public function add_type_attribute( $tag, $handle, $src ) {
    51     if ( 'vtables-index' !== $handle ) {
    52       return $tag;
     32    if ( $handle === 'vtables-script-boot' || $handle === 'vtables-index' ) {
     33      $tag = str_replace( '<script ', '<script type="module" ', $tag );
    5334    }
    5435
    55     $tag = str_replace('<script ', '<script type="module" ', $tag);
    5636    return $tag;
    5737  }
    5838
    59   public function vtables_enqueue_assets() {
    60     $env_file_path = VTABLES_DIR . 'env.php';
    6139
    62     if (file_exists($env_file_path)) {
    63       include($env_file_path);
    64       $vtables_mode = constant('VTABLES_MODE');
     40  /**
     41   * GET HASHED VUE FILES
     42   *
     43   * Vite emits <name>.<hash>.<ext> into dist/spa/assets, so the filename is only
     44   * known at runtime. dist/spa/index.html is the authority on which of them is
     45   * the entry: Vite writes the real filenames into it and
     46   * scripts/patch-asset-base.mjs rewrites them to ./assets/<name>.<hash>.<ext>.
     47   *
     48   * Do NOT pick a name out of the directory listing instead. The pattern
     49   * index.*.js is ambiguous even on a clean build — the entry chunk and a lazy
     50   * index chunk both match — and an update unpacked over an older install (FTP
     51   * or a file manager overwrites files but never deletes the ones that went
     52   * away) leaves both builds' hashes side by side.
     53   *
     54   * glob() stays as the fallback for a dist/spa without index.html, ordered by
     55   * mtime and then size so the newest build's entry chunk still wins.
     56   *
     57   * Results are memoized per request.
     58   */
     59  public function vtables_get_hashed_file( $name, $type ) {
     60    static $cache = array();
     61
     62    $key = $name . '.' . $type;
     63    if ( array_key_exists( $key, $cache ) ) {
     64      return $cache[ $key ];
    6565    }
    6666
    67     if (isset($vtables_mode) && $vtables_mode === 'dev') { // dev mode
     67    $file = $this->vtables_hashed_file_from_manifest( $name, $type );
    6868
    69       wp_enqueue_style('vtables-material-icons', VTABLES_URL . 'public/css/material-icons-font-dist.css', false, VTABLES_VERSION);
    70       wp_enqueue_script('vtables-script-boot', constant('VTABLES_BASE_URL') . '/.quasar/client-entry.js', array('jquery'), null, true);
     69    if ( $file === null ) {
     70      $file = $this->vtables_hashed_file_from_glob( $name, $type );
     71    }
    7172
    72     } else { // prod mode
     73    $cache[ $key ] = $file;
    7374
    74       wp_enqueue_style( 'vtables-material-icons', VTABLES_URL . 'dist/spa/css/material-icons-font-dist.css', false, VTABLES_VERSION);
     75    return $file;
     76  }
    7577
    76       $app_css = VTABLES_URL . 'dist/spa/assets/' . vtables_get_hashed_file('index', 'css');
    77       wp_enqueue_style( 'vtables-index', $app_css, false, VTABLES_VERSION);
    7878
    79       wp_localize_script(
    80         'wp-api',
    81         'WP_API_Settings',
    82         array(
    83           'nonce' => wp_create_nonce('wp_rest'),
    84           'base_url' => rest_url(),
    85         )
    86       );
    87       wp_enqueue_script('wp-api');
     79  /**
     80   * HASHED FILE FROM THE BUILD MANIFEST
     81   * dist/spa/index.html as written by Vite — the only place that says which
     82   * hash belongs to the current build.
     83   */
     84  private function vtables_hashed_file_from_manifest( $name, $type ) {
     85    $manifest = VTABLES_DIR . 'dist/spa/index.html';
    8886
    89       $app_js = VTABLES_URL . 'dist/spa/assets/' . vtables_get_hashed_file('index', 'js');
    90       wp_enqueue_script( 'vtables-index', $app_js, array('jquery'), VTABLES_VERSION, false);
     87    if ( ! is_readable( $manifest ) ) {
     88      return null;
     89    }
    9190
    92       add_filter( 'script_loader_tag', array( $this, 'add_type_attribute' ), 10, 3 );
     91    $html = file_get_contents( $manifest ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local build file
    9392
     93    if ( ! is_string( $html ) || $html === '' ) {
     94      return null;
    9495    }
     96
     97    // Case-sensitive on purpose: 'index' must not match 'IndexPage.<hash>.js'.
     98    $pattern = '#assets/(' . preg_quote( $name, '#' ) . '\.[A-Za-z0-9_-]+\.' . preg_quote( $type, '#' ) . ')#';
     99
     100    return preg_match( $pattern, $html, $matches ) ? $matches[1] : null;
     101  }
     102
     103
     104  /**
     105   * HASHED FILE FROM THE DIRECTORY LISTING
     106   * Fallback only. Newest build first, then the largest file: chunks from one
     107   * build share an mtime (an unpacked zip gives them all the same one) and the
     108   * entry is by far the biggest of them.
     109   */
     110  private function vtables_hashed_file_from_glob( $name, $type ) {
     111    $matched = glob( VTABLES_DIR . 'dist/spa/assets/' . $name . '.*.' . $type );
     112
     113    if ( ! is_array( $matched ) || empty( $matched ) ) {
     114      return null;
     115    }
     116
     117    if ( count( $matched ) > 1 ) {
     118      usort( $matched, function ( $a, $b ) {
     119        $by_mtime = filemtime( $b ) <=> filemtime( $a );
     120        if ( $by_mtime !== 0 ) {
     121          return $by_mtime;
     122        }
     123
     124        $by_size = filesize( $b ) <=> filesize( $a );
     125
     126        return $by_size !== 0 ? $by_size : strcmp( basename( $b ), basename( $a ) );
     127      } );
     128    }
     129
     130    return basename( $matched[0] );
     131  }
     132
     133
     134  /**
     135   * ENQUEUE ASSETS: for admin page
     136   */
     137  public function vtables_enqueue_assets_admin() {
     138    $sys_info = $this->vtables_get_sys_info( 'admin' );
     139
     140    if ( self::is_dev_mode() ) {
     141      wp_enqueue_style( 'vtables-material-icons', VTABLES_URL . 'public/css/material-icons-font-dist.css', array(), VTABLES_VERSION );
     142      wp_enqueue_style( 'vtables-css', VTABLES_URL . 'public/css/vtables.css', array(), VTABLES_VERSION );
     143
     144      wp_enqueue_script( 'vtables-script-boot', constant( 'VTABLES_BASE_URL' ) . '/.quasar/client-entry.js', array(), VTABLES_VERSION, true );
     145      wp_localize_script( 'vtables-script-boot', 'vtables_sys_info', $sys_info );
     146    } else {
     147      wp_enqueue_style( 'vtables-material-icons', VTABLES_URL . 'dist/spa/css/material-icons-font-dist.css', array(), VTABLES_VERSION );
     148      wp_enqueue_style( 'vtables-css', VTABLES_URL . 'public/css/vtables.css', array(), VTABLES_VERSION );
     149
     150      // A missing hash would enqueue the assets directory itself; skip instead,
     151      // so a broken dist fails as a missing script rather than a 403/404 on a
     152      // directory URL.
     153      $index_css = $this->vtables_get_hashed_file( 'index', 'css' );
     154      if ( $index_css ) {
     155        wp_enqueue_style( 'vtables-index', VTABLES_URL . 'dist/spa/assets/' . $index_css, array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- hashed build filename
     156      }
     157
     158      $index_js = $this->vtables_get_hashed_file( 'index', 'js' );
     159      if ( $index_js ) {
     160        wp_enqueue_script( 'vtables-index', VTABLES_URL . 'dist/spa/assets/' . $index_js, array(), null, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- hashed build filename
     161        wp_localize_script( 'vtables-index', 'vtables_sys_info', $sys_info );
     162      }
     163    }
     164
     165    add_filter( 'script_loader_tag', array( $this, 'add_type_attribute' ), 10, 3 );
     166  }
     167
     168
     169  /**
     170   * GET SYS INFO
     171   * 'front' is printed into the embed document (the public SPA reads its own
     172   * window first, see src/boot/wpapi.ts); 'admin' adds the nonce and upload
     173   * limit for the admin screen.
     174   */
     175  public function vtables_get_sys_info( $page_type = 'front' ) {
     176    $arr_params = array(
     177      'ajaxurl'     => admin_url( 'admin-ajax.php' ),
     178      'plain_links' => empty( get_option( 'permalink_structure' ) ),
     179      'locale'      => ( is_admin() && function_exists( 'get_user_locale' ) ) ? get_user_locale() : get_locale(),
     180      'pro_url'     => 'https://vtables.pro',
     181    );
     182
     183    if ( $page_type === 'admin' ) {
     184      $arr_params['nonce']           = wp_create_nonce( 'vtables_nonce' );
     185      $arr_params['max_upload_size'] = wp_max_upload_size();
     186    }
     187
     188    return apply_filters( 'vtables_sys_info', $arr_params, $page_type );
    95189  }
    96190}
    97 
  • vtables/trunk/includes/autoload.php

    r3036822 r3754379  
    1313     * Plugin autoloader.
    1414     *
    15      * Pattern (with or without <Subnamespace>):
    16      *  <Namespace>/<Subnamespace>.../Class_Name (or Classname)
    17      *  'includes/subdir.../class-name.php' or '...classname.php'
     15     * Maps vTables\<Subnamespace>\<Class> to includes/<Subnamespace>/<Class>.php.
     16     * The namespace separator is part of the match, so vTables Pro's
     17     * vTablesPro\ classes are never resolved here.
    1818     *
    19      * @since 3.0.0
    20      *
    21      * @param $class
     19     * @param string $class
    2220     */
    2321    function vtables_autoload($class)
    2422    {
    25         // Do not load unless in plugin domain.
    26         $namespace = 'vTables';
     23        $namespace = 'vTables\\';
    2724        if (strpos($class, $namespace) !== 0) {
    2825            return;
    2926        }
    3027
    31         // Remove the root namespace.
    3228        $unprefixed = substr($class, strlen($namespace));
    33 
    34         // Build the file path.
    35         $file_path = str_replace('\\', DIRECTORY_SEPARATOR, $unprefixed);
    36 
    37         $file      = dirname(__FILE__) . $file_path . '.php';
     29        $file_path  = str_replace('\\', DIRECTORY_SEPARATOR, $unprefixed);
     30        $file       = __DIR__ . DIRECTORY_SEPARATOR . $file_path . '.php';
    3831
    3932        if (file_exists($file)) {
    … …  
    4134        }
    4235    }
    43     // Register the autoloader.
     36
    4437    spl_autoload_register('vtables_autoload');
    4538}
  • vtables/trunk/includes/endpoints.php

    r3576926 r3754379  
    33
    44/**
    5  * GET ALL TABLES.
    6  */
    7 add_action( 'rest_api_init', function () {
    8   register_rest_route( 'vtables/v1', '/tables', array(
    9     'methods' => 'GET',
    10     'callback' => 'vtables_get_all_handler',
    11     'args' => [
    12       'page' => [
    13         'description' => 'Current page',
    14         'type' => "integer",
    15       ],
    16       'per_page' => [
    17         'description' => 'Items per page',
    18         'type' => "integer",
    19       ],
    20       'search' => [
    21         'description' => 'Search items',
    22         'type' => "string",
    23       ]
    24     ],
    25     'permission_callback' => '__return_true',
    26   ) );
    27 } );
    28 
    29 function vtables_get_all_handler( WP_REST_Request $data ) {
     5 * LIST TABLES.
     6 */
     7add_action('wp_ajax_vtables_list_tables', 'vtables_list_tables_handler');
     8function vtables_list_tables_handler() {
     9  vtables_require_admin_ajax();
     10
    3011  global $wpdb;
    31 
    32   $table_name = $wpdb->prefix . 'vtables';
    33   $limit = $data['per_page'];
    34   $offset = ($data['page'] - 1) * $data['per_page'];
    35   $search = $data['search'] ?? '';
     12  $allowed_orderby = array('created_at', 'updated_at', 'title', 'table_type');
     13  $limit   = isset($_GET['per_page']) ? max(1, min(100, intval($_GET['per_page']))) : 10; // phpcs:ignore WordPress.Security.NonceVerification
     14  $page    = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification
     15  $offset  = ($page - 1) * $limit;
     16  $search  = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     17  $orderby = isset($_GET['orderby']) && in_array(sanitize_text_field(wp_unslash($_GET['orderby'])), $allowed_orderby, true) // phpcs:ignore WordPress.Security.NonceVerification
     18    ? sanitize_text_field(wp_unslash($_GET['orderby'])) // phpcs:ignore WordPress.Security.NonceVerification
     19    : 'created_at';
     20  $order   = isset($_GET['order']) && in_array(strtoupper(sanitize_text_field(wp_unslash($_GET['order']))), array('ASC', 'DESC'), true) // phpcs:ignore WordPress.Security.NonceVerification
     21    ? strtoupper(sanitize_text_field(wp_unslash($_GET['order']))) // phpcs:ignore WordPress.Security.NonceVerification
     22    : 'DESC';
     23
    3624  $keyword = $wpdb->esc_like($search);
    37   $orderby = isset($data['orderby']) ? $data['orderby'] : 'created_at';
    38   $order = isset($data['order']) && in_array($data['order'], ['ASC', 'DESC']) ? $data['order'] : 'DESC';
    39 
    40   $search_by_title_query = $wpdb->prepare(
    41     "SELECT * FROM $table_name WHERE title LIKE %s",
    42     '%' . $keyword . '%'
     25
     26  $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     27    $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}vtables WHERE title LIKE %s", '%' . $keyword . '%' )
    4328  );
    4429
    45   $pagination_query = $wpdb->prepare(
    46     "SELECT * FROM $table_name WHERE title LIKE %s ORDER BY $orderby $order LIMIT $limit OFFSET $offset",
    47     '%' . $keyword . '%'
     30  // $orderby and $order are allowlisted above; sanitize_sql_orderby() keeps
     31  // the clause SQL-safe on its own.
     32  $order_sql = sanitize_sql_orderby("$orderby $order");
     33  $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     34    $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}vtables WHERE title LIKE %s ORDER BY $order_sql LIMIT %d OFFSET %d", '%' . $keyword . '%', $limit, $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    4835  );
    4936
    50   $total = $wpdb->get_results($search_by_title_query);
    51   $results = $wpdb->get_results($pagination_query);
    52 
    53   if (!$results) {
    54     new WP_Error( 'TABLES_NOT_FOUND', 'Tables Not found.', array( 'status' => 404 ) );
     37  if ($wpdb->last_error) {
     38    vtables_send_db_error();
    5539  }
    5640
    … …  
    5842    $item->settings = json_decode($item->settings);
    5943    $item->source = json_decode($item->source);
    60   }
    61 
    62   $response = array();
    63   $response['tables'] = $results;
    64   $response['total'] = count($total);
    65 
    66   $rest_response = new WP_REST_Response(
     44    // Pro-only tables (see vtables_table_requires_pro): the list shows them
     45    // (their data is untouched) but they can't be opened here.
     46    $item->requires_pro = vtables_table_requires_pro($item);
     47    unset($item->woocommerce_categories, $item->plan);
     48    if ($item->table_type === 'importing') {
     49      $transient = get_transient('vtables_import_' . $item->id);
     50      if ($transient) {
     51        $item->import_progress = array(
     52          'imported_rows' => intval($transient['imported_rows']),
     53          'total_rows'    => intval($transient['total_rows']),
     54        );
     55      }
     56    }
     57  }
     58
     59  $response             = array();
     60  $response['tables']   = $results;
     61  $response['total']    = $total;
     62
     63  wp_send_json($response);
     64}
     65
     66
     67/**
     68 * DB ERROR RESPONSE
     69 * The raw $wpdb->last_error describes the schema and query; it goes to the
     70 * debug log (when enabled), never to the client.
     71 */
     72function vtables_send_db_error() {
     73  global $wpdb;
     74
     75  if (defined('WP_DEBUG') && WP_DEBUG && $wpdb->last_error) {
     76    error_log('vTables DB error: ' . $wpdb->last_error); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
     77  }
     78
     79  wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Database error.'), 500);
     80}
     81
     82
     83/**
     84 * STANDARD GUARDS
     85 * Every admin action: valid vtables_nonce on an AJAX request, manage_options.
     86 */
     87function vtables_require_admin_ajax() {
     88  if (
     89    !check_ajax_referer('vtables_nonce', 'security', false) ||
     90    !defined('DOING_AJAX') ||
     91    !DOING_AJAX
     92  ) {
     93    wp_send_json(array('code' => 400, 'error' => 'INVALID_REQUEST', 'message' => 'Invalid request.'), 400);
     94  }
     95
     96  if (!current_user_can('manage_options')) {
     97    wp_send_json(array('code' => 403, 'error' => 'PERMISSION_DENIED', 'message' => 'Permission denied.'), 403);
     98  }
     99}
     100
     101
     102/**
     103 * REJECT PRO-ONLY TABLES (AJAX GUARD)
     104 * Google Sheets / external / WooCommerce tables, and tables with a saved row
     105 * filter (see vtables_table_requires_pro), are left untouched for vTables
     106 * Pro — none of the read/edit endpoints serve them here.
     107 */
     108function vtables_reject_pro_table($table_id) {
     109  if (vtables_table_requires_pro(vtables_table_meta($table_id))) {
     110    wp_send_json(array('code' => 403, 'error' => 'REQUIRES_PRO', 'message' => 'This table requires vTables Pro.'), 403);
     111  }
     112}
     113
     114
     115/**
     116 * FETCH TABLE BY ID.
     117 */
     118add_action('wp_ajax_vtables_fetch_table', 'vtables_fetch_table_handler');
     119add_action('wp_ajax_nopriv_vtables_fetch_table', 'vtables_fetch_table_handler');
     120function vtables_fetch_table_handler() {
     121  if (!defined('DOING_AJAX') || !DOING_AJAX) {
     122    wp_send_json(array('code' => 400, 'error' => 'INVALID_REQUEST', 'message' => 'Invalid request.'), 400);
     123  }
     124
     125  if (empty($_GET['id'])) { // phpcs:ignore WordPress.Security.NonceVerification
     126    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing required fields.'), 400);
     127  }
     128
     129  global $wpdb;
     130  $id = intval($_GET['id']); // phpcs:ignore WordPress.Security.NonceVerification
     131
     132  $table = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     133    $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}vtables WHERE id = %d", $id )
     134  );
     135
     136  if (!$table) {
     137    wp_send_json(array('code' => 404, 'error' => 'TABLE_NOT_FOUND', 'message' => 'Table by ID not found.'), 404);
     138  }
     139
     140  // Pro-only sources / saved row filter: metadata only.
     141  if (vtables_table_requires_pro($table)) {
     142    wp_send_json(array(
     143      'id'           => (int) $table->id,
     144      'title'        => $table->title,
     145      'table_type'   => $table->table_type,
     146      'requires_pro' => true,
     147      'settings'     => null,
     148      'source'       => array('columns' => array(), 'rows' => array()),
     149    ));
     150  }
     151
     152  $table->settings     = json_decode($table->settings);
     153  $table->source       = json_decode($table->source);
     154  $table->requires_pro = false;
     155  unset($table->woocommerce_categories, $table->plan);
     156
     157  // Rows live in wp_vtables_source (one record per data row). Legacy tables
     158  // (rows still inside the source cell, not yet moved by the background
     159  // migration) have no records here, so their source->rows stay as a fallback.
     160  $row_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     161    $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}vtables_source WHERE table_id = %d", $id )
     162  );
     163
     164  if ($row_count > 0) {
     165    if (!is_object($table->source)) {
     166      $table->source = new stdClass();
     167    }
     168
     169    // ALWAYS SERVER MODE for tables whose rows live in wp_vtables_source: the
     170    // SPA pages through vtables_query_rows instead of loading every row.
     171    $per_page = vtables_default_per_page();
     172
     173    $first_page = vtables_query_source_rows(array(
     174      'table_id' => $id,
     175      'page'     => 1,
     176      'per_page' => $per_page,
     177    ));
     178
     179    $table->server_mode  = true;
     180    $table->total_rows   = $row_count;
     181    $table->source->rows = array();
     182    $table->first_page   = $first_page['rows'];
     183    $table->per_page     = $per_page;
     184  }
     185
     186  wp_send_json($table);
     187}
     188
     189
     190/**
     191 * Find a title that doesn't collide with an existing row, appending
     192 * -copy, -copy-2, -copy-3, ... until unique. A single -copy check only
     193 * survives one duplication; duplicating the same title again would
     194 * collide with the previous "-copy" row, so this keeps incrementing.
     195 */
     196function vtables_generate_unique_title($wpdb, $table_name, $title) {
     197  if (!$title) {
     198    return $title;
     199  }
     200
     201  $candidate = $title;
     202  $suffix    = 1;
     203
     204  while (
     205    $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     206      $wpdb->prepare("SELECT id FROM $table_name WHERE title = %s", $candidate) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     207    )
     208  ) {
     209    $candidate = $suffix === 1 ? $title . '-copy' : $title . '-copy-' . $suffix;
     210    $suffix++;
     211  }
     212
     213  return $candidate;
     214}
     215
     216
     217/**
     218 * Seed settings.columns.visibleColumns from the parsed column list when the
     219 * incoming settings carry no explicit show/hide selection (both lists empty
     220 * or missing). The renderer treats an empty visibleColumns as "show all",
     221 * but the Settings → Columns toggles bind to it directly, so a table stored
     222 * with an empty list shows every toggle off and one click hides everything.
     223 * Legacy uploaders seed this client-side; the chunked/stream import paths
     224 * send INITIAL_SETTINGS verbatim, so it has to happen here.
     225 */
     226function vtables_seed_visible_columns($settings_json, $columns) {
     227  $decoded = json_decode((string) $settings_json, true);
     228  if (!is_array($decoded)) {
     229    return $settings_json;
     230  }
     231
     232  $cols    = isset($decoded['columns']) && is_array($decoded['columns']) ? $decoded['columns'] : array();
     233  $visible = isset($cols['visibleColumns']) && is_array($cols['visibleColumns']) ? $cols['visibleColumns'] : array();
     234  $hidden  = isset($cols['hiddenColumns']) && is_array($cols['hiddenColumns']) ? $cols['hiddenColumns'] : array();
     235
     236  if (!empty($visible) || !empty($hidden)) {
     237    return $settings_json;
     238  }
     239
     240  $names = array();
     241  foreach ((array) $columns as $col) {
     242    if (is_array($col) && isset($col['name']) && $col['name'] !== '') {
     243      $names[] = (string) $col['name'];
     244    }
     245  }
     246
     247  if (empty($names)) {
     248    return $settings_json;
     249  }
     250
     251  $cols['visibleColumns'] = $names;
     252  $cols['hiddenColumns']  = array();
     253  $decoded['columns']     = $cols;
     254
     255  $encoded = wp_json_encode($decoded);
     256  return $encoded !== false ? $encoded : $settings_json;
     257}
     258
     259
     260/**
     261 * CREATE TABLE.
     262 */
     263add_action('wp_ajax_vtables_create_table', 'vtables_create_table_handler');
     264function vtables_create_table_handler() {
     265  vtables_require_admin_ajax();
     266
     267  global $wpdb;
     268  $table_name = $wpdb->prefix . 'vtables';
     269
     270  $title      = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     271  $source     = isset($_POST['source']) ? wp_unslash($_POST['source']) : '{}'; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, decoded below
     272  $file_size  = isset($_POST['file_size']) ? sanitize_text_field(wp_unslash($_POST['file_size'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     273  $settings   = isset($_POST['settings']) ? wp_unslash($_POST['settings']) : '{}'; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, filtered below
     274  $table_type = isset($_POST['table_type']) ? sanitize_key(wp_unslash($_POST['table_type'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     275
     276  // Only file/manual sources are created here; the other sources are vTables Pro's.
     277  if (!in_array($table_type, vtables_free_table_types(), true)) {
     278    wp_send_json(array('code' => 403, 'error' => 'REQUIRES_PRO', 'message' => 'This data source requires vTables Pro.'), 403);
     279  }
     280
     281  // Settings go through the same Free settings policy as every save, so a
     282  // crafted payload can't seed Pro-only settings onto a new table.
     283  $settings = vtables_filter_settings_for_free($settings, '{}');
     284
     285  $title = vtables_generate_unique_title($wpdb, $table_name, $title);
     286
     287  // Split source: keep {columns, rows:[]} in wp_vtables; store the rows in
     288  // wp_vtables_source via the shared helper. Keeps a single read path in fetch_table
     289  // and keeps large datasets out of the one longtext cell.
     290  $source_rows = array();
     291  $source_decoded = json_decode($source, true);
     292  if (!is_array($source_decoded)) {
     293    wp_send_json(array('code' => 400, 'error' => 'INVALID_SOURCE', 'message' => 'Invalid table source.'), 400);
     294  }
     295  if (isset($source_decoded['rows']) && is_array($source_decoded['rows'])) {
     296    $source_rows = $source_decoded['rows'];
     297  }
     298  $source_decoded['rows'] = array();
     299  $source_to_store = wp_json_encode($source_decoded);
     300  if ($source_to_store !== false) {
     301    $source = $source_to_store;
     302  }
     303
     304  $result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     305    $table_name,
    67306    array(
    68       'success' => !!$results,
    69       'data' => $response,
     307      'title'      => $title,
     308      'source'     => $source,
     309      'file_size'  => $file_size,
     310      'settings'   => $settings,
     311      'table_type' => $table_type,
     312      // Stamped like vTables Pro stamps tables created without a license, so
     313      // Pro later treats it as the Free-plan table it is.
     314      'plan'       => 'free',
    70315    )
    71316  );
    72317
    73   vtables_maybe_send_nocache_headers($rest_response);
    74 
    75   return $rest_response;
    76 }
    77 
    78 
    79 /**
    80  * GET TABLE BY ID.
    81  */
    82 add_action( 'rest_api_init', function () {
    83   register_rest_route( 'vtables/v1', '/tables/(?P<id>\d+)', array(
    84     'methods' => 'GET',
    85     'callback' => 'vtables_get_by_id_handler',
    86     'permission_callback' => '__return_true',
    87   ) );
    88 } );
    89 
    90 function vtables_get_by_id_handler( WP_REST_Request $data ) {
     318  if ($result === false) {
     319    wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Failed to create record.'), 500);
     320  }
     321
     322  $new_table_id = $wpdb->insert_id;
     323
     324  if (!empty($source_rows)) {
     325    vtables_replace_source_rows($new_table_id, $source_rows);
     326  }
     327
     328  wp_send_json(array('id' => $new_table_id));
     329}
     330
     331
     332/**
     333 * SAVE TABLE CHANGES.
     334 */
     335add_action('wp_ajax_vtables_save_source', 'vtables_save_source_handler');
     336function vtables_save_source_handler() {
     337  vtables_require_admin_ajax();
     338
    91339  global $wpdb;
    92340  $table_name = $wpdb->prefix . 'vtables';
    93   $id = $data['id'];
    94 
    95   $query = $wpdb->prepare("SELECT * FROM $table_name WHERE id = %d", $id);
    96   $results = $wpdb->get_results($query);
    97 
    98   if (empty($results)) {
    99     return new WP_Error( 'TABLE_NOT_FOUND', 'Table by ID not found.', array( 'status' => 404 ) );
    100   }
    101 
    102   $results[0]->settings = json_decode($results[0]->settings);
    103   $results[0]->source = json_decode($results[0]->source);
    104   $response = $results[0];
    105 
    106   $rest_response = new WP_REST_Response(
     341
     342  $id        = isset($_POST['id']) ? intval($_POST['id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     343  $title     = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     344  $source    = isset($_POST['source']) ? wp_unslash($_POST['source']) : ''; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, decoded below
     345  $file_size = isset($_POST['fileSize']) ? sanitize_text_field(wp_unslash($_POST['fileSize'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     346
     347  if (!$id) {
     348    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing required fields.'), 400);
     349  }
     350
     351  if (!vtables_table_exists($id)) {
     352    wp_send_json(array('code' => 404, 'error' => 'TABLE_NOT_FOUND', 'message' => 'Table not found.'), 404);
     353  }
     354
     355  vtables_reject_pro_table($id);
     356
     357  // Update only title
     358  if ($title && empty($source)) {
     359    $result = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     360      $table_name,
     361      array(
     362        'title'      => $title,
     363        'updated_at' => current_time('mysql'),
     364      ),
     365      array('id' => $id)
     366    );
     367    wp_send_json($result);
     368  }
     369
     370  // Split source: persist {columns, rows:[]} on wp_vtables, store rows in
     371  // wp_vtables_source. (Still bounded by post_max_size on the incoming request —
     372  // pre-existing; fully addressed by the per-row edit endpoints in Phase 2.)
     373  $source_rows = array();
     374  $source_decoded = json_decode($source, true);
     375  if (is_array($source_decoded)) {
     376    if (isset($source_decoded['rows']) && is_array($source_decoded['rows'])) {
     377      $source_rows = $source_decoded['rows'];
     378    }
     379    $source_decoded['rows'] = array();
     380    $source_to_store = wp_json_encode($source_decoded);
     381    if ($source_to_store !== false) {
     382      $source = $source_to_store;
     383    }
     384  }
     385
     386  // Update source
     387  $result = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     388    $table_name,
    107389    array(
    108       'success' => !!$results,
    109       'data' => $response,
     390      'title'      => $title,
     391      'file_size'  => $file_size,
     392      'source'     => $source,
     393      'updated_at' => current_time('mysql'),
     394    ),
     395    array('id' => $id),
     396    array('%s', '%s', '%s', '%s'),
     397    array('%d')
     398  );
     399
     400  // Only replace stored rows when the payload actually carries rows. In server
     401  // mode the client holds only one page, so source.rows is empty here — replacing
     402  // would destroy the dataset. Per-row edits go through the dedicated endpoints.
     403  if (!empty($source_rows)) {
     404    vtables_replace_source_rows($id, $source_rows);
     405  }
     406
     407  $updated_table = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     408    $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}vtables WHERE id = %d", $id )
     409  );
     410  $updated_table->source = json_decode($updated_table->source);
     411
     412  // Reattach saved rows so the response mirrors the submitted source.
     413  if (!empty($source_rows)) {
     414    if (!is_object($updated_table->source)) {
     415      $updated_table->source = new stdClass();
     416    }
     417    $updated_table->source->rows = json_decode(wp_json_encode($source_rows));
     418  }
     419
     420  wp_send_json($updated_table);
     421}
     422
     423
     424/**
     425 * SAVE TABLE SETTINGS.
     426 */
     427add_action('wp_ajax_vtables_save_settings', 'vtables_save_settings_handler');
     428function vtables_save_settings_handler() {
     429  vtables_require_admin_ajax();
     430
     431  global $wpdb;
     432  $table_name = $wpdb->prefix . 'vtables';
     433
     434  $id         = isset($_POST['id']) ? intval($_POST['id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     435  $settings   = isset($_POST['settings']) ? wp_unslash($_POST['settings']) : '{}'; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, filtered below
     436  $updated_at = current_time('mysql');
     437
     438  if (!$id) {
     439    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing required fields.'), 400);
     440  }
     441
     442  $table_row = $wpdb->get_row( $wpdb->prepare( "SELECT id, table_type, settings FROM {$wpdb->prefix}vtables WHERE id = %d", $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     443
     444  if ($table_row === null) {
     445    wp_send_json(array('code' => 404, 'error' => 'TABLE_NOT_FOUND', 'message' => 'Table not found.'), 404);
     446  }
     447
     448  vtables_reject_pro_table($id);
     449
     450  // Settings that belong to vTables Pro keep their stored values: this plugin
     451  // neither adds nor erases them (see includes/settings-policy.php), so a
     452  // table's Pro settings survive editing here and apply again under Pro.
     453  $settings = vtables_filter_settings_for_free($settings, (string) $table_row->settings);
     454
     455  $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     456    $table_name,
     457    array(
     458      'settings'   => $settings,
     459      'updated_at' => $updated_at,
     460    ),
     461    array('id' => $id),
     462    array('%s', '%s'),
     463    array('%d')
     464  );
     465
     466  wp_send_json(array('updated_at' => $updated_at));
     467}
     468
     469
     470/**
     471 * DELETE TABLE.
     472 */
     473add_action('wp_ajax_vtables_delete_table', 'vtables_delete_table_handler');
     474function vtables_delete_table_handler() {
     475  vtables_require_admin_ajax();
     476
     477  if (empty($_POST['id'])) { // phpcs:ignore WordPress.Security.NonceVerification
     478    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table ID.'), 400);
     479  }
     480
     481  global $wpdb;
     482  $table_name = $wpdb->prefix . 'vtables';
     483  $id = intval($_POST['id']); // phpcs:ignore WordPress.Security.NonceVerification
     484
     485  // $wpdb->delete() returns 0 (not false) for a row that was never there, so
     486  // without this the caller gets an indistinguishable 200 for a bogus id.
     487  if (!vtables_table_exists($id)) {
     488    wp_send_json(array('code' => 404, 'error' => 'TABLE_NOT_FOUND', 'message' => 'Table not found.'), 404);
     489  }
     490
     491  $result = $wpdb->delete( $table_name, array('id' => $id) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     492
     493  if ($result === false) {
     494    wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Failed to delete table.'), 500);
     495  }
     496
     497  // Remove the table's rows so we don't orphan records in wp_vtables_source.
     498  $wpdb->delete( $wpdb->prefix . 'vtables_source', array('table_id' => $id), array('%d') ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     499
     500  wp_send_json(array('id' => $id));
     501}
     502
     503
     504/**
     505 * PARSE EXCEL FILE.
     506 */
     507add_action('wp_ajax_vtables_parse_excel', 'vtables_parse_excel_handler');
     508function vtables_parse_excel_handler() {
     509  vtables_require_admin_ajax();
     510
     511  if (!isset($_FILES['file']['error'], $_FILES['file']['name'], $_FILES['file']['size'], $_FILES['file']['tmp_name']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { // phpcs:ignore WordPress.Security.NonceVerification
     512    wp_send_json(array('code' => 400, 'error' => 'MISSING_FILE', 'message' => 'No file uploaded or upload error.'), 400);
     513  }
     514
     515  $allowed_extensions = array('xlsx', 'xls');
     516  $file_name          = sanitize_file_name(wp_unslash($_FILES['file']['name'])); // phpcs:ignore WordPress.Security.NonceVerification
     517  $extension          = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
     518
     519  if (!in_array($extension, $allowed_extensions, true)) {
     520    wp_send_json(array('code' => 400, 'error' => 'INVALID_FILE_TYPE', 'message' => 'Only .xlsx and .xls files are allowed.'), 400);
     521  }
     522
     523  $max_size = 52428800; // 50 MB
     524  if (intval($_FILES['file']['size']) > $max_size) { // phpcs:ignore WordPress.Security.NonceVerification
     525    wp_send_json(array('code' => 400, 'error' => 'FILE_TOO_LARGE', 'message' => 'File exceeds the 50 MB size limit.'), 400);
     526  }
     527
     528  $temp_dir = vtables_uploads_dir();
     529
     530  if (!$temp_dir) {
     531    wp_send_json(array('code' => 500, 'error' => 'UPLOAD_DIR_ERROR', 'message' => 'Could not create upload directory.'), 500);
     532  }
     533
     534  $temp_file = vtables_store_uploaded_file($_FILES['file'], $temp_dir, wp_unique_filename($temp_dir, $file_name)); // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by wp_handle_upload()
     535
     536  if (!$temp_file) {
     537    wp_send_json(array('code' => 500, 'error' => 'FILE_MOVE_ERROR', 'message' => 'Could not move uploaded file.'), 500);
     538  }
     539
     540  $raw_rows = array();
     541
     542  if ($extension === 'xlsx') {
     543    $xlsx = \Shuchkin\SimpleXLSX::parse($temp_file);
     544    if (!$xlsx) {
     545      wp_delete_file($temp_file);
     546      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Failed to parse .xlsx file.'), 500);
     547    }
     548    $raw_rows = $xlsx->rows();
     549  } else {
     550    $xls = \Shuchkin\SimpleXLS::parse($temp_file);
     551    if (!$xls) {
     552      wp_delete_file($temp_file);
     553      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Failed to parse .xls file.'), 500);
     554    }
     555    $raw_rows = $xls->rows();
     556  }
     557
     558  wp_delete_file($temp_file);
     559
     560  if (empty($raw_rows)) {
     561    wp_send_json(array('code' => 400, 'error' => 'EMPTY_FILE', 'message' => 'The file contains no data.'), 400);
     562  }
     563
     564  $headers = array_shift($raw_rows);
     565  $columns = array();
     566  foreach ($headers as $header) {
     567    $name      = (string) $header;
     568    $columns[] = array('name' => $name, 'label' => $name);
     569  }
     570
     571  $rows = array();
     572  foreach ($raw_rows as $raw_row) {
     573    // Skip entirely empty rows.
     574    $non_empty = array_filter($raw_row, function($v) { return $v !== '' && $v !== null; });
     575    if (empty($non_empty)) {
     576      continue;
     577    }
     578    $row_data = array();
     579    foreach ($headers as $index => $header) {
     580      $row_data[(string) $header] = isset($raw_row[$index]) ? $raw_row[$index] : '';
     581    }
     582    $rows[] = $row_data;
     583  }
     584
     585  wp_send_json(array('columns' => $columns, 'rows' => $rows));
     586}
     587
     588
     589/**
     590 * UPLOAD CHUNK.
     591 * Accepts one binary chunk of a large file and stores it to disk.
     592 */
     593add_action('wp_ajax_vtables_upload_chunk', 'vtables_upload_chunk_handler');
     594function vtables_upload_chunk_handler() {
     595  vtables_require_admin_ajax();
     596
     597  // Validate and sanitize inputs.
     598  $session_id   = isset($_POST['session_id']) ? sanitize_text_field(wp_unslash($_POST['session_id'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     599  $chunk_index  = isset($_POST['chunk_index']) ? intval($_POST['chunk_index']) : -1; // phpcs:ignore WordPress.Security.NonceVerification
     600  $total_chunks = isset($_POST['total_chunks']) ? intval($_POST['total_chunks']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     601
     602  if (!preg_match('/^[0-9a-f\-]{36}$/i', $session_id) || $chunk_index < 0 || $total_chunks < 1) {
     603    wp_send_json(array('code' => 400, 'error' => 'INVALID_PARAMS', 'message' => 'Invalid parameters.'), 400);
     604  }
     605
     606  if (!isset($_FILES['file']['error'], $_FILES['file']['name'], $_FILES['file']['size'], $_FILES['file']['tmp_name']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { // phpcs:ignore WordPress.Security.NonceVerification
     607    wp_send_json(array('code' => 400, 'error' => 'MISSING_FILE', 'message' => 'No file chunk uploaded or upload error.'), 400);
     608  }
     609
     610  // Chunk must not exceed the server upload limit on its own.
     611  if (intval($_FILES['file']['size']) > wp_max_upload_size()) { // phpcs:ignore WordPress.Security.NonceVerification
     612    wp_send_json(array('code' => 400, 'error' => 'CHUNK_TOO_LARGE', 'message' => 'Chunk exceeds server upload limit.'), 400);
     613  }
     614
     615  $uploads_dir = vtables_uploads_dir();
     616  $chunk_dir   = $uploads_dir ? $uploads_dir . 'chunks/' . $session_id . '/' : '';
     617
     618  if (!$uploads_dir || !wp_mkdir_p($chunk_dir)) {
     619    wp_send_json(array('code' => 500, 'error' => 'UPLOAD_DIR_ERROR', 'message' => 'Could not create chunk directory.'), 500);
     620  }
     621
     622  if (!vtables_store_uploaded_file($_FILES['file'], $chunk_dir, 'chunk_' . $chunk_index)) { // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by wp_handle_upload()
     623    wp_send_json(array('code' => 500, 'error' => 'FILE_MOVE_ERROR', 'message' => 'Could not store chunk.'), 500);
     624  }
     625
     626  wp_send_json(array('received' => $chunk_index));
     627}
     628
     629
     630/**
     631 * FINALIZE UPLOAD.
     632 * Assembles chunks, extracts column headers, creates a table skeleton with
     633 * table_type = 'importing', and persists import state in a WP transient.
     634 */
     635add_action('wp_ajax_vtables_finalize_upload', 'vtables_finalize_upload_handler');
     636function vtables_finalize_upload_handler() {
     637  vtables_require_admin_ajax();
     638
     639  $session_id              = isset($_POST['session_id'])             ? sanitize_text_field(wp_unslash($_POST['session_id'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     640  $file_name               = isset($_POST['file_name'])              ? sanitize_file_name(wp_unslash($_POST['file_name'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     641  $title                   = isset($_POST['title'])                   ? sanitize_text_field(wp_unslash($_POST['title'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     642  $total_chunks            = isset($_POST['total_chunks'])            ? intval($_POST['total_chunks']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     643  $file_size               = isset($_POST['file_size'])              ? sanitize_text_field(wp_unslash($_POST['file_size'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     644  $settings                = isset($_POST['settings'])               ? wp_unslash($_POST['settings']) : '{}'; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, filtered below
     645  $original_type_override  = isset($_POST['original_type'])          ? sanitize_key(wp_unslash($_POST['original_type'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     646
     647  // The override may only name another file/manual type.
     648  if ($original_type_override !== '' && !in_array($original_type_override, vtables_free_table_types(), true)) {
     649    $original_type_override = '';
     650  }
     651
     652  if (!preg_match('/^[0-9a-f\-]{36}$/i', $session_id) || !$file_name || $total_chunks < 1) {
     653    wp_send_json(array('code' => 400, 'error' => 'INVALID_PARAMS', 'message' => 'Invalid parameters.'), 400);
     654  }
     655
     656  $allowed_extensions = array('xlsx', 'xls', 'csv', 'json');
     657  $extension          = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
     658
     659  if (!in_array($extension, $allowed_extensions, true)) {
     660    wp_send_json(array('code' => 400, 'error' => 'INVALID_FILE_TYPE', 'message' => 'Unsupported file type.'), 400);
     661  }
     662
     663  $assembled_dir = vtables_uploads_dir();
     664  $chunk_dir     = $assembled_dir . 'chunks/' . $session_id . '/';
     665
     666  if (!$assembled_dir) {
     667    wp_send_json(array('code' => 500, 'error' => 'UPLOAD_DIR_ERROR', 'message' => 'Could not create upload directory.'), 500);
     668  }
     669
     670  // Assemble chunks in order.
     671  $assembled_path = $assembled_dir . $session_id . '.' . $extension;
     672  $out = fopen($assembled_path, 'wb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     673  if (!$out) {
     674    wp_send_json(array('code' => 500, 'error' => 'ASSEMBLE_ERROR', 'message' => 'Could not create assembled file.'), 500);
     675  }
     676
     677  for ($i = 0; $i < $total_chunks; $i++) {
     678    $chunk_file = $chunk_dir . 'chunk_' . $i;
     679    if (!file_exists($chunk_file)) {
     680      fclose($out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     681      wp_delete_file($assembled_path);
     682      wp_send_json(array('code' => 400, 'error' => 'MISSING_CHUNK', 'message' => 'Missing chunk ' . $i . '.'), 400);
     683    }
     684    $chunk_data = file_get_contents($chunk_file); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
     685    fwrite($out, $chunk_data); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     686  }
     687  fclose($out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     688
     689  // Remove chunk directory.
     690  vtables_remove_directory($chunk_dir);
     691
     692  // Hard 10 MB cap (defense in depth; the client rejects earlier). A larger
     693  // assembled file is removed before any parser loads it into memory — a 10 MB
     694  // xlsx can already expand to hundreds of MB inside SimpleXLSX on a 128 MB host.
     695  if (filesize($assembled_path) > vtables_max_upload_bytes()) {
     696    wp_delete_file($assembled_path);
     697    wp_send_json(array('code' => 400, 'error' => 'FILE_TOO_LARGE', 'message' => 'File exceeds the 10 MB size limit.'), 400);
     698  }
     699
     700  // Extract column headers, count rows, and collect a small sample for type
     701  // sniffing (first ~200 data rows). $original_extension is kept before xlsx/xls
     702  // (and now json) get normalized to a streamable CSV cache.
     703  $columns            = array();
     704  $total_rows         = 0;
     705  $original_extension = $extension;
     706  $sample_rows        = array();
     707  $sample_limit       = 200;
     708
     709  if ($extension === 'xlsx') {
     710    $xlsx = \Shuchkin\SimpleXLSX::parse($assembled_path);
     711    if (!$xlsx) {
     712      wp_delete_file($assembled_path);
     713      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Failed to parse .xlsx file.'), 500);
     714    }
     715    $raw_rows = $xlsx->rows();
     716    if (empty($raw_rows)) {
     717      wp_delete_file($assembled_path);
     718      wp_send_json(array('code' => 400, 'error' => 'EMPTY_FILE', 'message' => 'The file contains no data.'), 400);
     719    }
     720    $headers = array_shift($raw_rows);
     721    foreach ($headers as $header) {
     722      $name      = (string) $header;
     723      $columns[] = array('name' => $name, 'label' => $name);
     724    }
     725    // Convert to a streamable CSV cache so each batch call reads O(batch) not O(total).
     726    $csv_cache_path = $assembled_dir . $session_id . '_cache.csv';
     727    $fh_out = fopen($csv_cache_path, 'w'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     728    if ($fh_out) {
     729      fputcsv($fh_out, $headers); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     730      $actual_rows = 0;
     731      foreach ($raw_rows as $raw_row) {
     732        $non_empty = array_filter($raw_row, function ($v) { return $v !== '' && $v !== null; });
     733        if (empty($non_empty)) continue;
     734        $padded = array();
     735        $assoc  = array();
     736        foreach ($headers as $idx => $h) {
     737          $cell = isset($raw_row[$idx]) ? $raw_row[$idx] : '';
     738          $padded[] = $cell;
     739          $assoc[(string) $h] = $cell;
     740        }
     741        fputcsv($fh_out, $padded); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     742        if (count($sample_rows) < $sample_limit) {
     743          $sample_rows[] = $assoc;
     744        }
     745        $actual_rows++;
     746      }
     747      fclose($fh_out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     748      wp_delete_file($assembled_path);
     749      $assembled_path = $csv_cache_path;
     750      $extension      = 'csv';
     751      $total_rows     = $actual_rows;
     752    } else {
     753      $total_rows = count($raw_rows);
     754    }
     755  } elseif ($extension === 'xls') {
     756    $xls = \Shuchkin\SimpleXLS::parse($assembled_path);
     757    if (!$xls) {
     758      wp_delete_file($assembled_path);
     759      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Failed to parse .xls file.'), 500);
     760    }
     761    $raw_rows = $xls->rows();
     762    if (empty($raw_rows)) {
     763      wp_delete_file($assembled_path);
     764      wp_send_json(array('code' => 400, 'error' => 'EMPTY_FILE', 'message' => 'The file contains no data.'), 400);
     765    }
     766    $headers = array_shift($raw_rows);
     767    foreach ($headers as $header) {
     768      $name      = (string) $header;
     769      $columns[] = array('name' => $name, 'label' => $name);
     770    }
     771    // Convert to a streamable CSV cache so each batch call reads O(batch) not O(total).
     772    $csv_cache_path = $assembled_dir . $session_id . '_cache.csv';
     773    $fh_out = fopen($csv_cache_path, 'w'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     774    if ($fh_out) {
     775      fputcsv($fh_out, $headers); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     776      $actual_rows = 0;
     777      foreach ($raw_rows as $raw_row) {
     778        $non_empty = array_filter($raw_row, function ($v) { return $v !== '' && $v !== null; });
     779        if (empty($non_empty)) continue;
     780        $padded = array();
     781        $assoc  = array();
     782        foreach ($headers as $idx => $h) {
     783          $cell = isset($raw_row[$idx]) ? $raw_row[$idx] : '';
     784          $padded[] = $cell;
     785          $assoc[(string) $h] = $cell;
     786        }
     787        fputcsv($fh_out, $padded); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     788        if (count($sample_rows) < $sample_limit) {
     789          $sample_rows[] = $assoc;
     790        }
     791        $actual_rows++;
     792      }
     793      fclose($fh_out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     794      wp_delete_file($assembled_path);
     795      $assembled_path = $csv_cache_path;
     796      $extension      = 'csv';
     797      $total_rows     = $actual_rows;
     798    } else {
     799      $total_rows = count($raw_rows);
     800    }
     801  } elseif ($extension === 'csv') {
     802    $fh = fopen($assembled_path, 'r'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     803    if (!$fh) {
     804      wp_delete_file($assembled_path);
     805      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Could not open CSV file.'), 500);
     806    }
     807    $headers = fgetcsv($fh);
     808    if (empty($headers)) {
     809      fclose($fh); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     810      wp_delete_file($assembled_path);
     811      wp_send_json(array('code' => 400, 'error' => 'EMPTY_FILE', 'message' => 'The CSV file contains no data.'), 400);
     812    }
     813    foreach ($headers as $header) {
     814      $name      = (string) $header;
     815      $columns[] = array('name' => $name, 'label' => $name);
     816    }
     817    while (($csv_row = fgetcsv($fh)) !== false) {
     818      if (count($sample_rows) < $sample_limit) {
     819        $assoc = array();
     820        foreach ($headers as $idx => $h) {
     821          $assoc[(string) $h] = isset($csv_row[$idx]) ? $csv_row[$idx] : '';
     822        }
     823        $sample_rows[] = $assoc;
     824      }
     825      $total_rows++;
     826    }
     827    fclose($fh); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     828  } elseif ($extension === 'json') {
     829    $raw_content = file_get_contents($assembled_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
     830    $decoded     = json_decode($raw_content, true);
     831    if (!is_array($decoded) || empty($decoded)) {
     832      wp_delete_file($assembled_path);
     833      wp_send_json(array('code' => 400, 'error' => 'EMPTY_FILE', 'message' => 'The JSON file contains no data.'), 400);
     834    }
     835    $first_item = reset($decoded);
     836    if (!is_array($first_item)) {
     837      wp_delete_file($assembled_path);
     838      wp_send_json(array('code' => 400, 'error' => 'INVALID_FORMAT', 'message' => 'JSON must be an array of objects.'), 400);
     839    }
     840    $headers = array_keys($first_item);
     841    foreach ($headers as $key) {
     842      $name      = (string) $key;
     843      $columns[] = array('name' => $name, 'label' => $name);
     844    }
     845    // Normalize JSON to the same streamable CSV cache that xlsx/xls produce so
     846    // import_rows_batch never re-decodes the whole file on every batch (it just
     847    // streams the CSV). Falls back to raw JSON if the cache can't be written.
     848    $csv_cache_path = $assembled_dir . $session_id . '_cache.csv';
     849    $fh_out = fopen($csv_cache_path, 'w'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     850    if ($fh_out) {
     851      fputcsv($fh_out, $headers); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     852      $actual_rows = 0;
     853      foreach ($decoded as $item) {
     854        if (!is_array($item)) continue;
     855        $padded = array();
     856        $assoc  = array();
     857        foreach ($headers as $h) {
     858          $cell = isset($item[$h]) ? $item[$h] : '';
     859          // Flatten nested values so the CSV cell stays scalar; import re-keys by header.
     860          if (is_array($cell)) {
     861            $cell = wp_json_encode($cell);
     862          }
     863          $padded[] = $cell;
     864          $assoc[(string) $h] = $cell;
     865        }
     866        fputcsv($fh_out, $padded); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     867        if (count($sample_rows) < $sample_limit) {
     868          $sample_rows[] = $assoc;
     869        }
     870        $actual_rows++;
     871      }
     872      fclose($fh_out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     873      wp_delete_file($assembled_path);
     874      $assembled_path = $csv_cache_path;
     875      $extension      = 'csv';
     876      $total_rows     = $actual_rows;
     877    } else {
     878      $total_rows = count($decoded);
     879    }
     880  }
     881
     882  // Sniff a data type per column from the sample so the upload-step UI can
     883  // pre-fill the data-type selectors. Pure inference; nothing is persisted yet.
     884  $sniffed_types = vtables_sniff_column_types($headers, $sample_rows);
     885  foreach ($columns as &$col_ref) {
     886    $cname = isset($col_ref['name']) ? (string) $col_ref['name'] : '';
     887    $col_ref['dataType'] = isset($sniffed_types[$cname]) ? $sniffed_types[$cname] : 'text';
     888  }
     889  unset($col_ref);
     890
     891  // Determine original table_type. The map doubles as a whitelist; rows
     892  // imported before xls/xlsx were split carry the legacy merged value 'excel'.
     893  $type_map = array('xlsx' => 'xlsx', 'xls' => 'xls', 'csv' => 'csv', 'json' => 'json');
     894  $original_type = isset($type_map[$original_extension]) ? $type_map[$original_extension] : 'csv';
     895
     896  // Deduplicate title.
     897  global $wpdb;
     898  $vtables_table = $wpdb->prefix . 'vtables';
     899
     900  $title = vtables_generate_unique_title($wpdb, $vtables_table, $title);
     901
     902  $source = wp_json_encode(array('columns' => $columns, 'rows' => array()));
     903
     904  if ($source === false) {
     905    wp_delete_file($assembled_path);
     906    wp_send_json(array('code' => 500, 'error' => 'ENCODE_ERROR', 'message' => 'Failed to encode table structure.'), 500);
     907  }
     908
     909  $result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     910    $vtables_table,
     911    array(
     912      'title'      => $title,
     913      'source'     => $source,
     914      'table_type' => 'importing',
     915      'settings'   => vtables_filter_settings_for_free(
     916        vtables_seed_visible_columns(!empty($settings) ? $settings : '{}', $columns),
     917        '{}'
     918      ),
     919      'file_size'  => $file_size,
     920      'plan'       => 'free',
    110921    )
    111922  );
    112923
    113   vtables_maybe_send_nocache_headers($rest_response);
    114 
    115   return $rest_response;
    116 }
    117 
    118 
    119 /**
    120  * CREATE TABLE.
    121  */
    122 add_action( 'rest_api_init', function () {
    123   register_rest_route( 'vtables/v1', '/tables', array(
    124     'methods' => 'POST',
    125     'callback' => 'vtables_create_record_handler',
    126     'permission_callback' => 'vtables_admin_permission_callback',
    127   ) );
    128 } );
    129 
    130 function vtables_create_record_handler( WP_REST_Request $data ) {
     924  if ($result === false) {
     925    wp_delete_file($assembled_path);
     926    wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Failed to create table record.'), 500);
     927  }
     928
     929  $table_id = $wpdb->insert_id;
     930
     931  set_transient(
     932    'vtables_import_' . $table_id,
     933    array(
     934      'session_id'     => $session_id,
     935      'file_path'      => $assembled_path,
     936      'file_ext'       => $extension,
     937      'total_rows'     => $total_rows,
     938      'imported_rows'  => 0,
     939      'original_type'  => $original_type_override ?: $original_type,
     940      'headers'        => $headers,
     941      // vTables Pro holds the import here until the user picks indexed
     942      // columns for server-side sort/filter; there is no such step here.
     943      'config_pending' => false,
     944      'index_map'      => array(),
     945    ),
     946    DAY_IN_SECONDS
     947  );
     948
     949  wp_send_json(array(
     950    'table_id'       => $table_id,
     951    'title'          => $title,
     952    'total_rows'     => $total_rows,
     953    'columns'        => $columns,
     954    'config_pending' => false,
     955  ));
     956}
     957
     958
     959/**
     960 * IMPORT ROWS BATCH.
     961 * Reads a batch of rows from the assembled file and appends them to the DB.
     962 */
     963add_action('wp_ajax_vtables_import_rows_batch', 'vtables_import_rows_batch_handler');
     964function vtables_import_rows_batch_handler() {
     965  vtables_require_admin_ajax();
     966
     967  $table_id = isset($_POST['table_id']) ? intval($_POST['table_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     968  if (!$table_id) {
     969    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table_id.'), 400);
     970  }
     971
     972  $transient = get_transient('vtables_import_' . $table_id);
     973  if (!$transient) {
     974    wp_send_json(array('code' => 404, 'error' => 'IMPORT_NOT_FOUND', 'message' => 'Import session not found or expired.'), 404);
     975  }
     976
     977  // Hold off until the upload-step index/type config is persisted (or skipped).
     978  // The poller can call this immediately after finalize; returning early keeps
     979  // it harmless and lets the user pick indexed columns before rows are written.
     980  if (!empty($transient['config_pending'])) {
     981    wp_send_json(array(
     982      'imported_rows'  => intval($transient['imported_rows']),
     983      'total_rows'     => intval($transient['total_rows']),
     984      'done'           => false,
     985      'config_pending' => true,
     986    ));
     987  }
     988
     989  $file_path     = $transient['file_path'];
     990  $file_ext      = $transient['file_ext'];
     991  $total_rows    = intval($transient['total_rows']);
     992  $imported_rows = intval($transient['imported_rows']);
     993  $original_type = $transient['original_type'];
     994  $headers       = isset($transient['headers']) ? $transient['headers'] : array();
     995  // Resolved column -> {type, slot} map (from persist_index_config). Empty = no
     996  // indexed columns; slot sub-columns stay NULL for this table.
     997  $index_map     = isset($transient['index_map']) && is_array($transient['index_map']) ? $transient['index_map'] : array();
     998
     999  // Guard: headers must be present in the transient.
     1000  if (empty($headers)) {
     1001    wp_send_json(array('code' => 500, 'error' => 'MISSING_HEADERS', 'message' => 'Import metadata is incomplete. Please restart the import.'), 500);
     1002  }
     1003
     1004  $batch_size = 500;
     1005
    1311006  global $wpdb;
    132   $table_name = $wpdb->prefix . 'vtables';
    133   $title = $data['title'];
    134   $source = json_encode($data['source']);
    135   $file_size = $data['file_size'];
    136   $external_source = $data['external_source'];
    137   $settings = json_encode($data['settings']);
    138   $table_type = $data['table_type'];
    139   $woocommerce_categories = json_encode($data['woocommerce_categories']);
    140 
    141   if ($title) {
    142     $query = $wpdb->prepare(
    143       "SELECT * FROM $table_name WHERE title = %s",
    144       addslashes($title)
     1007  $vtables_table = $wpdb->prefix . 'vtables';
     1008  $source_table  = $wpdb->prefix . 'vtables_source';
     1009
     1010  $batch_rows = array();
     1011
     1012  if ($file_ext === 'xlsx') {
     1013    $xlsx = \Shuchkin\SimpleXLSX::parse($file_path);
     1014    if (!$xlsx) {
     1015      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Failed to parse .xlsx file.'), 500);
     1016    }
     1017    $all_rows = $xlsx->rows();
     1018    array_shift($all_rows); // remove header row
     1019    $slice = array_slice($all_rows, $imported_rows, $batch_size);
     1020    foreach ($slice as $raw_row) {
     1021      $non_empty = array_filter($raw_row, function($v) { return $v !== '' && $v !== null; });
     1022      if (empty($non_empty)) continue;
     1023      $row_data = array();
     1024      foreach ($headers as $idx => $header) {
     1025        $row_data[$header] = isset($raw_row[$idx]) ? $raw_row[$idx] : '';
     1026      }
     1027      $batch_rows[] = $row_data;
     1028    }
     1029  } elseif ($file_ext === 'xls') {
     1030    $xls = \Shuchkin\SimpleXLS::parse($file_path);
     1031    if (!$xls) {
     1032      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Failed to parse .xls file.'), 500);
     1033    }
     1034    $all_rows = $xls->rows();
     1035    array_shift($all_rows);
     1036    $slice = array_slice($all_rows, $imported_rows, $batch_size);
     1037    foreach ($slice as $raw_row) {
     1038      $non_empty = array_filter($raw_row, function($v) { return $v !== '' && $v !== null; });
     1039      if (empty($non_empty)) continue;
     1040      $row_data = array();
     1041      foreach ($headers as $idx => $header) {
     1042        $row_data[$header] = isset($raw_row[$idx]) ? $raw_row[$idx] : '';
     1043      }
     1044      $batch_rows[] = $row_data;
     1045    }
     1046  } elseif ($file_ext === 'csv') {
     1047    $fh = fopen($file_path, 'r'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     1048    if (!$fh) {
     1049      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Could not open CSV file.'), 500);
     1050    }
     1051    fgetcsv($fh); // skip header
     1052    $current_line = 0;
     1053    while (($csv_row = fgetcsv($fh)) !== false) {
     1054      if ($current_line < $imported_rows) {
     1055        $current_line++;
     1056        continue;
     1057      }
     1058      if ($current_line >= $imported_rows + $batch_size) {
     1059        break;
     1060      }
     1061      $non_empty = array_filter($csv_row, function($v) { return $v !== '' && $v !== null; });
     1062      if (!empty($non_empty)) {
     1063        $row_data = array();
     1064        foreach ($headers as $idx => $header) {
     1065          $row_data[$header] = isset($csv_row[$idx]) ? $csv_row[$idx] : '';
     1066        }
     1067        $batch_rows[] = $row_data;
     1068      }
     1069      $current_line++;
     1070    }
     1071    fclose($fh); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     1072  } elseif ($file_ext === 'json') {
     1073    $raw_content = file_get_contents($file_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
     1074    $decoded     = json_decode($raw_content, true);
     1075    if (!is_array($decoded)) {
     1076      wp_send_json(array('code' => 500, 'error' => 'PARSE_ERROR', 'message' => 'Could not parse JSON file.'), 500);
     1077    }
     1078    $slice = array_slice($decoded, $imported_rows, $batch_size);
     1079    foreach ($slice as $item) {
     1080      if (!is_array($item)) continue;
     1081      $row_data = array();
     1082      foreach ($headers as $header) {
     1083        $row_data[$header] = isset($item[$header]) ? $item[$header] : '';
     1084      }
     1085      $batch_rows[] = $row_data;
     1086    }
     1087  }
     1088
     1089  // Insert each row of the batch as its own record in wp_vtables_source. Each
     1090  // INSERT packet only carries this batch (~500 small rows) and the largest single
     1091  // value the server handles is one row — never the full accumulated dataset — so
     1092  // the max_allowed_packet ceiling that broke the old CONCAT-into-one-cell approach
     1093  // no longer applies. row_index stays contiguous per table_id. The 10 typed slot
     1094  // sub-columns are populated from the resolved index map so indexed columns get
     1095  // fast index-backed sort/filter (NULL when unset / uncoercible). See Phase C/D.
     1096  $slot_cols = vtables_slot_columns();
     1097  $col_list  = array_merge(array('table_id', 'row_index', 'row_json', 'search_blob'), $slot_cols);
     1098  $values = array();
     1099  $params = array();
     1100  $i      = 0;
     1101  foreach (array_values($batch_rows) as $row) {
     1102    $row_json = wp_json_encode($row);
     1103    if ($row_json === false) {
     1104      continue;
     1105    }
     1106    $slot_values = vtables_row_index_values($row, $index_map);
     1107
     1108    $placeholders = array('%d', '%d', '%s', '%s');
     1109    $params[] = $table_id;
     1110    $params[] = $imported_rows + $i;
     1111    $params[] = $row_json;
     1112    $params[] = vtables_row_search_blob($row);
     1113    foreach ($slot_cols as $slot_col) {
     1114      if (!isset($slot_values[$slot_col]) || $slot_values[$slot_col] === null) {
     1115        $placeholders[] = 'NULL';
     1116      } else {
     1117        $placeholders[] = '%s';
     1118        $params[] = $slot_values[$slot_col];
     1119      }
     1120    }
     1121    $values[] = '(' . implode(', ', $placeholders) . ')';
     1122    $i++;
     1123  }
     1124
     1125  $batch_count  = count($values);
     1126  $new_imported = $imported_rows + $batch_count;
     1127  $done         = $new_imported >= $total_rows;
     1128
     1129  if ($batch_count > 0) {
     1130    $sql = "INSERT INTO {$source_table} (" . implode(', ', $col_list) . ") VALUES " . implode(', ', $values);
     1131    $wpdb->query( $wpdb->prepare($sql, $params) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql holds only fixed column names and placeholders; every value goes through prepare()
     1132    if ($wpdb->last_error) {
     1133      vtables_send_db_error();
     1134    }
     1135  }
     1136
     1137  // Flip table_type (importing -> original) when done; always bump updated_at.
     1138  // source stays {columns:[...], rows:[]} on wp_vtables; rows live in wp_vtables_source.
     1139  $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1140    $vtables_table,
     1141    array('table_type' => $done ? $original_type : 'importing', 'updated_at' => current_time('mysql')),
     1142    array('id' => $table_id),
     1143    array('%s', '%s'),
     1144    array('%d')
     1145  );
     1146
     1147  if ($done) {
     1148    wp_delete_file($file_path);
     1149    delete_transient('vtables_import_' . $table_id);
     1150  } else {
     1151    $transient['imported_rows'] = $new_imported;
     1152    set_transient('vtables_import_' . $table_id, $transient, DAY_IN_SECONDS);
     1153  }
     1154
     1155  wp_send_json(array(
     1156    'imported_rows' => $new_imported,
     1157    'total_rows'    => $total_rows,
     1158    'done'          => $done,
     1159  ));
     1160}
     1161
     1162
     1163/**
     1164 * CANCEL IMPORT.
     1165 * Deletes the in-progress table, the assembled file, and the import transient.
     1166 */
     1167add_action('wp_ajax_vtables_cancel_import', 'vtables_cancel_import_handler');
     1168function vtables_cancel_import_handler() {
     1169  vtables_require_admin_ajax();
     1170
     1171  $table_id = isset($_POST['table_id']) ? intval($_POST['table_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1172  if (!$table_id) {
     1173    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table_id.'), 400);
     1174  }
     1175
     1176  // Only an import in progress can be cancelled; finished tables go through
     1177  // delete_table.
     1178  $meta = vtables_table_meta($table_id);
     1179  if (!$meta || $meta->table_type !== 'importing') {
     1180    wp_send_json(array('code' => 409, 'error' => 'NOT_IMPORTING', 'message' => 'This table is not being imported.'), 409);
     1181  }
     1182
     1183  $transient = get_transient('vtables_import_' . $table_id);
     1184  if ($transient) {
     1185    // Remove assembled file.
     1186    if (!empty($transient['file_path']) && file_exists($transient['file_path'])) {
     1187      wp_delete_file($transient['file_path']);
     1188    }
     1189    // Remove chunk dir if still present (upload was cancelled before finalize).
     1190    if (!empty($transient['session_id']) && preg_match('/^[0-9a-f\-]{36}$/i', $transient['session_id'])) {
     1191      $chunk_dir = vtables_uploads_dir() . 'chunks/' . $transient['session_id'] . '/';
     1192      if (is_dir($chunk_dir)) {
     1193        vtables_remove_directory($chunk_dir);
     1194      }
     1195    }
     1196    delete_transient('vtables_import_' . $table_id);
     1197  }
     1198
     1199  global $wpdb;
     1200  $vtables_table = $wpdb->prefix . 'vtables';
     1201  $wpdb->delete($vtables_table, array('id' => $table_id), array('%d')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1202  $wpdb->delete($wpdb->prefix . 'vtables_source', array('table_id' => $table_id), array('%d')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1203
     1204  wp_send_json(array('success' => true));
     1205}
     1206
     1207
     1208/**
     1209 * CANCEL CHUNK UPLOAD.
     1210 * Removes the chunk directory for an in-progress (pre-finalize) upload session.
     1211 */
     1212add_action('wp_ajax_vtables_cancel_chunk_upload', 'vtables_cancel_chunk_upload_handler');
     1213function vtables_cancel_chunk_upload_handler() {
     1214  vtables_require_admin_ajax();
     1215
     1216  $session_id = isset($_POST['session_id']) ? sanitize_text_field(wp_unslash($_POST['session_id'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification
     1217
     1218  if (!preg_match('/^[0-9a-f\-]{36}$/i', $session_id)) {
     1219    wp_send_json(array('code' => 400, 'error' => 'INVALID_PARAMS', 'message' => 'Invalid session ID.'), 400);
     1220  }
     1221
     1222  $chunk_dir = vtables_uploads_dir() . 'chunks/' . $session_id . '/';
     1223
     1224  if (is_dir($chunk_dir)) {
     1225    vtables_remove_directory($chunk_dir);
     1226  }
     1227
     1228  wp_send_json(array('success' => true));
     1229}
     1230
     1231
     1232/**
     1233 * QUERY ROWS (server-mode paginated read).
     1234 */
     1235add_action('wp_ajax_vtables_query_rows', 'vtables_query_rows_handler');
     1236add_action('wp_ajax_nopriv_vtables_query_rows', 'vtables_query_rows_handler');
     1237function vtables_query_rows_handler() {
     1238  if (!defined('DOING_AJAX') || !DOING_AJAX) {
     1239    wp_send_json(array('code' => 400, 'error' => 'INVALID_REQUEST', 'message' => 'Invalid request.'), 400);
     1240  }
     1241
     1242  $table_id = isset($_GET['table_id']) ? intval($_GET['table_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1243  if (!$table_id) {
     1244    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table_id.'), 400);
     1245  }
     1246
     1247  vtables_reject_pro_table($table_id);
     1248
     1249  // Plain pagination only: search, sorting and filtering are vTables Pro features.
     1250  $result = vtables_query_source_rows(array(
     1251    'table_id'        => $table_id,
     1252    'page'            => isset($_GET['page']) ? intval($_GET['page']) : 1, // phpcs:ignore WordPress.Security.NonceVerification
     1253    'per_page'        => isset($_GET['per_page']) ? intval($_GET['per_page']) : vtables_default_per_page(), // phpcs:ignore WordPress.Security.NonceVerification
     1254    'after_row_index' => isset($_GET['after_row_index']) && $_GET['after_row_index'] !== '' ? intval($_GET['after_row_index']) : '', // phpcs:ignore WordPress.Security.NonceVerification
     1255  ));
     1256
     1257  global $wpdb;
     1258  if ($wpdb->last_error) {
     1259    vtables_send_db_error();
     1260  }
     1261
     1262  wp_send_json(array('rows' => $result['rows'], 'total' => $result['total']));
     1263}
     1264
     1265
     1266/**
     1267 * UPDATE ROW (server-mode single-row edit).
     1268 */
     1269add_action('wp_ajax_vtables_update_row', 'vtables_update_row_handler');
     1270function vtables_update_row_handler() {
     1271  vtables_require_admin_ajax();
     1272
     1273  $table_id = isset($_POST['table_id']) ? intval($_POST['table_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1274  $vt_id    = isset($_POST['vt_id']) ? intval($_POST['vt_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1275  $row_raw  = isset($_POST['row']) ? wp_unslash($_POST['row']) : ''; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, decoded below
     1276
     1277  if (!$table_id || !$vt_id) {
     1278    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table_id or vt_id.'), 400);
     1279  }
     1280
     1281  vtables_reject_pro_table($table_id);
     1282
     1283  $row = json_decode($row_raw, true);
     1284  if (!is_array($row)) {
     1285    wp_send_json(array('code' => 400, 'error' => 'INVALID_ROW', 'message' => 'Invalid row payload.'), 400);
     1286  }
     1287  unset($row['vt_id'], $row['vt_row_index']);
     1288
     1289  global $wpdb;
     1290  $source_table = $wpdb->prefix . 'vtables_source';
     1291  $row_json = wp_json_encode($row);
     1292
     1293  // Refresh search_blob AND the typed slot sub-columns so search and indexed
     1294  // sort/filter stay consistent with the edited cell values.
     1295  $index_map   = vtables_table_index_map($table_id);
     1296  $slot_values = vtables_row_index_values($row, $index_map);
     1297
     1298  $data    = array('row_json' => $row_json, 'search_blob' => vtables_row_search_blob($row));
     1299  $formats = array('%s', '%s');
     1300  foreach (vtables_slot_columns() as $slot_col) {
     1301    $data[$slot_col]  = isset($slot_values[$slot_col]) ? $slot_values[$slot_col] : null;
     1302    $formats[]        = '%s';
     1303  }
     1304
     1305  $result = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1306    $source_table,
     1307    $data,
     1308    array('id' => $vt_id, 'table_id' => $table_id),
     1309    $formats,
     1310    array('%d', '%d')
     1311  );
     1312
     1313  if ($result === false) {
     1314    wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Failed to update row.'), 500);
     1315  }
     1316
     1317  // Cell values changed but the row count didn't; only filtered/searched totals
     1318  // could shift, so drop the cached counts for this table to stay correct.
     1319  vtables_invalidate_count_cache($table_id);
     1320
     1321  wp_send_json(array('vt_id' => $vt_id));
     1322}
     1323
     1324
     1325/**
     1326 * INSERT ROW (server-mode add row).
     1327 */
     1328
     1329/**
     1330 * Named-lock key serializing anchored inserts per table. GET_LOCK names are
     1331 * global to the MySQL server and capped at 64 chars, so scope per
     1332 * install+blog+table through a hash. The same name vTables Pro uses, since
     1333 * both write rows of the same tables.
     1334 */
     1335function vtables_row_insert_lock_name($table_id) {
     1336  global $wpdb;
     1337  $db = defined('DB_NAME') ? DB_NAME : '';
     1338  return 'vtables_pro_rows:' . md5($db . '|' . $wpdb->prefix . '|' . (int) $table_id);
     1339}
     1340
     1341add_action('wp_ajax_vtables_insert_row', 'vtables_insert_row_handler');
     1342function vtables_insert_row_handler() {
     1343  vtables_require_admin_ajax();
     1344
     1345  $table_id     = isset($_POST['table_id']) ? intval($_POST['table_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1346  $after_index  = isset($_POST['after_index']) ? intval($_POST['after_index']) : -1; // phpcs:ignore WordPress.Security.NonceVerification
     1347  $anchor_vt_id = isset($_POST['anchor_vt_id']) ? intval($_POST['anchor_vt_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1348  $position     = isset($_POST['position']) && sanitize_text_field(wp_unslash($_POST['position'])) === 'before' ? 'before' : 'after'; // phpcs:ignore WordPress.Security.NonceVerification
     1349  $row_raw      = isset($_POST['row']) ? wp_unslash($_POST['row']) : ''; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, decoded below
     1350
     1351  if (!$table_id) {
     1352    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table_id.'), 400);
     1353  }
     1354
     1355  // Bail before any write: wp_vtables_source has no FK, so an insert against a
     1356  // deleted/never-existing table_id would orphan the row permanently.
     1357  if (!vtables_table_exists($table_id)) {
     1358    wp_send_json(array('code' => 404, 'error' => 'TABLE_NOT_FOUND', 'message' => 'Table not found.'), 404);
     1359  }
     1360
     1361  vtables_reject_pro_table($table_id);
     1362
     1363  $row = json_decode($row_raw, true);
     1364  if (!is_array($row)) {
     1365    $row = array();
     1366  }
     1367  unset($row['vt_id'], $row['vt_row_index']);
     1368
     1369  global $wpdb;
     1370  $source_table = $wpdb->prefix . 'vtables_source';
     1371
     1372  // Anchored insert: resolve the target index from the anchor row, shift every
     1373  // row at/after it up by one and take the freed slot. Keeps row_index unique
     1374  // and ordered — keyset pagination in vtables_query_source_rows
     1375  // (WHERE row_index > %d) breaks on duplicates, which is why the old sparse
     1376  // "after_index + 1" placement misplaced rows. The shift and the insert are
     1377  // two statements, so serialize per table with a named lock (session-scoped,
     1378  // released on request shutdown, so every wp_send_json exit path is covered).
     1379  if ($anchor_vt_id > 0) {
     1380    $lock = vtables_row_insert_lock_name($table_id);
     1381    $acquired = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1382      $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock, 10)
    1451383    );
    146     $row_exists = $wpdb->get_results($query);
    147     $title = $row_exists ? $title . '-copy' : $title;
    148   }
    149 
    150   $results = $wpdb->insert($table_name,
    151     array(
    152       'title' => $title,
    153       'source' => $source,
    154       'file_size' => $file_size,
    155       'external_source' => $external_source,
    156       'settings' => $settings,
    157       'table_type' => $table_type,
    158       'woocommerce_categories' => $woocommerce_categories,
    159     )
     1384    if ($acquired !== 1) {
     1385      wp_send_json(array('code' => 503, 'error' => 'ROWS_BUSY', 'message' => 'Another row operation is in progress. Please retry.'), 503);
     1386    }
     1387    register_shutdown_function(function () use ($wpdb, $lock) {
     1388      $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1389    });
     1390
     1391    $anchor_index = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1392      $wpdb->prepare( "SELECT row_index FROM {$wpdb->prefix}vtables_source WHERE id = %d AND table_id = %d", $anchor_vt_id, $table_id )
     1393    );
     1394    if ($anchor_index === null) {
     1395      wp_send_json(array('code' => 404, 'error' => 'ANCHOR_NOT_FOUND', 'message' => 'Anchor row not found.'), 404);
     1396    }
     1397
     1398    $new_index = (int) $anchor_index + ($position === 'after' ? 1 : 0);
     1399    $shifted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1400      $wpdb->prepare( "UPDATE {$wpdb->prefix}vtables_source SET row_index = row_index + 1 WHERE table_id = %d AND row_index >= %d", $table_id, $new_index )
     1401    );
     1402    if ($shifted === false) {
     1403      wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Failed to insert row.'), 500);
     1404    }
     1405  } elseif ($after_index >= 0) {
     1406    // Legacy sparse positioning (no anchor supplied): place just after the
     1407    // requested index; ORDER BY row_index, id breaks the tie.
     1408    $new_index = $after_index + 1;
     1409  } else {
     1410    $max_index = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1411      $wpdb->prepare( "SELECT MAX(row_index) FROM {$wpdb->prefix}vtables_source WHERE table_id = %d", $table_id )
     1412    );
     1413    $new_index = ($max_index === null) ? 0 : ((int) $max_index + 1);
     1414  }
     1415
     1416  // Populate the typed slot sub-columns for indexed columns on insert too.
     1417  $index_map   = vtables_table_index_map($table_id);
     1418  $slot_values = vtables_row_index_values($row, $index_map);
     1419
     1420  $data = array(
     1421    'table_id'    => $table_id,
     1422    'row_index'   => $new_index,
     1423    'row_json'    => wp_json_encode($row),
     1424    'search_blob' => vtables_row_search_blob($row),
    1601425  );
    161 
    162   $response = array();
    163   $response['id'] = $wpdb->insert_id;
    164 
    165   $rest_response = new WP_REST_Response(
    166     array(
    167       'success' => !!$results,
    168       'data' => $response,
    169     )
     1426  $formats = array('%d', '%d', '%s', '%s');
     1427  foreach (vtables_slot_columns() as $slot_col) {
     1428    $data[$slot_col] = isset($slot_values[$slot_col]) ? $slot_values[$slot_col] : null;
     1429    $formats[]       = '%s';
     1430  }
     1431
     1432  $result = $wpdb->insert($source_table, $data, $formats); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1433
     1434  if ($result === false) {
     1435    wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Failed to insert row.'), 500);
     1436  }
     1437
     1438  vtables_invalidate_count_cache($table_id);
     1439
     1440  wp_send_json(array('vt_id' => $wpdb->insert_id, 'row_index' => $new_index));
     1441}
     1442
     1443
     1444/**
     1445 * DELETE ROW (server-mode delete row).
     1446 */
     1447add_action('wp_ajax_vtables_delete_row', 'vtables_delete_row_handler');
     1448function vtables_delete_row_handler() {
     1449  vtables_require_admin_ajax();
     1450
     1451  $table_id = isset($_POST['table_id']) ? intval($_POST['table_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1452  $vt_id    = isset($_POST['vt_id']) ? intval($_POST['vt_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1453
     1454  if (!$table_id || !$vt_id) {
     1455    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table_id or vt_id.'), 400);
     1456  }
     1457
     1458  vtables_reject_pro_table($table_id);
     1459
     1460  global $wpdb;
     1461  $source_table = $wpdb->prefix . 'vtables_source';
     1462  $result = $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1463    $source_table,
     1464    array('id' => $vt_id, 'table_id' => $table_id),
     1465    array('%d', '%d')
    1701466  );
    1711467
    172   vtables_maybe_send_nocache_headers($rest_response);
    173 
    174   return $rest_response;
    175 }
    176 
    177 
    178 /**
    179  * SAVE TABLE CHANGES.
    180  */
    181 add_action( 'rest_api_init', function () {
    182   register_rest_route( 'vtables/v1', '/source', array(
    183     'methods' => 'PUT',
    184     'callback' => 'vtables_save_table_changes_handler',
    185     'permission_callback' => 'vtables_admin_permission_callback',
    186   ) );
    187 } );
    188 
    189 function vtables_save_table_changes_handler( WP_REST_Request $data ) {
     1468  if ($result === false) {
     1469    wp_send_json(array('code' => 500, 'error' => 'DB_ERROR', 'message' => 'Failed to delete row.'), 500);
     1470  }
     1471
     1472  vtables_invalidate_count_cache($table_id);
     1473
     1474  wp_send_json(array('vt_id' => $vt_id));
     1475}
     1476
     1477
     1478/**
     1479 * HELPER: the table's stored columns as name/label pairs.
     1480 * Names key row_json; export needs the labels too, because that is what the
     1481 * UI shows as the header.
     1482 */
     1483if (!function_exists('vtables_table_column_pairs')) {
     1484  function vtables_table_column_pairs($table_id) {
     1485    global $wpdb;
     1486    $source = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1487      $wpdb->prepare( "SELECT source FROM {$wpdb->prefix}vtables WHERE id = %d", $table_id )
     1488    );
     1489    $decoded = json_decode($source, true);
     1490    $pairs = array();
     1491    if (is_array($decoded) && isset($decoded['columns']) && is_array($decoded['columns'])) {
     1492      foreach ($decoded['columns'] as $col) {
     1493        if (!isset($col['name'])) {
     1494          continue;
     1495        }
     1496        $name = (string) $col['name'];
     1497        $pairs[] = array(
     1498          'name'  => $name,
     1499          'label' => isset($col['label']) && $col['label'] !== '' ? (string) $col['label'] : $name,
     1500        );
     1501      }
     1502    }
     1503    return $pairs;
     1504  }
     1505}
     1506
     1507/**
     1508 * HELPER: flatten one cell value for a CSV field.
     1509 * Cells are not always scalars — json-typed cells hold objects/arrays and
     1510 * booleans. Handing those to fputcsv() emits the literal "Array" plus a PHP
     1511 * notice, so encode them instead.
     1512 */
     1513if (!function_exists('vtables_export_scalarize')) {
     1514  function vtables_export_scalarize($value) {
     1515    if (is_null($value)) {
     1516      return '';
     1517    }
     1518    if (is_bool($value)) {
     1519      return $value ? 'true' : 'false';
     1520    }
     1521    if (is_scalar($value)) {
     1522      return (string) $value;
     1523    }
     1524    return (string) wp_json_encode($value);
     1525  }
     1526}
     1527
     1528/**
     1529 * Hard ceiling of the xlsx sheet format itself (rows per worksheet).
     1530 */
     1531if (!defined('VTABLES_XLSX_MAX_ROWS')) {
     1532  define('VTABLES_XLSX_MAX_ROWS', 1048576);
     1533}
     1534
     1535/**
     1536 * EXPORT ROWS (stream all rows for a table without buffering the whole dataset).
     1537 *
     1538 * Always exports the full, unfiltered table — the active search/filter/sort of
     1539 * the browsing session is deliberately not applied.
     1540 */
     1541add_action('wp_ajax_vtables_export_rows', 'vtables_export_rows_handler');
     1542function vtables_export_rows_handler() {
     1543  vtables_require_admin_ajax();
     1544
     1545  $table_id = isset($_GET['table_id']) ? intval($_GET['table_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification
     1546  $format   = isset($_GET['format']) ? sanitize_text_field(wp_unslash($_GET['format'])) : 'csv'; // phpcs:ignore WordPress.Security.NonceVerification
     1547  if (!in_array($format, array('csv', 'json', 'xlsx'), true)) {
     1548    $format = 'csv';
     1549  }
     1550
     1551  if (!$table_id) {
     1552    wp_send_json(array('code' => 400, 'error' => 'MISSING_REQUIRED_FIELDS', 'message' => 'Missing table_id.'), 400);
     1553  }
     1554
    1901555  global $wpdb;
    191   $table_name = $wpdb->prefix . 'vtables';
    192   $id = $data['id'];
    193   $title = $data['title'];
    194   $source = $data['source'];
    195   $file_size = $data['fileSize'];
    196   $sourceJSON = json_encode($data['source']);
    197 
    198   // Update only title
    199   if ($title && empty($source)) {
    200     $results = $wpdb->update(
    201       $table_name,
    202       array(
    203         'title' => $title,
    204         'updated_at' => date('Y-m-d h:i:s'),
    205       ),
    206       array( 'id' => $data['id'] ),
     1556  $table = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1557    $wpdb->prepare( "SELECT id, title, table_type, source FROM {$wpdb->prefix}vtables WHERE id = %d", $table_id )
     1558  );
     1559
     1560  if (!$table) {
     1561    wp_send_json(array('code' => 404, 'error' => 'NOT_FOUND', 'message' => 'Table not found.'), 404);
     1562  }
     1563
     1564  vtables_reject_pro_table($table_id);
     1565
     1566  // Everything that can refuse the request has to do so before a byte of the
     1567  // download goes out, so the xlsx preconditions are checked up here.
     1568  if ($format === 'xlsx') {
     1569    if (!class_exists('ZipArchive')) {
     1570      wp_send_json(array('code' => 501, 'error' => 'ZIP_UNAVAILABLE', 'message' => 'XLSX export needs the PHP zip extension. Use CSV instead.'), 501);
     1571    }
     1572
     1573    $row_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1574      $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}vtables_source WHERE table_id = %d", $table_id )
    2071575    );
    208 
    209     return new WP_REST_Response(
    210       array(
    211         'success' => !!$results,
    212         'data' => $results,
    213       )
     1576    // The sheet format itself caps out at 1,048,576 rows (one goes to the
     1577    // header). Truncating silently would hand back a plausible-looking but
     1578    // incomplete file, so refuse and point at the formats that have no ceiling.
     1579    if ($row_count > VTABLES_XLSX_MAX_ROWS - 1) {
     1580      wp_send_json(array(
     1581        'code'    => 400,
     1582        'error'   => 'TOO_MANY_ROWS',
     1583        'message' => sprintf('This table has %d rows; XLSX holds at most %d. Export as CSV or JSON instead.', $row_count, VTABLES_XLSX_MAX_ROWS - 1),
     1584      ), 400);
     1585    }
     1586  }
     1587
     1588  // Match the filename the client-side exporter used before this endpoint
     1589  // existed, so downloads stay recognizable.
     1590  $basename = sanitize_file_name($table->title);
     1591  if ($basename === '') {
     1592    $basename = 'vtable-' . $table_id;
     1593  }
     1594
     1595  // Streaming past a buffer would defeat the whole point (and blow memory on
     1596  // large tables), so drop whatever WordPress/plugins have open.
     1597  while (ob_get_level() > 0) {
     1598    ob_end_clean();
     1599  }
     1600
     1601  $content_types = array(
     1602    'json' => 'application/json; charset=utf-8',
     1603    'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
     1604    'csv'  => 'text/csv; charset=utf-8',
     1605  );
     1606
     1607  nocache_headers();
     1608  header('Content-Type: ' . $content_types[$format]);
     1609  header('Content-Disposition: attachment; filename="' . $basename . '.' . $format . '"');
     1610
     1611  $writer = vtables_export_writer_open($format);
     1612
     1613  vtables_export_stream_source_rows($table, $writer);
     1614
     1615  vtables_export_writer_close($writer);
     1616
     1617  wp_die();
     1618}
     1619
     1620/**
     1621 * EXPORT: stream rows out of wp_vtables_source.
     1622 * Legacy tables kept their rows inside the source blob and have no records
     1623 * there, so fall back to that blob rather than emitting an empty file.
     1624 */
     1625function vtables_export_stream_source_rows($table, &$writer) {
     1626  global $wpdb;
     1627  $table_id     = (int) $table->id;
     1628  $pairs        = vtables_table_column_pairs($table_id);
     1629
     1630  $has_records = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1631    $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}vtables_source WHERE table_id = %d", $table_id )
     1632  ) > 0;
     1633
     1634  $legacy_rows = array();
     1635  if (!$has_records) {
     1636    $decoded = json_decode($table->source, true);
     1637    if (is_array($decoded) && isset($decoded['rows']) && is_array($decoded['rows'])) {
     1638      $legacy_rows = $decoded['rows'];
     1639    }
     1640  }
     1641
     1642  vtables_export_writer_header($writer, $pairs);
     1643
     1644  $batch  = 1000;
     1645  $offset = 0;
     1646
     1647  do {
     1648    if ($has_records) {
     1649      $records = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1650        $wpdb->prepare( "SELECT row_json FROM {$wpdb->prefix}vtables_source WHERE table_id = %d ORDER BY row_index ASC, id ASC LIMIT %d OFFSET %d", $table_id, $batch, $offset )
     1651      );
     1652    } else {
     1653      $records = array_map('wp_json_encode', array_slice($legacy_rows, $offset, $batch));
     1654    }
     1655
     1656    foreach ($records as $row_json) {
     1657      if ($writer['format'] === 'json') {
     1658        // Already-encoded JSON goes out verbatim — no decode/re-encode round trip.
     1659        vtables_export_writer_row($writer, null, $pairs, $row_json);
     1660        continue;
     1661      }
     1662
     1663      $row = json_decode($row_json, true);
     1664      if (!is_array($row)) {
     1665        continue;
     1666      }
     1667      vtables_export_writer_row($writer, $row, $pairs);
     1668    }
     1669
     1670    $offset += $batch;
     1671    flush();
     1672  } while (count($records) === $batch);
     1673}
     1674
     1675/**
     1676 * EXPORT: one row -> the values for the column list, in column order. Without
     1677 * a column list (columnless legacy sources) fall back to the row's own order.
     1678 */
     1679function vtables_export_row_values($row, $pairs) {
     1680  if (empty($pairs)) {
     1681    return array_values($row);
     1682  }
     1683
     1684  $values = array();
     1685  foreach ($pairs as $pair) {
     1686    $values[] = isset($row[$pair['name']]) ? $row[$pair['name']] : '';
     1687  }
     1688  return $values;
     1689}
     1690
     1691
     1692/**
     1693 * ============================================================================
     1694 * EXPORT WRITERS
     1695 * One tiny state machine per output format, so the two row-producing streams
     1696 * above stay format-agnostic. csv and json go straight to php://output; xlsx
     1697 * builds on disk (see vtables_export_writer_close).
     1698 * ============================================================================
     1699 */
     1700
     1701function vtables_export_writer_open($format) {
     1702  $writer = array('format' => $format);
     1703
     1704  if ($format === 'json') {
     1705    $writer['first'] = true;
     1706    echo '[';
     1707    return $writer;
     1708  }
     1709
     1710  if ($format === 'csv') {
     1711    $writer['out'] = fopen('php://output', 'w'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     1712    return $writer;
     1713  }
     1714
     1715  // xlsx: the sheet is the only large part, so it is written to a temp file as
     1716  // rows arrive and zipped at the end — memory stays flat regardless of size.
     1717  $writer['sheet_path'] = wp_tempnam('vtables-export-sheet');
     1718  $writer['sheet']      = fopen($writer['sheet_path'], 'w'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     1719  $writer['row']        = 0;
     1720  fwrite( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     1721    $writer['sheet'],
     1722    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
     1723    . '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>'
     1724  );
     1725  return $writer;
     1726}
     1727
     1728function vtables_export_writer_header(&$writer, $pairs) {
     1729  if ($writer['format'] === 'json' || empty($pairs)) {
     1730    return;
     1731  }
     1732  $labels = wp_list_pluck($pairs, 'label');
     1733
     1734  if ($writer['format'] === 'csv') {
     1735    fputcsv($writer['out'], $labels); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     1736    return;
     1737  }
     1738
     1739  vtables_export_xlsx_row($writer, $labels);
     1740}
     1741
     1742/**
     1743 * @param array|null  $row     decoded row; may be null when $encoded is given.
     1744 * @param array       $pairs   column list.
     1745 * @param string|null $encoded pre-encoded JSON, to skip a decode/encode cycle.
     1746 */
     1747function vtables_export_writer_row(&$writer, $row, $pairs, $encoded = null) {
     1748  if ($writer['format'] === 'json') {
     1749    echo $writer['first'] ? '' : ','; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
     1750    echo $encoded !== null ? $encoded : wp_json_encode($row); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
     1751    $writer['first'] = false;
     1752    return;
     1753  }
     1754
     1755  $values = vtables_export_row_values($row, $pairs);
     1756
     1757  if ($writer['format'] === 'csv') {
     1758    fputcsv($writer['out'], array_map('vtables_export_scalarize', $values)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     1759    return;
     1760  }
     1761
     1762  vtables_export_xlsx_row($writer, $values);
     1763}
     1764
     1765function vtables_export_writer_close(&$writer) {
     1766  if ($writer['format'] === 'json') {
     1767    echo ']';
     1768    return;
     1769  }
     1770
     1771  if ($writer['format'] === 'csv') {
     1772    fclose($writer['out']); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     1773    return;
     1774  }
     1775
     1776  fwrite($writer['sheet'], '</sheetData></worksheet>'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     1777  fclose($writer['sheet']); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     1778
     1779  $xlsx_path = wp_tempnam('vtables-export-book');
     1780  $zip = new ZipArchive();
     1781  if ($zip->open($xlsx_path, ZipArchive::OVERWRITE) === true) {
     1782    foreach (vtables_export_xlsx_parts() as $name => $contents) {
     1783      $zip->addFromString($name, $contents);
     1784    }
     1785    $zip->addFile($writer['sheet_path'], 'xl/worksheets/sheet1.xml');
     1786    $zip->close();
     1787    readfile($xlsx_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
     1788  }
     1789
     1790  wp_delete_file($writer['sheet_path']);
     1791  wp_delete_file($xlsx_path);
     1792}
     1793
     1794
     1795/**
     1796 * XLSX: append one <row>. Values that are genuinely numeric become numeric
     1797 * cells; everything else becomes an inline string — which is what lets the
     1798 * sheet stream, since a shared-strings table would have to be held in memory
     1799 * until the very last row.
     1800 */
     1801function vtables_export_xlsx_row(&$writer, $values) {
     1802  $writer['row']++;
     1803  $r = $writer['row'];
     1804
     1805  $xml = '<row r="' . $r . '">';
     1806  $c = 0;
     1807  foreach ($values as $value) {
     1808    $c++;
     1809    $ref = vtables_export_xlsx_column_ref($c) . $r;
     1810
     1811    if (vtables_export_xlsx_is_numeric($value)) {
     1812      $xml .= '<c r="' . $ref . '"><v>' . (0 + $value) . '</v></c>';
     1813      continue;
     1814    }
     1815
     1816    $text = vtables_export_xlsx_text($value);
     1817    if ($text === '') {
     1818      continue; // an empty cell needs no element at all
     1819    }
     1820    // xml:space="preserve" or Excel eats leading/trailing whitespace.
     1821    $xml .= '<c r="' . $ref . '" t="inlineStr"><is><t xml:space="preserve">' . $text . '</t></is></c>';
     1822  }
     1823  $xml .= '</row>';
     1824
     1825  fwrite($writer['sheet'], $xml); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
     1826}
     1827
     1828/**
     1829 * XLSX: should this value be written as a number?
     1830 * Only when the canonical numeric form round-trips — so "00601" (a zip code)
     1831 * and "1e5" stay text rather than being silently rewritten by the spreadsheet.
     1832 */
     1833function vtables_export_xlsx_is_numeric($value) {
     1834  if (is_int($value) || is_float($value)) {
     1835    return is_finite((float) $value);
     1836  }
     1837  if (!is_string($value) || $value === '' || !is_numeric($value)) {
     1838    return false;
     1839  }
     1840  return (string) (0 + $value) === $value;
     1841}
     1842
     1843/**
     1844 * XLSX: escape a value for XML. Control characters are not merely invalid
     1845 * here — Excel rejects the whole workbook over one of them, so strip them.
     1846 */
     1847function vtables_export_xlsx_text($value) {
     1848  $text = vtables_export_scalarize($value);
     1849  // Byte-wise is safe: UTF-8 continuation bytes are all >= 0x80.
     1850  $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text);
     1851  return htmlspecialchars($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
     1852}
     1853
     1854/**
     1855 * XLSX: 1 -> A, 26 -> Z, 27 -> AA, ...
     1856 */
     1857function vtables_export_xlsx_column_ref($index) {
     1858  $ref = '';
     1859  while ($index > 0) {
     1860    $index--;
     1861    $ref = chr(65 + ($index % 26)) . $ref;
     1862    $index = intdiv($index, 26);
     1863  }
     1864  return $ref;
     1865}
     1866
     1867/**
     1868 * XLSX: the fixed, tiny package parts. Everything variable lives in
     1869 * xl/worksheets/sheet1.xml, which is added separately from its temp file.
     1870 */
     1871function vtables_export_xlsx_parts() {
     1872  $ns_rel = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
     1873  $decl   = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
     1874
     1875  return array(
     1876    '[Content_Types].xml' => $decl
     1877      . '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
     1878      . '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
     1879      . '<Default Extension="xml" ContentType="application/xml"/>'
     1880      . '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
     1881      . '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
     1882      . '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
     1883      . '</Types>',
     1884
     1885    '_rels/.rels' => $decl
     1886      . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
     1887      . '<Relationship Id="rId1" Type="' . $ns_rel . '/officeDocument" Target="xl/workbook.xml"/>'
     1888      . '</Relationships>',
     1889
     1890    'xl/workbook.xml' => $decl
     1891      . '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="' . $ns_rel . '">'
     1892      . '<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>'
     1893      . '</workbook>',
     1894
     1895    'xl/_rels/workbook.xml.rels' => $decl
     1896      . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
     1897      . '<Relationship Id="rId1" Type="' . $ns_rel . '/worksheet" Target="worksheets/sheet1.xml"/>'
     1898      . '<Relationship Id="rId2" Type="' . $ns_rel . '/styles" Target="styles.xml"/>'
     1899      . '</Relationships>',
     1900
     1901    // Minimal but complete: readers that assume a styles part exists (and index
     1902    // style 0) would otherwise choke.
     1903    'xl/styles.xml' => $decl
     1904      . '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
     1905      . '<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
     1906      . '<fills count="1"><fill><patternFill patternType="none"/></fill></fills>'
     1907      . '<borders count="1"><border/></borders>'
     1908      . '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
     1909      . '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
     1910      . '</styleSheet>',
     1911  );
     1912}
     1913
     1914
     1915/**
     1916 * HELPER: page size for server-mode reads.
     1917 * Fixed: the rows-per-page setting (settings.bottom.initial.rowsPerPage) is a
     1918 * vTables Pro feature, so every table pages by the same default a new table
     1919 * starts with (INITIAL_SETTINGS in src/stores/main/constants.ts).
     1920 */
     1921if (!function_exists('vtables_default_per_page')) {
     1922  function vtables_default_per_page() {
     1923    return max(1, min(1000, (int) apply_filters('vtables_default_per_page', 10)));
     1924  }
     1925}
     1926
     1927/**
     1928 * HELPER: hard upload size cap in bytes (10 MB). Enforced client- and server-side.
     1929 */
     1930if (!function_exists('vtables_max_upload_bytes')) {
     1931  function vtables_max_upload_bytes() {
     1932    return (int) apply_filters('vtables_max_upload_bytes', 10 * 1024 * 1024);
     1933  }
     1934}
     1935
     1936/**
     1937 * HELPER: ordered list of the typed slot sub-columns on wp_vtables_source.
     1938 * Slot k (1..5): idx_k_txt (VARCHAR) for text, idx_k_num (DECIMAL) for
     1939 * number/date(epoch)/boolean(0|1).
     1940 */
     1941if (!function_exists('vtables_slot_columns')) {
     1942  function vtables_slot_columns() {
     1943    $cols = array();
     1944    for ($k = 1; $k <= 5; $k++) {
     1945      $cols[] = "idx_{$k}_txt";
     1946      $cols[] = "idx_{$k}_num";
     1947    }
     1948    return $cols;
     1949  }
     1950}
     1951
     1952/**
     1953 * HELPER: coerce a raw cell value to the numeric representation for a data type,
     1954 * or null when it can't be represented (so it sorts last and never throws).
     1955 * Returns array('txt' => string|null, 'num' => string|null) — only one is used
     1956 * per slot depending on the column's type, but both are computed cheaply.
     1957 */
     1958if (!function_exists('vtables_coerce_index_value')) {
     1959  function vtables_coerce_index_value($value, $type) {
     1960    if (is_array($value) || is_object($value)) {
     1961      // Non-scalar values can't be indexed meaningfully.
     1962      return array('txt' => null, 'num' => null);
     1963    }
     1964    $str = ($value === null) ? '' : (string) $value;
     1965    $trimmed = trim($str);
     1966
     1967    switch ($type) {
     1968      case 'number':
     1969        if ($trimmed === '' || !is_numeric($trimmed)) {
     1970          return array('txt' => null, 'num' => null);
     1971        }
     1972        return array('txt' => null, 'num' => (string) (0 + $trimmed));
     1973
     1974      case 'date':
     1975        if ($trimmed === '') {
     1976          return array('txt' => null, 'num' => null);
     1977        }
     1978        // Numeric epoch passes straight through; otherwise parse a date string.
     1979        if (is_numeric($trimmed)) {
     1980          return array('txt' => null, 'num' => (string) (int) $trimmed);
     1981        }
     1982        $ts = strtotime($trimmed);
     1983        if ($ts === false) {
     1984          return array('txt' => null, 'num' => null);
     1985        }
     1986        return array('txt' => null, 'num' => (string) $ts);
     1987
     1988      case 'boolean':
     1989        if ($trimmed === '') {
     1990          return array('txt' => null, 'num' => null);
     1991        }
     1992        $low = strtolower($trimmed);
     1993        if (in_array($low, array('1', 'true', 'yes', 'y', 'on'), true)) {
     1994          return array('txt' => null, 'num' => '1');
     1995        }
     1996        if (in_array($low, array('0', 'false', 'no', 'n', 'off'), true)) {
     1997          return array('txt' => null, 'num' => '0');
     1998        }
     1999        return array('txt' => null, 'num' => null);
     2000
     2001      case 'text':
     2002      default:
     2003        if ($str === '') {
     2004          return array('txt' => null, 'num' => null);
     2005        }
     2006        // VARCHAR(191): truncate for indexing only (raw value stays in row_json).
     2007        return array('txt' => function_exists('mb_substr') ? mb_substr($str, 0, 191) : substr($str, 0, 191), 'num' => null);
     2008    }
     2009  }
     2010}
     2011
     2012/**
     2013 * HELPER: build the slot sub-column values for one row from a resolved index map
     2014 * ($map: column name -> array('type' => ..., 'slot' => 1..5)).
     2015 * Returns array('idx_k_txt' => ..., 'idx_k_num' => ...) with null where unused.
     2016 */
     2017if (!function_exists('vtables_row_index_values')) {
     2018  function vtables_row_index_values($row, $map) {
     2019    $out = array();
     2020    if (!is_array($row) || !is_array($map) || empty($map)) {
     2021      return $out;
     2022    }
     2023    foreach ($map as $col => $meta) {
     2024      $slot = isset($meta['slot']) ? (int) $meta['slot'] : 0;
     2025      $type = isset($meta['type']) ? (string) $meta['type'] : 'text';
     2026      if ($slot < 1 || $slot > 5) {
     2027        continue;
     2028      }
     2029      $value = isset($row[$col]) ? $row[$col] : null;
     2030      $coerced = vtables_coerce_index_value($value, $type);
     2031      if ($type === 'text') {
     2032        $out["idx_{$slot}_txt"] = $coerced['txt'];
     2033        $out["idx_{$slot}_num"] = null;
     2034      } else {
     2035        $out["idx_{$slot}_txt"] = null;
     2036        $out["idx_{$slot}_num"] = $coerced['num'];
     2037      }
     2038    }
     2039    return $out;
     2040  }
     2041}
     2042
     2043/**
     2044 * HELPER: does a table row exist in wp_vtables? Handlers that write rows into
     2045 * wp_vtables_source must call this first — the source table has no FK back to
     2046 * wp_vtables, so inserting against a stale/bogus table_id silently orphans the
     2047 * row where nothing but a manual sweep will find it.
     2048 */
     2049if (!function_exists('vtables_table_exists')) {
     2050  function vtables_table_exists($table_id) {
     2051    global $wpdb;
     2052    $found = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     2053      $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}vtables WHERE id = %d", $table_id )
    2142054    );
    215   }
    216 
    217   // Update source
    218   $results = $wpdb->update(
    219     $table_name,
    220     array(
    221       'title' => $title,
    222       'file_size' => $file_size,
    223       'source' => $sourceJSON,
    224       'updated_at' => date('Y-m-d h:i:s'),
    225     ),
    226     array( 'id' => $data['id'] ),
    227     array( '%s', '%d', '%s', '%s' ),
    228     array( '%d' )
    229   );
    230 
    231   $query = $wpdb->prepare("SELECT * FROM $table_name WHERE id = %d", $id);
    232   $updated_table = $wpdb->get_results($query)[0];
    233   $updated_table->source = json_decode($updated_table->source);
    234   $response = $updated_table;
    235 
    236   $rest_response = new WP_REST_Response(
    237     array(
    238       'success' => !!$results,
    239       'data' => $response,
    240     )
    241   );
    242 
    243   vtables_maybe_send_nocache_headers($rest_response);
    244 
    245   return $rest_response;
    246 }
    247 
    248 
    249 /**
    250  * SAVE TABLE SETTINGS.
    251  */
    252 add_action( 'rest_api_init', function () {
    253   register_rest_route( 'vtables/v1', '/settings', array(
    254     'methods' => 'PUT',
    255     'callback' => 'vtables_save_table_settings_handler',
    256     'permission_callback' => 'vtables_admin_permission_callback',
    257   ) );
    258 } );
    259 
    260 function vtables_save_table_settings_handler( WP_REST_Request $data ) {
    261   global $wpdb;
    262 
    263   $table_name = $wpdb->prefix . 'vtables';
    264   $settings = $data['settings'];
    265   $settingsJSON = json_encode($data['settings']);
    266 
    267   $results = $wpdb->update(
    268     $table_name,
    269     array(
    270       'settings' => $settingsJSON,
    271       'updated_at' => date('Y-m-d h:i:s'),
    272     ),
    273     array( 'id' => $data['id'] ),
    274     array( '%s', '%s' ),
    275     array( '%d' )
    276   );
    277 
    278   $response = array();
    279   $response['success'] = true;
    280   $response['updated_at'] = date('Y-m-d h:i:s');
    281 
    282   $rest_response = new WP_REST_Response(
    283     array(
    284       'success' => !!$results,
    285       'data' => $response,
    286     )
    287   );
    288 
    289   vtables_maybe_send_nocache_headers($rest_response);
    290 
    291   return $rest_response;
    292 }
    293 
    294 
    295 
    296 /**
    297  * DELETE TABLE.
    298  */
    299 add_action( 'rest_api_init', function () {
    300   register_rest_route( 'vtables/v1', '/tables', array(
    301     'methods' => 'DELETE',
    302     'callback' => 'vtables_delete_record_handler',
    303     'permission_callback' => 'vtables_admin_permission_callback',
    304   ) );
    305 } );
    306 
    307 function vtables_delete_record_handler( WP_REST_Request $data ) {
    308   global $wpdb;
    309   $table_name = $wpdb->prefix . 'vtables';
    310   $results = $wpdb->delete(
    311     $table_name,
    312     array('id' => $data['id']),
    313   );
    314 
    315   $response = array();
    316   $response['id'] = $data['id'];
    317 
    318   $rest_response = new WP_REST_Response(
    319     array(
    320       'success' => !!$results,
    321       'data' => $response,
    322     )
    323   );
    324 
    325   vtables_maybe_send_nocache_headers($rest_response);
    326 
    327   return $rest_response;
    328 }
    329 
    330 
    331 /**
    332  * CUSTOM PERMISSION CALLBACK: accessible only by administrator.
    333  */
    334 function vtables_admin_permission_callback() {
    335   if (
    336     current_user_can('administrator') ||
    337     current_user_can('editor') ||
    338     current_user_can('shop_manager')
    339   ) {
    340     return true;
    341   }
    342   return new WP_Error(
    343     'rest_forbidden',
    344     'You do not have permissions to access this endpoint.',
    345     array('status' => 401)
    346   );
    347 }
    348 
    349 
    350 /**
    351  * Add no-cache headers for requests so aggressive caches
    352  * (LiteSpeed, reverse proxies) don't serve stale data.
    353  */
    354 function vtables_maybe_send_nocache_headers( WP_REST_Response $response ) {
    355   $response->header( 'Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0' );
    356   $response->header( 'Pragma', 'no-cache' );
    357   $response->header( 'Expires', '0' );
    358 }
     2055    return $found !== null;
     2056  }
     2057}
     2058
     2059/**
     2060 * HELPER: per-table index map (column name -> array('type','slot')) read from
     2061 * wp_vtables.source.columns. Only columns flagged indexed with a 1..5 slot are
     2062 * returned. Drives the slot-backed sort/filter fast path in the query layer.
     2063 */
     2064if (!function_exists('vtables_table_index_map')) {
     2065  function vtables_table_index_map($table_id) {
     2066    global $wpdb;
     2067    $source = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     2068      $wpdb->prepare( "SELECT source FROM {$wpdb->prefix}vtables WHERE id = %d", $table_id )
     2069    );
     2070    $decoded = json_decode($source, true);
     2071    $map = array();
     2072    if (is_array($decoded) && isset($decoded['columns']) && is_array($decoded['columns'])) {
     2073      foreach ($decoded['columns'] as $col) {
     2074        if (
     2075          isset($col['name'], $col['slot']) &&
     2076          !empty($col['indexed']) &&
     2077          (int) $col['slot'] >= 1 && (int) $col['slot'] <= 5
     2078        ) {
     2079          $type = isset($col['dataType']) ? (string) $col['dataType'] : 'text';
     2080          $map[(string) $col['name']] = array('type' => $type, 'slot' => (int) $col['slot']);
     2081        }
     2082      }
     2083    }
     2084    return $map;
     2085  }
     2086}
     2087
     2088/**
     2089 * HELPER: type-sniff each column from a sample of rows.
     2090 * Rules over the non-empty sample values: all-numeric -> number; all parseable
     2091 * dates -> date; only boolean-ish (0/1/true/false/yes/no) -> boolean; else text.
     2092 */
     2093if (!function_exists('vtables_sniff_column_types')) {
     2094  function vtables_sniff_column_types($headers, $sample_rows) {
     2095    $types = array();
     2096    if (!is_array($headers)) {
     2097      return $types;
     2098    }
     2099    $bool_set = array('0', '1', 'true', 'false', 'yes', 'no', 'y', 'n', 'on', 'off');
     2100
     2101    foreach ($headers as $header) {
     2102      $name = (string) $header;
     2103      $seen      = 0;
     2104      $all_num   = true;
     2105      $all_date  = true;
     2106      $all_bool  = true;
     2107
     2108      if (is_array($sample_rows)) {
     2109        foreach ($sample_rows as $row) {
     2110          if (!is_array($row) || !array_key_exists($name, $row)) {
     2111            continue;
     2112          }
     2113          $val = $row[$name];
     2114          if (is_array($val) || is_object($val)) {
     2115            $all_num = $all_date = $all_bool = false;
     2116            $seen++;
     2117            continue;
     2118          }
     2119          $str = trim((string) $val);
     2120          if ($str === '') {
     2121            continue; // empties don't constrain the type
     2122          }
     2123          $seen++;
     2124          if (!is_numeric($str)) {
     2125            $all_num = false;
     2126          }
     2127          if (!in_array(strtolower($str), $bool_set, true)) {
     2128            $all_bool = false;
     2129          }
     2130          // A bare integer is ambiguous as a date; require a non-numeric parseable
     2131          // string for the date guess so years/ids don't all become dates.
     2132          if (is_numeric($str) || strtotime($str) === false) {
     2133            $all_date = false;
     2134          }
     2135        }
     2136      }
     2137
     2138      if ($seen === 0) {
     2139        $types[$name] = 'text';
     2140      } elseif ($all_bool) {
     2141        $types[$name] = 'boolean';
     2142      } elseif ($all_num) {
     2143        $types[$name] = 'number';
     2144      } elseif ($all_date) {
     2145        $types[$name] = 'date';
     2146      } else {
     2147        $types[$name] = 'text';
     2148      }
     2149    }
     2150
     2151    return $types;
     2152  }
     2153}
     2154
     2155/**
     2156 * HELPER: count-cache key for a table + a query signature (the key scheme is
     2157 * shared with vTables Pro, which also folds search/filter into it).
     2158 */
     2159if (!function_exists('vtables_count_cache_key')) {
     2160  function vtables_count_cache_key($table_id, $signature) {
     2161    return 'vtables_count_' . (int) $table_id . '_' . md5((string) $signature);
     2162  }
     2163}
     2164
     2165/**
     2166 * HELPER: invalidate every cached COUNT for a table. Bumps a per-table version
     2167 * stored in an option; cache keys fold the version in, so a single bump expires
     2168 * all of a table's count transients without enumerating them. The option and
     2169 * transient names are the ones vTables Pro uses, so an edit made by either
     2170 * plugin expires both plugins' cached counts.
     2171 */
     2172if (!function_exists('vtables_count_cache_version')) {
     2173  function vtables_count_cache_version($table_id) {
     2174    $v = get_option('vtables_count_ver_' . (int) $table_id);
     2175    return $v ? (int) $v : 1;
     2176  }
     2177}
     2178if (!function_exists('vtables_invalidate_count_cache')) {
     2179  function vtables_invalidate_count_cache($table_id) {
     2180    $key = 'vtables_count_ver_' . (int) $table_id;
     2181    update_option($key, vtables_count_cache_version($table_id) + 1, false);
     2182  }
     2183}
     2184
     2185/**
     2186 * HELPER: paginated read over wp_vtables_source, in row order.
     2187 * Returns array('rows' => array<assoc row + vt_id>, 'total' => int).
     2188 * Search, sorting and filtering are vTables Pro features; this is the plain
     2189 * pagination path of Pro's query layer (same row order, same keyset cursor,
     2190 * same cached count), so page boundaries match whichever plugin serves them.
     2191 *
     2192 * @param array $args table_id, page, per_page, after_row_index
     2193 */
     2194if (!function_exists('vtables_query_source_rows')) {
     2195  function vtables_query_source_rows($args) {
     2196    global $wpdb;
     2197
     2198    $table_id = isset($args['table_id']) ? (int) $args['table_id'] : 0;
     2199    $page     = isset($args['page']) ? max(1, (int) $args['page']) : 1;
     2200    $per_page = isset($args['per_page']) ? (int) $args['per_page'] : vtables_default_per_page();
     2201    $per_page = max(1, min(1000, $per_page));
     2202    // Keyset cursor for flat-cost next/prev.
     2203    $after_row_index = isset($args['after_row_index']) && $args['after_row_index'] !== '' ? (int) $args['after_row_index'] : null;
     2204
     2205    // COUNT(*) caching: the total only changes on insert/delete, so cache it
     2206    // per table and invalidation version.
     2207    $cache_key = vtables_count_cache_key($table_id, vtables_count_cache_version($table_id) . '|all');
     2208    $total = get_transient($cache_key);
     2209    if ($total === false) {
     2210      $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     2211        $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}vtables_source WHERE table_id = %d", $table_id )
     2212      );
     2213      set_transient($cache_key, $total, HOUR_IN_SECONDS);
     2214    } else {
     2215      $total = (int) $total;
     2216    }
     2217
     2218    if ($after_row_index !== null) {
     2219      // Uses the (table_id, row_index) index for flat cost at any depth.
     2220      $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     2221        $wpdb->prepare( "SELECT id, row_index, row_json FROM {$wpdb->prefix}vtables_source WHERE table_id = %d AND row_index > %d ORDER BY row_index ASC, id ASC LIMIT %d", $table_id, $after_row_index, $per_page ),
     2222        ARRAY_A
     2223      );
     2224    } else {
     2225      $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     2226        $wpdb->prepare( "SELECT id, row_index, row_json FROM {$wpdb->prefix}vtables_source WHERE table_id = %d ORDER BY row_index ASC, id ASC LIMIT %d OFFSET %d", $table_id, $per_page, ($page - 1) * $per_page ),
     2227        ARRAY_A
     2228      );
     2229    }
     2230
     2231    $rows = array();
     2232    if (is_array($records)) {
     2233      foreach ($records as $record) {
     2234        $decoded = json_decode($record['row_json'], true);
     2235        if (!is_array($decoded)) {
     2236          $decoded = array();
     2237        }
     2238        $decoded['vt_id']        = (int) $record['id'];
     2239        $decoded['vt_row_index'] = (int) $record['row_index'];
     2240        $rows[] = $decoded;
     2241      }
     2242    }
     2243
     2244    return array('rows' => $rows, 'total' => $total);
     2245  }
     2246}
     2247
     2248
     2249/**
     2250 * HELPER: build the lowercased free-text search blob for one row.
     2251 * Concatenates all cell values so a single LIKE can match across columns.
     2252 */
     2253if (!function_exists('vtables_row_search_blob')) {
     2254  function vtables_row_search_blob($row) {
     2255    if (!is_array($row)) {
     2256      $row = (array) $row;
     2257    }
     2258    $parts = array();
     2259    foreach ($row as $value) {
     2260      if (is_scalar($value)) {
     2261        $parts[] = (string) $value;
     2262      } elseif ($value !== null) {
     2263        $parts[] = wp_json_encode($value);
     2264      }
     2265    }
     2266    return strtolower(implode(' ', $parts));
     2267  }
     2268}
     2269
     2270
     2271/**
     2272 * HELPER: replace all source rows for a table.
     2273 * Deletes existing wp_vtables_source records for $table_id and re-inserts $rows as
     2274 * one record per row, batched so each INSERT packet stays small (well under
     2275 * max_allowed_packet). row_index is contiguous starting at 0.
     2276 */
     2277if (!function_exists('vtables_replace_source_rows')) {
     2278  function vtables_replace_source_rows($table_id, $rows) {
     2279    global $wpdb;
     2280    $source_table = $wpdb->prefix . 'vtables_source';
     2281
     2282    $wpdb->delete($source_table, array('table_id' => $table_id), array('%d')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     2283    vtables_invalidate_count_cache($table_id);
     2284
     2285    if (empty($rows) || !is_array($rows)) {
     2286      return;
     2287    }
     2288
     2289    // Populate the typed slot sub-columns from the table's index map (if any).
     2290    $index_map = vtables_table_index_map($table_id);
     2291    $slot_cols = vtables_slot_columns();
     2292    $col_list  = array_merge(array('table_id', 'row_index', 'row_json', 'search_blob'), $slot_cols);
     2293
     2294    $batch_size = 500;
     2295    $index      = 0;
     2296    $values     = array();
     2297    $params     = array();
     2298
     2299    $flush = function () use (&$values, &$params, $source_table, $col_list, $wpdb) {
     2300      if (empty($values)) {
     2301        return;
     2302      }
     2303      $sql = "INSERT INTO {$source_table} (" . implode(', ', $col_list) . ") VALUES " . implode(', ', $values);
     2304      $wpdb->query( $wpdb->prepare($sql, $params) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql holds only fixed column names and placeholders; every value goes through prepare()
     2305      $values = array();
     2306      $params = array();
     2307    };
     2308
     2309    foreach (array_values($rows) as $row) {
     2310      $row_json = wp_json_encode($row);
     2311      if ($row_json === false) {
     2312        continue;
     2313      }
     2314      $slot_values  = vtables_row_index_values($row, $index_map);
     2315      $placeholders = array('%d', '%d', '%s', '%s');
     2316      $params[]     = $table_id;
     2317      $params[]     = $index;
     2318      $params[]     = $row_json;
     2319      $params[]     = vtables_row_search_blob($row);
     2320      foreach ($slot_cols as $slot_col) {
     2321        if (!isset($slot_values[$slot_col]) || $slot_values[$slot_col] === null) {
     2322          $placeholders[] = 'NULL';
     2323        } else {
     2324          $placeholders[] = '%s';
     2325          $params[]       = $slot_values[$slot_col];
     2326        }
     2327      }
     2328      $values[] = '(' . implode(', ', $placeholders) . ')';
     2329      $index++;
     2330
     2331      if (count($values) >= $batch_size) {
     2332        $flush();
     2333      }
     2334    }
     2335
     2336    $flush();
     2337  }
     2338}
     2339
     2340
     2341/**
     2342 * HELPER: private working directory for uploads in progress.
     2343 * uploads/vtables_uploads/ holds chunks and assembled files only until the
     2344 * import has read them. Created on demand with an index.php and a deny-all
     2345 * .htaccess, so partial uploads are never browsable or directly fetchable.
     2346 * Returns the path with a trailing slash, or '' when it can't be created.
     2347 */
     2348if (!function_exists('vtables_uploads_dir')) {
     2349  function vtables_uploads_dir() {
     2350    $upload_info = wp_upload_dir();
     2351    $dir = trailingslashit($upload_info['basedir']) . 'vtables_uploads/';
     2352
     2353    if (!wp_mkdir_p($dir)) {
     2354      return '';
     2355    }
     2356
     2357    if (!file_exists($dir . 'index.php')) {
     2358      file_put_contents($dir . 'index.php', "<?php\n// Silence is golden.\n"); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
     2359    }
     2360
     2361    if (!file_exists($dir . '.htaccess')) {
     2362      file_put_contents($dir . '.htaccess', "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n"); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
     2363    }
     2364
     2365    return $dir;
     2366  }
     2367}
     2368
     2369
     2370/**
     2371 * HELPER: store an uploaded file (a $_FILES entry) as $dir . $filename.
     2372 * Goes through wp_handle_upload(), which runs the is_uploaded_file() check
     2373 * and the upload filters, pointed at $dir instead of the media library
     2374 * folders. Its type test is off: callers allowlist the extension themselves,
     2375 * and chunks are raw byte ranges. Returns the stored path, or '' on failure.
     2376 */
     2377if (!function_exists('vtables_store_uploaded_file')) {
     2378  function vtables_store_uploaded_file($file, $dir, $filename) {
     2379    require_once ABSPATH . 'wp-admin/includes/file.php';
     2380
     2381    $use_dir = function ($uploads) use ($dir) {
     2382      $uploads['path'] = untrailingslashit($dir);
     2383      return $uploads;
     2384    };
     2385
     2386    add_filter('upload_dir', $use_dir);
     2387    $result = wp_handle_upload($file, array(
     2388      'test_form'                => false,
     2389      'test_type'                => false,
     2390      'unique_filename_callback' => function () use ($filename) {
     2391        return $filename;
     2392      },
     2393    ));
     2394    remove_filter('upload_dir', $use_dir);
     2395
     2396    return empty($result['file']) ? '' : $result['file'];
     2397  }
     2398}
     2399
     2400
     2401/**
     2402 * HELPER: recursively remove a directory.
     2403 */
     2404if (!function_exists('vtables_remove_directory')) {
     2405  function vtables_remove_directory($dir) {
     2406    if (!is_dir($dir)) {
     2407      return;
     2408    }
     2409    $files = array_diff(scandir($dir), array('.', '..'));
     2410    foreach ($files as $file) {
     2411      $path = $dir . $file;
     2412      if (is_dir($path)) {
     2413        vtables_remove_directory($path . '/');
     2414      } else {
     2415        wp_delete_file($path);
     2416      }
     2417    }
     2418    rmdir($dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
     2419  }
     2420}
  • vtables/trunk/includes/shortcode.html

    r3133736 r3754379  
    11<!DOCTYPE html>
    2 <html lang="en-US">
    3   <head>
    4     <meta charset="utf-8" />
    5     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    6     <title>vTables</title>
    7     <link
    8       href="../dist/spa/css/material-icons-font-dist.css"
    9       rel="stylesheet"
    10       type="text/css"
    11     />
    12     <link
    13       href="../dist/spa/css/quasar.prod.css"
    14       rel="stylesheet"
    15       type="text/css"
    16     />
    17     <link href="../dist/spa/css/vtables.css" rel="stylesheet" type="text/css" />
    18   </head>
     2<html>
     3<head>
     4  <meta charset="utf-8">
     5  <meta name="robots" content="noindex, nofollow">
     6  <title>vTables</title>
     7</head>
     8<body>
     9<script>
     10/*
     11 * Compatibility stub for pages cached while vTables 0.2.x was active: its
     12 * [vtables] iframe pointed here and carried the table id and the REST base
     13 * URL as data attributes. Send such an iframe on to the current embed
     14 * document (?vtables_embed=<id>, which works with and without pretty
     15 * permalinks). Pages rendered by vTables 1.0 never load this file.
     16 */
     17(function () {
     18  try {
     19    var frame = window.frameElement;
     20    if (!frame) return;
    1921
    20   <body>
    21     <div class="vtable q-pa-sm">
    22       <q-table
    23         v-if="selectedTable"
    24         v-model:pagination="settings.bottom.initial"
    25         :class="tableCssClasses"
    26         :columns="columns"
    27         :rows="rows"
    28         :grid="settings.general.grid"
    29         :bordered="settings.general.bordered"
    30         :table-style="tableStyle"
    31         :card-class="cardClasses"
    32         :card-style="cardStyle"
    33         :dark="settings.general.dark"
    34         :dense="settings.general.dense"
    35         :flat="settings.general.flat || !selectedTable.source.rows.length"
    36         :square="settings.general.square"
    37         :separator="settings.general.separator"
    38       >
    39         <!-- TOP -->
    40         <template #top="props">
    41           <div class="vtable__top | full-width flex" :class="topClasses">
    42             <div v-if="!settings.top.hideTitle" class="col">
    43               <h1
    44                 class="q-mt-xs q-mr-xs q-mb-xs q-ml-none text-h5"
    45                 :class="
    46                     settings.top.textColor
    47                       ? `text-${settings.top.textColor}`
    48                       : `text-${settings.general.textColor}`
    49                   "
    50               >
    51                 {{ selectedTable.title }}
    52               </h1>
    53             </div>
    54           </div>
    55         </template>
     22    var id = parseInt(frame.getAttribute('data-id'), 10);
     23    var base = frame.getAttribute('data-baseurl') || '';
     24    if (!id || id < 1 || !/^https?:\/\//i.test(base)) return;
    5625
    57         <!-- BODY -->
    58         <template #body="props">
    59           <q-tr :props="props">
    60             <q-td v-for="col in props.cols" :key="col.name" :props="props">
    61               {{ props.row[col.field] || props.row[col.label] }}
    62             </q-td>
    63           </q-tr>
    64         </template>
     26    // rest_url() is either .../wp-json/ or .../?rest_route=/ (plain permalinks).
     27    var home = base.replace(/\?rest_route=.*$/, '').replace(/wp-json\/?$/, '');
     28    var separator = home.indexOf('?') === -1 ? '?' : '&';
    6529
    66         <!-- GRID -->
    67         <template v-if="settings.general.grid" #item="props">
    68           <div
    69             class="vtable__grid | q-pa-xs col-xs-12 col-sm-6 col-md-4 grid-style-transition"
    70             :class="gridColumnWidth"
    71           >
    72             <q-card
    73               class="q-pt-sm full-height"
    74               :bordered="props.bordered"
    75               :dark="props.dark"
    76               :dense="props.dense"
    77               :flat="props.flat"
    78               :square="props.square"
    79               :class="[
    80                   `bg-${settingsGeneralBackgroundColor}`,
    81                   props.cardClass,
    82                 ]"
    83             >
    84               <q-list>
    85                 <q-item v-for="col in props.cols" :key="col.name">
    86                   <q-item-section v-if="col.value">
    87                     <q-item-label
    88                       caption
    89                       :class="`text-${settingsGeneralTextColor}`"
    90                     >
    91                       {{ col.label }}
    92                     </q-item-label>
    93                     <q-item-label
    94                       :class="[
    95                           `text-${settingsGeneralTextColor}`,
    96                         ]"
    97                     >
    98                       <div
    99                         v-if="renderLinksAsLinks(col.value)"
    100                         class="flex gap-5"
    101                       >
    102                         <template
    103                           v-for="link in col.value.split(' ')"
    104                           :key="link"
    105                         >
    106                           <a
    107                             :href="link"
    108                             target="_blank"
    109                             :class="`text-${settings.content.buttonTextColor}`"
    110                           >
    111                             <span v-if="settings.content.buttonText">
    112                               {{ settings.content.buttonText }}
    113                             </span>
    114                             <span v-else> Link </span>
    115                           </a>
    116                         </template>
    117                       </div>
    118                       <div v-else class="cell-value">{{ col.value }}</div>
    119                     </q-item-label>
    120                   </q-item-section>
    121                 </q-item>
    122               </q-list>
    123             </q-card>
    124           </div>
    125         </template>
    126       </q-table>
    127 
    128       <!-- Show empty table while loading -->
    129       <q-table
    130         v-else-if="loading"
    131         :loading="loading"
    132         :rows="[]"
    133         :columns="[]"
    134       ></q-table>
    135 
    136       <!-- Show empty table if no data -->
    137       <q-table
    138         v-else
    139         :rows="[]"
    140         :columns="[]"
    141         :no-data-label="noDataLabel"
    142       ></q-table>
    143     </div>
    144 
    145     <!-- Add the following at the end of your body tag -->
    146     <script src="../dist/spa/js/vue.global.prod.js"></script>
    147     <script src="../dist/spa/js/quasar.umd.prod.js"></script>
    148 
    149     <script>
    150       const iframe = window.frameElement;
    151       const dataId = iframe.getAttribute('data-id');
    152       const dataCount = iframe.getAttribute('data-count');
    153       const baseUrl = iframe.getAttribute('data-baseurl');
    154       const table = document.querySelector('.vtable');
    155 
    156       if (table) {
    157         // Set attribute
    158         table.setAttribute('id', 'vtable-' + dataCount);
    159         // Add class
    160         table.classList.add('vtable-' + dataId);
    161       }
    162 
    163       // Vue instance
    164       const app = Vue.createApp({
    165         data() {
    166           return {
    167             dataCount: null,
    168             dataId: null,
    169             selectedTable: null,
    170             loading: false,
    171             title: '',
    172             columns: [],
    173             rows: [],
    174             settings: {},
    175             initialPagination: {},
    176             settingsSearchFilter: '',
    177             showFullImage: false,
    178             imagePath: '',
    179             tableMaximized: false,
    180           };
    181         },
    182         async mounted() {
    183           this.loading = true;
    184           this.dataCount = dataCount;
    185           this.dataId = dataId;
    186 
    187           // Fetch data
    188           try {
    189             this.selectedTable = await this.getTable(dataId);
    190           } catch (error) {
    191             console.log(error);
    192           }
    193 
    194           this.loading = false;
    195 
    196           // Set height of iframe if no data
    197           if (!this.selectedTable) {
    198             iframe.style.height = '70px';
    199             return;
    200           }
    201 
    202           // Descruct data
    203           const { source, settings, title } = this.selectedTable;
    204 
    205           // Assign data
    206           this.columns = source.columns;
    207           this.rows = source.rows;
    208           this.settings = settings;
    209           this.initialPagination = settings.bottom.initial;
    210           this.title = title;
    211 
    212           // Dynamic Height
    213           this.$nextTick(() => {
    214             const bodyHeight = document.body.clientHeight;
    215             iframe.style.minHeight = bodyHeight + 20 + 'px';
    216           });
    217 
    218           // Dynamic width
    219           if (this.settings.dimensions.tableMaxWidth) {
    220             iframe.style.maxWidth =
    221               this.settings.dimensions.tableMaxWidth + 'px';
    222           }
    223 
    224           // Add event listener
    225           window.addEventListener('resize', this.handleResize);
    226         },
    227         beforeDestroy() {
    228           // Destroy event listener
    229           window.removeEventListener('resize', this.handleResize);
    230         },
    231         computed: {
    232           // Table style computed prop.
    233           tableStyle() {
    234             if (Object.keys(this.settings).length) {
    235               return {
    236                 height: this.settings.dimensions.tableHeight
    237                   ? this.settings.dimensions.tableHeight + 'px'
    238                   : '100%',
    239               };
    240             }
    241 
    242             return '';
    243           },
    244 
    245           // No data label computed prop.
    246           noDataLabel() {
    247             if (!this.selectedTable) {
    248               return 'Table with this shortcode does not exist';
    249             }
    250 
    251             return 'No data available';
    252           },
    253 
    254           // Card classes computed prop.
    255           cardClasses() {
    256             if (Object.keys(this.settings).length) {
    257               const cssClasses = [this.settings.customCss.cardClass];
    258 
    259               if (this.settings.general.backgroundColor) {
    260                 cssClasses.push(`bg-${this.settings.general.backgroundColor}`);
    261               }
    262 
    263               if (this.settings.general.textColor) {
    264                 cssClasses.push(`text-${this.settings.general.textColor}`);
    265               }
    266 
    267               return cssClasses;
    268             }
    269 
    270             return [];
    271           },
    272 
    273           // Card style computed prop.
    274           cardStyle() {
    275             if (Object.keys(this.settings).length) {
    276               return {
    277                 maxWidth: this.settings.general.tableMaxWidth
    278                   ? this.settings.general.tableMaxWidth + 'px'
    279                   : '100%',
    280               };
    281             }
    282 
    283             return '';
    284           },
    285 
    286           // Top classes computed prop.
    287           topClasses() {
    288             if (Object.keys(this.settings).length) {
    289               const cssClasses = [];
    290 
    291               if (this.settings.top.topBackgroundColor) {
    292                 cssClasses.push(`bg-${this.settings.top.topBackgroundColor}`);
    293               }
    294 
    295               if (this.settings.top.textColor) {
    296                 cssClasses.push(`text-${this.settings.top.textColor}`);
    297               }
    298 
    299               return cssClasses;
    300             }
    301 
    302             return [];
    303           },
    304 
    305           // Table CSS classes computed prop.
    306           tableCssClasses() {
    307             const isAdminbarPresented = document.querySelector('#wpadminbar');
    308             return [
    309               { 'sticky-header-table': this.settings.top.stickyHeader },
    310               {
    311                 'sticky-column-first': this.settings.columns.stickyFirstColumn,
    312               },
    313               { 'sticky-column-last': this.settings.columns.stickyLastColumn },
    314               {
    315                 'admin-bar-presented-on-fullscreen':
    316                   this.tableMaximized && isAdminbarPresented,
    317               },
    318             ];
    319           },
    320 
    321           // Grid column width computed prop.
    322           gridColumnWidth() {
    323             if (
    324               this.settings.bottom.initial &&
    325               this.settings.bottom.initial.rowsPerPage
    326             ) {
    327               return `col-sm-${12 / this.settings.bottom.initial.rowsPerPage}`;
    328             }
    329             return 'col-sm-3';
    330           },
    331 
    332           // General text color computed prop.
    333           settingsGeneralTextColor() {
    334             if (Object.keys(this.settings).length) {
    335               return this.settings.general.textColor;
    336             }
    337             return '';
    338           },
    339 
    340           // General background color computed prop.
    341           settingsGeneralBackgroundColor() {
    342             if (Object.keys(this.settings).length) {
    343               return this.settings.general.backgroundColor;
    344             }
    345             return '';
    346           },
    347         },
    348         setup() {
    349           /**
    350            * FETCH TABLE
    351            */
    352           async function getTable(id) {
    353             const base = baseUrl || '/wp-json/';
    354             const post = await fetch(`${base}vtables/v1/tables/${id}`).then(
    355               (resp) => resp.json()
    356             );
    357 
    358             if (!post || post?.data?.status === 404) {
    359               return;
    360             }
    361 
    362             return post.data;
    363           }
    364 
    365           /**
    366            * CHECK IF LINK
    367            * @param str
    368            */
    369           function getLinkType(str) {
    370             const regex = /http|https|jpg|jpeg|png|gif|bmp|tiff|webp|svg/i;
    371 
    372             if (!regex.test(str)) {
    373               return;
    374             }
    375 
    376             const linkPattern = /^(https?:\/\/\S+)$/i;
    377             const imageExtensions = /\.(jpg|jpeg|png|gif|bmp|tiff|webp|svg)$/i;
    378             const strArr = str.split(' ');
    379             const isLinks = strArr.every((el) => linkPattern.test(el));
    380             const isImages = strArr.every((el) => imageExtensions.test(el));
    381 
    382             if (isImages) {
    383               return 'images';
    384             }
    385 
    386             if (isLinks) {
    387               return 'links';
    388             }
    389           }
    390 
    391           /**
    392            * RENDER LINKS AS LINKS
    393            * @param value
    394            */
    395           function renderLinksAsLinks(value) {
    396             if (!Object.keys(this.settings).length) {
    397               return;
    398             }
    399 
    400             return (
    401               (!this.settings.content.turnLinksToImages &&
    402                 getLinkType(value) === 'images') ||
    403               getLinkType(value) === 'links'
    404             );
    405           }
    406 
    407           /**
    408            * HANDLE RESIZE
    409            */
    410           function handleResize() {
    411             const bodyHeight = document.body.clientHeight;
    412             iframe.style.minHeight = bodyHeight + 20 + 'px';
    413           }
    414 
    415           return {
    416             getTable,
    417             renderLinksAsLinks,
    418             getLinkType,
    419             handleResize,
    420           };
    421         },
    422       });
    423 
    424       app.use(Quasar);
    425       app.mount('#vtable-' + dataCount);
    426     </script>
    427   </body>
     30    window.location.replace(home + separator + 'vtables_embed=' + id);
     31  } catch (e) {
     32    // Cross-origin parent: nothing to read; the frame stays blank.
     33  }
     34})();
     35</script>
     36</body>
    42837</html>
  • vtables/trunk/index.html

    r3134061 r3754379  
    1212      content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
    1313    />
     14    <link rel="stylesheet" href="./public/css/material-icons-font-dist.css" />
    1415  </head>
    1516  <body>
  • vtables/trunk/package-lock.json

    r3036822 r3754379  
    11{
    22  "name": "vtables",
    3   "version": "0.0.1",
     3  "version": "3.0.0",
    44  "lockfileVersion": 3,
    55  "requires": true,
    … …  
    77    "": {
    88      "name": "vtables",
    9       "version": "0.0.1",
     9      "version": "3.0.0",
     10      "license": "GPL-3.0-only",
    1011      "dependencies": {
    1112        "@quasar/extras": "^1.0.0",
    1213        "@types/papaparse": "^5.3.9",
    13         "@types/wpapi": "^1.1.1",
    1414        "@vuelidate/core": "^2.0.3",
    1515        "@vuelidate/validators": "^2.0.3",
    … …  
    2222        "vue": "^3.0.0",
    2323        "vue-i18n": "^9.2.2",
    24         "vue-router": "^4.0.0",
    25         "wpapi": "^1.2.2"
     24        "vue-router": "^4.0.0"
    2625      },
    2726      "devDependencies": {
    … …  
    3736        "eslint-plugin-vue": "^9.0.0",
    3837        "prettier": "^2.5.1",
    39         "typescript": "5.0.4"
     38        "typescript": "5.0.4",
     39        "vue-tsc": "^1.8.27"
    4040      },
    4141      "engines": {
    … …  
    694694      "license": "MIT"
    695695    },
    696     "node_modules/@types/wpapi": {
    697       "version": "1.1.1",
    698       "resolved": "https://registry.npmjs.org/@types/wpapi/-/wpapi-1.1.1.tgz",
    699       "integrity": "sha512-Ek1siuveAaN/iVoRvGbSGWNBg7RXQoz4Am1C3nnoXVA33f/0+9GibJq/xNBWsInZk5mNVqySkeYVco5C1KMqog==",
    700       "license": "MIT"
    701     },
    702696    "node_modules/@typescript-eslint/eslint-plugin": {
    703697      "version": "5.59.11",
    … …  
    934928      }
    935929    },
     930    "node_modules/@volar/language-core": {
     931      "version": "1.11.1",
     932      "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz",
     933      "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==",
     934      "dev": true,
     935      "dependencies": {
     936        "@volar/source-map": "1.11.1"
     937      }
     938    },
     939    "node_modules/@volar/source-map": {
     940      "version": "1.11.1",
     941      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz",
     942      "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==",
     943      "dev": true,
     944      "dependencies": {
     945        "muggle-string": "^0.3.1"
     946      }
     947    },
     948    "node_modules/@volar/typescript": {
     949      "version": "1.11.1",
     950      "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz",
     951      "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==",
     952      "dev": true,
     953      "dependencies": {
     954        "@volar/language-core": "1.11.1",
     955        "path-browserify": "^1.0.1"
     956      }
     957    },
    936958    "node_modules/@vue/compiler-core": {
    937959      "version": "3.3.4",
    … …  
    9901012      "license": "MIT"
    9911013    },
     1014    "node_modules/@vue/language-core": {
     1015      "version": "1.8.27",
     1016      "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz",
     1017      "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==",
     1018      "dev": true,
     1019      "dependencies": {
     1020        "@volar/language-core": "~1.11.1",
     1021        "@volar/source-map": "~1.11.1",
     1022        "@vue/compiler-dom": "^3.3.0",
     1023        "@vue/shared": "^3.3.0",
     1024        "computeds": "^0.0.1",
     1025        "minimatch": "^9.0.3",
     1026        "muggle-string": "^0.3.1",
     1027        "path-browserify": "^1.0.1",
     1028        "vue-template-compiler": "^2.7.14"
     1029      },
     1030      "peerDependencies": {
     1031        "typescript": "*"
     1032      },
     1033      "peerDependenciesMeta": {
     1034        "typescript": {
     1035          "optional": true
     1036        }
     1037      }
     1038    },
     1039    "node_modules/@vue/language-core/node_modules/brace-expansion": {
     1040      "version": "2.1.7",
     1041      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
     1042      "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
     1043      "dev": true,
     1044      "dependencies": {
     1045        "balanced-match": "^1.0.0"
     1046      }
     1047    },
     1048    "node_modules/@vue/language-core/node_modules/minimatch": {
     1049      "version": "9.0.9",
     1050      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
     1051      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
     1052      "dev": true,
     1053      "dependencies": {
     1054        "brace-expansion": "^2.0.2"
     1055      },
     1056      "engines": {
     1057        "node": ">=16 || 14 >=14.17"
     1058      },
     1059      "funding": {
     1060        "url": "https://github.com/sponsors/isaacs"
     1061      }
     1062    },
    9921063    "node_modules/@vue/reactivity": {
    9931064      "version": "3.3.4",
    … …  
    12231294      "engines": {
    12241295        "node": ">=0.4.0"
     1296      }
     1297    },
     1298    "node_modules/agent-base": {
     1299      "version": "6.0.2",
     1300      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
     1301      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
     1302      "dependencies": {
     1303        "debug": "4"
     1304      },
     1305      "engines": {
     1306        "node": ">= 6.0.0"
    12251307      }
    12261308    },
    … …  
    14671549    },
    14681550    "node_modules/axios": {
    1469       "version": "1.4.0",
    1470       "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
    1471       "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
    1472       "license": "MIT",
    1473       "dependencies": {
    1474         "follow-redirects": "^1.15.0",
    1475         "form-data": "^4.0.0",
    1476         "proxy-from-env": "^1.1.0"
     1551      "version": "1.18.1",
     1552      "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
     1553      "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
     1554      "dependencies": {
     1555        "follow-redirects": "^1.16.0",
     1556        "form-data": "^4.0.5",
     1557        "https-proxy-agent": "^5.0.1",
     1558        "proxy-from-env": "^2.1.0"
    14771559      }
    14781560    },
    … …  
    16821764      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
    16831765      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
     1766      "dev": true,
    16841767      "license": "MIT",
    16851768      "dependencies": {
    … …  
    16891772      "funding": {
    16901773        "url": "https://github.com/sponsors/ljharb"
     1774      }
     1775    },
     1776    "node_modules/call-bind-apply-helpers": {
     1777      "version": "1.0.2",
     1778      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
     1779      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
     1780      "dependencies": {
     1781        "es-errors": "^1.3.0",
     1782        "function-bind": "^1.1.2"
     1783      },
     1784      "engines": {
     1785        "node": ">= 0.4"
    16911786      }
    16921787    },
    … …  
    19292024      "license": "MIT"
    19302025    },
    1931     "node_modules/component-emitter": {
    1932       "version": "1.3.0",
    1933       "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
    1934       "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
    1935       "license": "MIT"
    1936     },
    19372026    "node_modules/compress-commons": {
    19382027      "version": "4.1.1",
    … …  
    20172106      "license": "MIT"
    20182107    },
     2108    "node_modules/computeds": {
     2109      "version": "0.0.1",
     2110      "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz",
     2111      "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==",
     2112      "dev": true
     2113    },
    20192114    "node_modules/concat-map": {
    20202115      "version": "0.0.1",
    … …  
    20642159      "license": "MIT"
    20652160    },
    2066     "node_modules/cookiejar": {
    2067       "version": "2.1.4",
    2068       "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
    2069       "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
    2070       "license": "MIT"
    2071     },
    20722161    "node_modules/core-util-is": {
    20732162      "version": "1.0.3",
    … …  
    21382227      "license": "MIT"
    21392228    },
     2229    "node_modules/de-indent": {
     2230      "version": "1.0.2",
     2231      "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
     2232      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
     2233      "dev": true
     2234    },
    21402235    "node_modules/debug": {
    21412236      "version": "4.3.4",
    … …  
    22572352      }
    22582353    },
     2354    "node_modules/dunder-proto": {
     2355      "version": "1.0.1",
     2356      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
     2357      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
     2358      "dependencies": {
     2359        "call-bind-apply-helpers": "^1.0.1",
     2360        "es-errors": "^1.3.0",
     2361        "gopd": "^1.2.0"
     2362      },
     2363      "engines": {
     2364        "node": ">= 0.4"
     2365      }
     2366    },
    22592367    "node_modules/ee-first": {
    22602368      "version": "1.1.1",
    … …  
    23092417      "dependencies": {
    23102418        "once": "^1.4.0"
     2419      }
     2420    },
     2421    "node_modules/es-define-property": {
     2422      "version": "1.0.1",
     2423      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
     2424      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
     2425      "engines": {
     2426        "node": ">= 0.4"
     2427      }
     2428    },
     2429    "node_modules/es-errors": {
     2430      "version": "1.3.0",
     2431      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
     2432      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
     2433      "engines": {
     2434        "node": ">= 0.4"
     2435      }
     2436    },
     2437    "node_modules/es-object-atoms": {
     2438      "version": "1.1.2",
     2439      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
     2440      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
     2441      "dependencies": {
     2442        "es-errors": "^1.3.0"
     2443      },
     2444      "engines": {
     2445        "node": ">= 0.4"
     2446      }
     2447    },
     2448    "node_modules/es-set-tostringtag": {
     2449      "version": "2.1.0",
     2450      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
     2451      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
     2452      "dependencies": {
     2453        "es-errors": "^1.3.0",
     2454        "get-intrinsic": "^1.2.6",
     2455        "has-tostringtag": "^1.0.2",
     2456        "hasown": "^2.0.2"
     2457      },
     2458      "engines": {
     2459        "node": ">= 0.4"
    23112460      }
    23122461    },
    … …  
    28983047    },
    28993048    "node_modules/follow-redirects": {
    2900       "version": "1.15.2",
    2901       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
    2902       "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
     3049      "version": "1.16.0",
     3050      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
     3051      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
    29033052      "funding": [
    29043053        {
    … …  
    29073056        }
    29083057      ],
    2909       "license": "MIT",
    29103058      "engines": {
    29113059        "node": ">=4.0"
    … …  
    29183066    },
    29193067    "node_modules/form-data": {
    2920       "version": "4.0.0",
    2921       "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
    2922       "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
    2923       "license": "MIT",
     3068      "version": "4.0.6",
     3069      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
     3070      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
    29243071      "dependencies": {
    29253072        "asynckit": "^0.4.0",
    29263073        "combined-stream": "^1.0.8",
    2927         "mime-types": "^2.1.12"
     3074        "es-set-tostringtag": "^2.1.0",
     3075        "hasown": "^2.0.4",
     3076        "mime-types": "^2.1.35"
    29283077      },
    29293078      "engines": {
    29303079        "node": ">= 6"
    2931       }
    2932     },
    2933     "node_modules/formidable": {
    2934       "version": "1.2.6",
    2935       "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz",
    2936       "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==",
    2937       "license": "MIT",
    2938       "funding": {
    2939         "url": "https://ko-fi.com/tunnckoCore/commissions"
    29403080      }
    29413081    },
    … …  
    30183158    },
    30193159    "node_modules/function-bind": {
    3020       "version": "1.1.1",
    3021       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
    3022       "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
    3023       "license": "MIT"
     3160      "version": "1.1.2",
     3161      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
     3162      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
     3163      "funding": {
     3164        "url": "https://github.com/sponsors/ljharb"
     3165      }
    30243166    },
    30253167    "node_modules/get-caller-file": {
    … …  
    30343176    },
    30353177    "node_modules/get-intrinsic": {
    3036       "version": "1.2.1",
    3037       "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz",
    3038       "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==",
    3039       "license": "MIT",
    3040       "dependencies": {
    3041         "function-bind": "^1.1.1",
    3042         "has": "^1.0.3",
    3043         "has-proto": "^1.0.1",
    3044         "has-symbols": "^1.0.3"
     3178      "version": "1.3.0",
     3179      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
     3180      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
     3181      "dependencies": {
     3182        "call-bind-apply-helpers": "^1.0.2",
     3183        "es-define-property": "^1.0.1",
     3184        "es-errors": "^1.3.0",
     3185        "es-object-atoms": "^1.1.1",
     3186        "function-bind": "^1.1.2",
     3187        "get-proto": "^1.0.1",
     3188        "gopd": "^1.2.0",
     3189        "has-symbols": "^1.1.0",
     3190        "hasown": "^2.0.2",
     3191        "math-intrinsics": "^1.1.0"
     3192      },
     3193      "engines": {
     3194        "node": ">= 0.4"
    30453195      },
    30463196      "funding": {
    30473197        "url": "https://github.com/sponsors/ljharb"
     3198      }
     3199    },
     3200    "node_modules/get-proto": {
     3201      "version": "1.0.1",
     3202      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
     3203      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
     3204      "dependencies": {
     3205        "dunder-proto": "^1.0.1",
     3206        "es-object-atoms": "^1.0.0"
     3207      },
     3208      "engines": {
     3209        "node": ">= 0.4"
    30483210      }
    30493211    },
    … …  
    31193281      }
    31203282    },
     3283    "node_modules/gopd": {
     3284      "version": "1.2.0",
     3285      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
     3286      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
     3287      "engines": {
     3288        "node": ">= 0.4"
     3289      },
     3290      "funding": {
     3291        "url": "https://github.com/sponsors/ljharb"
     3292      }
     3293    },
    31213294    "node_modules/graceful-fs": {
    31223295      "version": "4.2.11",
    … …  
    31443317      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
    31453318      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
     3319      "dev": true,
    31463320      "license": "MIT",
    31473321      "dependencies": {
    … …  
    31623336      }
    31633337    },
    3164     "node_modules/has-proto": {
    3165       "version": "1.0.1",
    3166       "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz",
    3167       "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==",
    3168       "license": "MIT",
     3338    "node_modules/has-symbols": {
     3339      "version": "1.1.0",
     3340      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
     3341      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
    31693342      "engines": {
    31703343        "node": ">= 0.4"
    … …  
    31743347      }
    31753348    },
    3176     "node_modules/has-symbols": {
    3177       "version": "1.0.3",
    3178       "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
    3179       "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
    3180       "license": "MIT",
     3349    "node_modules/has-tostringtag": {
     3350      "version": "1.0.2",
     3351      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
     3352      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
     3353      "dependencies": {
     3354        "has-symbols": "^1.0.3"
     3355      },
    31813356      "engines": {
    31823357        "node": ">= 0.4"
    … …  
    31843359      "funding": {
    31853360        "url": "https://github.com/sponsors/ljharb"
     3361      }
     3362    },
     3363    "node_modules/hasown": {
     3364      "version": "2.0.4",
     3365      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
     3366      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
     3367      "dependencies": {
     3368        "function-bind": "^1.1.2"
     3369      },
     3370      "engines": {
     3371        "node": ">= 0.4"
    31863372      }
    31873373    },
    … …  
    32333419      "engines": {
    32343420        "node": ">= 0.8"
     3421      }
     3422    },
     3423    "node_modules/https-proxy-agent": {
     3424      "version": "5.0.1",
     3425      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
     3426      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
     3427      "dependencies": {
     3428        "agent-base": "6",
     3429        "debug": "4"
     3430      },
     3431      "engines": {
     3432        "node": ">= 6"
    32353433      }
    32363434    },
    … …  
    33213519      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
    33223520      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
     3521      "dev": true,
    33233522      "license": "ISC"
    33243523    },
    … …  
    37193918      }
    37203919    },
    3721     "node_modules/li": {
    3722       "version": "1.3.0",
    3723       "resolved": "https://registry.npmjs.org/li/-/li-1.3.0.tgz",
    3724       "integrity": "sha512-z34TU6GlMram52Tss5mt1m//ifRIpKH5Dqm7yUVOdHI+BQCs9qGPHFaCUTIzsWX7edN30aa2WrPwR7IO10FHaw==",
    3725       "license": "MIT"
    3726     },
    37273920    "node_modules/locate-path": {
    37283921      "version": "6.0.0",
    … …  
    38444037      }
    38454038    },
     4039    "node_modules/math-intrinsics": {
     4040      "version": "1.1.0",
     4041      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
     4042      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
     4043      "engines": {
     4044        "node": ">= 0.4"
     4045      }
     4046    },
    38464047    "node_modules/media-typer": {
    38474048      "version": "0.3.0",
    … …  
    38754076      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
    38764077      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
     4078      "dev": true,
    38774079      "license": "MIT",
    38784080      "engines": {
    … …  
    38944096      }
    38954097    },
    3896     "node_modules/mime": {
    3897       "version": "2.6.0",
    3898       "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
    3899       "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
    3900       "license": "MIT",
    3901       "bin": {
    3902         "mime": "cli.js"
    3903       },
    3904       "engines": {
    3905         "node": ">=4.0.0"
    3906       }
    3907     },
    39084098    "node_modules/mime-db": {
    39094099      "version": "1.52.0",
    … …  
    39654155      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
    39664156      "license": "MIT"
     4157    },
     4158    "node_modules/muggle-string": {
     4159      "version": "0.3.1",
     4160      "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz",
     4161      "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==",
     4162      "dev": true
    39674163    },
    39684164    "node_modules/mute-stream": {
    … …  
    40694265      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
    40704266      "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==",
     4267      "dev": true,
    40714268      "license": "MIT",
    40724269      "funding": {
    … …  
    42544451      }
    42554452    },
    4256     "node_modules/parse-link-header": {
    4257       "version": "1.0.1",
    4258       "resolved": "https://registry.npmjs.org/parse-link-header/-/parse-link-header-1.0.1.tgz",
    4259       "integrity": "sha512-Z0gpfHmwCIKDr5rRzjypL+p93aHVWO7e+0rFcUl9E3sC67njjs+xHFenuboSXZGlvYtmQqRzRaE3iFpTUnLmFQ==",
    4260       "license": "MIT",
    4261       "dependencies": {
    4262         "xtend": "~4.0.1"
    4263       }
    4264     },
    42654453    "node_modules/parseurl": {
    42664454      "version": "1.3.3",
    … …  
    42724460        "node": ">= 0.8"
    42734461      }
     4462    },
     4463    "node_modules/path-browserify": {
     4464      "version": "1.0.1",
     4465      "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
     4466      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
     4467      "dev": true
    42744468    },
    42754469    "node_modules/path-exists": {
    … …  
    44684662    },
    44694663    "node_modules/proxy-from-env": {
    4470       "version": "1.1.0",
    4471       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
    4472       "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
    4473       "license": "MIT"
     4664      "version": "2.1.0",
     4665      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
     4666      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
     4667      "engines": {
     4668        "node": ">=10"
     4669      }
    44744670    },
    44754671    "node_modules/punycode": {
    … …  
    45754771      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
    45764772      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
     4773      "dev": true,
    45774774      "license": "MIT",
    45784775      "dependencies": {
    … …  
    48455042      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
    48465043      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
     5044      "dev": true,
    48475045      "funding": [
    48485046        {
    … …  
    50415239      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
    50425240      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
     5241      "dev": true,
    50435242      "license": "MIT",
    50445243      "dependencies": {
    … …  
    51285327      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
    51295328      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
     5329      "dev": true,
    51305330      "license": "MIT",
    51315331      "dependencies": {
    … …  
    51725372      "funding": {
    51735373        "url": "https://github.com/sponsors/sindresorhus"
    5174       }
    5175     },
    5176     "node_modules/superagent": {
    5177       "version": "4.1.0",
    5178       "resolved": "https://registry.npmjs.org/superagent/-/superagent-4.1.0.tgz",
    5179       "integrity": "sha512-FT3QLMasz0YyCd4uIi5HNe+3t/onxMyEho7C3PSqmti3Twgy2rXT4fmkTz6wRL6bTF4uzPcfkUCa8u4JWHw8Ag==",
    5180       "license": "MIT",
    5181       "dependencies": {
    5182         "component-emitter": "^1.2.0",
    5183         "cookiejar": "^2.1.2",
    5184         "debug": "^4.1.0",
    5185         "form-data": "^2.3.3",
    5186         "formidable": "^1.2.0",
    5187         "methods": "^1.1.1",
    5188         "mime": "^2.4.0",
    5189         "qs": "^6.6.0",
    5190         "readable-stream": "^3.0.6"
    5191       },
    5192       "engines": {
    5193         "node": ">= 6.0"
    5194       }
    5195     },
    5196     "node_modules/superagent/node_modules/form-data": {
    5197       "version": "2.5.1",
    5198       "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz",
    5199       "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==",
    5200       "license": "MIT",
    5201       "dependencies": {
    5202         "asynckit": "^0.4.0",
    5203         "combined-stream": "^1.0.6",
    5204         "mime-types": "^2.1.12"
    5205       },
    5206       "engines": {
    5207         "node": ">= 0.12"
    5208       }
    5209     },
    5210     "node_modules/superagent/node_modules/qs": {
    5211       "version": "6.11.2",
    5212       "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
    5213       "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==",
    5214       "license": "BSD-3-Clause",
    5215       "dependencies": {
    5216         "side-channel": "^1.0.4"
    5217       },
    5218       "engines": {
    5219         "node": ">=0.6"
    5220       },
    5221       "funding": {
    5222         "url": "https://github.com/sponsors/ljharb"
    52235374      }
    52245375    },
    … …  
    55195670      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
    55205671      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
     5672      "dev": true,
    55215673      "license": "MIT"
    55225674    },
    … …  
    57305882      }
    57315883    },
     5884    "node_modules/vue-template-compiler": {
     5885      "version": "2.7.16",
     5886      "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
     5887      "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
     5888      "dev": true,
     5889      "dependencies": {
     5890        "de-indent": "^1.0.2",
     5891        "he": "^1.2.0"
     5892      }
     5893    },
     5894    "node_modules/vue-tsc": {
     5895      "version": "1.8.27",
     5896      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz",
     5897      "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==",
     5898      "dev": true,
     5899      "dependencies": {
     5900        "@volar/typescript": "~1.11.1",
     5901        "@vue/language-core": "1.8.27",
     5902        "semver": "^7.5.4"
     5903      },
     5904      "bin": {
     5905        "vue-tsc": "bin/vue-tsc.js"
     5906      },
     5907      "peerDependencies": {
     5908        "typescript": "*"
     5909      }
     5910    },
    57325911    "node_modules/wcwidth": {
    57335912      "version": "1.0.1",
    … …  
    57865965      }
    57875966    },
    5788     "node_modules/wpapi": {
    5789       "version": "1.2.2",
    5790       "resolved": "https://registry.npmjs.org/wpapi/-/wpapi-1.2.2.tgz",
    5791       "integrity": "sha512-lkgi8Gjav3SArrCkNpG61ZnmCyamXKB+SjaR8tAoHhSZbJRTeabIlsdqUUAN3JGbVY3ht8p+EGdpCFIaanI5+w==",
    5792       "license": "MIT",
    5793       "dependencies": {
    5794         "li": "^1.3.0",
    5795         "parse-link-header": "^1.0.1",
    5796         "qs": "^6.6.0",
    5797         "superagent": "^4.0.0"
    5798       }
    5799     },
    5800     "node_modules/wpapi/node_modules/qs": {
    5801       "version": "6.11.2",
    5802       "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
    5803       "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==",
    5804       "license": "BSD-3-Clause",
    5805       "dependencies": {
    5806         "side-channel": "^1.0.4"
    5807       },
    5808       "engines": {
    5809         "node": ">=0.6"
    5810       },
    5811       "funding": {
    5812         "url": "https://github.com/sponsors/ljharb"
    5813       }
    5814     },
    58155967    "node_modules/wrap-ansi": {
    58165968      "version": "7.0.0",
    … …  
    58465998      "engines": {
    58475999        "node": ">=12"
    5848       }
    5849     },
    5850     "node_modules/xtend": {
    5851       "version": "4.0.2",
    5852       "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
    5853       "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
    5854       "license": "MIT",
    5855       "engines": {
    5856         "node": ">=0.4"
    58576000      }
    58586001    },
  • vtables/trunk/package.json

    r3576926 r3754379  
    11{
    22  "name": "vtables",
    3   "version": "0.2.1",
    4   "description": "Create stunning, interactive tables effortlessly. No coding required. The ultimate WordPress plugin, lets you design powerful tables in a few clicks. Transform your data visualization now.",
     3  "version": "0.3",
     4  "description": "Build interactive data tables from CSV, Excel and JSON files, or from scratch, and embed them anywhere with a shortcode.",
    55  "productName": "vTables",
    6   "author": "iamvs.com",
     6  "author": "vTables",
     7  "license": "GPL-3.0-only",
    78  "private": true,
    89  "scripts": {
    910    "lint": "eslint --ext .js,.ts,.vue ./",
    1011    "format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
     12    "typecheck": "vue-tsc --noEmit",
    1113    "test": "echo \"No test specified\" && exit 0",
    1214    "dev": "quasar dev",
    13     "build": "quasar build",
     15    "build": "quasar build && node scripts/patch-asset-base.mjs",
    1416    "release": "./release.sh"
    1517  },
    … …  
    1719    "@quasar/extras": "^1.0.0",
    1820    "@types/papaparse": "^5.3.9",
    19     "@types/wpapi": "^1.1.1",
    2021    "@vuelidate/core": "^2.0.3",
    2122    "@vuelidate/validators": "^2.0.3",
    … …  
    2627    "pinia": "^2.0.11",
    2728    "quasar": "^2.6.0",
    28     "rollbar": "^2.26.2",
    2929    "vue": "^3.0.0",
    3030    "vue-i18n": "^9.2.2",
    31     "vue-router": "^4.0.0",
    32     "woocommerce-rest-ts-api": "^7.0.0",
    33     "wpapi": "^1.2.2"
     31    "vue-router": "^4.0.0"
    3432  },
    3533  "devDependencies": {
    … …  
    4543    "eslint-plugin-vue": "^9.0.0",
    4644    "prettier": "^2.5.1",
    47     "typescript": "5.0.4"
     45    "typescript": "5.0.4",
     46    "vue-tsc": "^1.8.27"
    4847  },
    4948  "engines": {
  • vtables/trunk/public/css/vtables.css

    r3212270 r3754379  
     1:root {
     2  --red: #f44336;
     3  --red-1: #ffebee;
     4  --red-2: #ffcdd2;
     5  --red-3: #ef9a9a;
     6  --red-4: #e57373;
     7  --red-5: #ef5350;
     8  --red-6: #f44336;
     9  --red-7: #e53935;
     10  --red-8: #d32f2f;
     11  --red-9: #c62828;
     12  --red-10: #b71c1c;
     13  --red-11: #ff8a80;
     14  --red-12: #ff5252;
     15  --red-13: #ff1744;
     16  --red-14: #d50000;
     17  --pink: #e91e63;
     18  --pink-1: #fce4ec;
     19  --pink-2: #f8bbd0;
     20  --pink-3: #f48fb1;
     21  --pink-4: #f06292;
     22  --pink-5: #ec407a;
     23  --pink-6: #e91e63;
     24  --pink-7: #d81b60;
     25  --pink-8: #c2185b;
     26  --pink-9: #ad1457;
     27  --pink-10: #880e4f;
     28  --pink-11: #ff80ab;
     29  --pink-12: #ff4081;
     30  --pink-13: #f50057;
     31  --pink-14: #c51162;
     32  --purple: #9c27b0;
     33  --purple-1: #f3e5f5;
     34  --purple-2: #e1bee7;
     35  --purple-3: #ce93d8;
     36  --purple-4: #ba68c8;
     37  --purple-5: #ab47bc;
     38  --purple-6: #9c27b0;
     39  --purple-7: #8e24aa;
     40  --purple-8: #7b1fa2;
     41  --purple-9: #6a1b9a;
     42  --purple-10: #4a148c;
     43  --purple-11: #ea80fc;
     44  --purple-12: #e040fb;
     45  --purple-13: #d500f9;
     46  --purple-14: #aa00ff;
     47  --deep-purple: #673ab7;
     48  --deep-purple-1: #ede7f6;
     49  --deep-purple-2: #d1c4e9;
     50  --deep-purple-3: #b39ddb;
     51  --deep-purple-4: #9575cd;
     52  --deep-purple-5: #7e57c2;
     53  --deep-purple-6: #673ab7;
     54  --deep-purple-7: #5e35b1;
     55  --deep-purple-8: #512da8;
     56  --deep-purple-9: #4527a0;
     57  --deep-purple-10: #311b92;
     58  --deep-purple-11: #b388ff;
     59  --deep-purple-12: #7c4dff;
     60  --deep-purple-13: #651fff;
     61  --deep-purple-14: #6200ea;
     62  --indigo: #3f51b5;
     63  --indigo-1: #e8eaf6;
     64  --indigo-2: #c5cae9;
     65  --indigo-3: #9fa8da;
     66  --indigo-4: #7986cb;
     67  --indigo-5: #5c6bc0;
     68  --indigo-6: #3f51b5;
     69  --indigo-7: #3949ab;
     70  --indigo-8: #303f9f;
     71  --indigo-9: #283593;
     72  --indigo-10: #1a237e;
     73  --indigo-11: #8c9eff;
     74  --indigo-12: #536dfe;
     75  --indigo-13: #3d5afe;
     76  --indigo-14: #304ffe;
     77  --blue: #2196f3;
     78  --blue-1: #e3f2fd;
     79  --blue-2: #bbdefb;
     80  --blue-3: #90caf9;
     81  --blue-4: #64b5f6;
     82  --blue-5: #42a5f5;
     83  --blue-6: #2196f3;
     84  --blue-7: #1e88e5;
     85  --blue-8: #1976d2;
     86  --blue-9: #1565c0;
     87  --blue-10: #0d47a1;
     88  --blue-11: #82b1ff;
     89  --blue-12: #448aff;
     90  --blue-13: #2979ff;
     91  --blue-14: #2962ff;
     92  --light-blue: #03a9f4;
     93  --light-blue-1: #e1f5fe;
     94  --light-blue-2: #b3e5fc;
     95  --light-blue-3: #81d4fa;
     96  --light-blue-4: #4fc3f7;
     97  --light-blue-5: #29b6f6;
     98  --light-blue-6: #03a9f4;
     99  --light-blue-7: #039be5;
     100  --light-blue-8: #0288d1;
     101  --light-blue-9: #0277bd;
     102  --light-blue-10: #01579b;
     103  --light-blue-11: #80d8ff;
     104  --light-blue-12: #40c4ff;
     105  --light-blue-13: #00b0ff;
     106  --light-blue-14: #0091ea;
     107  --cyan: #00bcd4;
     108  --cyan-1: #e0f7fa;
     109  --cyan-2: #b2ebf2;
     110  --cyan-3: #80deea;
     111  --cyan-4: #4dd0e1;
     112  --cyan-5: #26c6da;
     113  --cyan-6: #00bcd4;
     114  --cyan-7: #00acc1;
     115  --cyan-8: #0097a7;
     116  --cyan-9: #00838f;
     117  --cyan-10: #006064;
     118  --cyan-11: #84ffff;
     119  --cyan-12: #18ffff;
     120  --cyan-13: #00e5ff;
     121  --cyan-14: #00b8d4;
     122  --teal: #009688;
     123  --teal-1: #e0f2f1;
     124  --teal-2: #b2dfdb;
     125  --teal-3: #80cbc4;
     126  --teal-4: #4db6ac;
     127  --teal-5: #26a69a;
     128  --teal-6: #009688;
     129  --teal-7: #00897b;
     130  --teal-8: #00796b;
     131  --teal-9: #00695c;
     132  --teal-10: #004d40;
     133  --teal-11: #a7ffeb;
     134  --teal-12: #64ffda;
     135  --teal-13: #1de9b6;
     136  --teal-14: #00bfa5;
     137  --green: #4caf50;
     138  --green-1: #e8f5e9;
     139  --green-2: #c8e6c9;
     140  --green-3: #a5d6a7;
     141  --green-4: #81c784;
     142  --green-5: #66bb6a;
     143  --green-6: #4caf50;
     144  --green-7: #43a047;
     145  --green-8: #388e3c;
     146  --green-9: #2e7d32;
     147  --green-10: #1b5e20;
     148  --green-11: #b9f6ca;
     149  --green-12: #69f0ae;
     150  --green-13: #00e676;
     151  --green-14: #00c853;
     152  --light-green: #8bc34a;
     153  --light-green-1: #f1f8e9;
     154  --light-green-2: #dcedc8;
     155  --light-green-3: #c5e1a5;
     156  --light-green-4: #aed581;
     157  --light-green-5: #9ccc65;
     158  --light-green-6: #8bc34a;
     159  --light-green-7: #7cb342;
     160  --light-green-8: #689f38;
     161  --light-green-9: #558b2f;
     162  --light-green-10: #33691e;
     163  --light-green-11: #ccff90;
     164  --light-green-12: #b2ff59;
     165  --light-green-13: #76ff03;
     166  --light-green-14: #64dd17;
     167  --lime: #cddc39;
     168  --lime-1: #f9fbe7;
     169  --lime-2: #f0f4c3;
     170  --lime-3: #e6ee9c;
     171  --lime-4: #dce775;
     172  --lime-5: #d4e157;
     173  --lime-6: #cddc39;
     174  --lime-7: #c0ca33;
     175  --lime-8: #afb42b;
     176  --lime-9: #9e9d24;
     177  --lime-10: #827717;
     178  --lime-11: #f4ff81;
     179  --lime-12: #eeff41;
     180  --lime-13: #c6ff00;
     181  --lime-14: #aeea00;
     182  --yellow: #ffeb3b;
     183  --yellow-1: #fffde7;
     184  --yellow-2: #fff9c4;
     185  --yellow-3: #fff59d;
     186  --yellow-4: #fff176;
     187  --yellow-5: #ffee58;
     188  --yellow-6: #ffeb3b;
     189  --yellow-7: #fdd835;
     190  --yellow-8: #fbc02d;
     191  --yellow-9: #f9a825;
     192  --yellow-10: #f57f17;
     193  --yellow-11: #ffff8d;
     194  --yellow-12: #ffff00;
     195  --yellow-13: #ffea00;
     196  --yellow-14: #ffd600;
     197  --amber: #ffc107;
     198  --amber-1: #fff8e1;
     199  --amber-2: #ffecb3;
     200  --amber-3: #ffe082;
     201  --amber-4: #ffd54f;
     202  --amber-5: #ffca28;
     203  --amber-6: #ffc107;
     204  --amber-7: #ffb300;
     205  --amber-8: #ffa000;
     206  --amber-9: #ff8f00;
     207  --amber-10: #ff6f00;
     208  --amber-11: #ffe57f;
     209  --amber-12: #ffd740;
     210  --amber-13: #ffc400;
     211  --amber-14: #ffab00;
     212  --orange: #ff9800;
     213  --orange-1: #fff3e0;
     214  --orange-2: #ffe0b2;
     215  --orange-3: #ffcc80;
     216  --orange-4: #ffb74d;
     217  --orange-5: #ffa726;
     218  --orange-6: #ff9800;
     219  --orange-7: #fb8c00;
     220  --orange-8: #f57c00;
     221  --orange-9: #ef6c00;
     222  --orange-10: #e65100;
     223  --orange-11: #ffd180;
     224  --orange-12: #ffab40;
     225  --orange-13: #ff9100;
     226  --orange-14: #ff6d00;
     227  --deep-orange: #ff5722;
     228  --deep-orange-1: #fbe9e7;
     229  --deep-orange-2: #ffccbc;
     230  --deep-orange-3: #ffab91;
     231  --deep-orange-4: #ff8a65;
     232  --deep-orange-5: #ff7043;
     233  --deep-orange-6: #ff5722;
     234  --deep-orange-7: #f4511e;
     235  --deep-orange-8: #e64a19;
     236  --deep-orange-9: #d84315;
     237  --deep-orange-10: #bf360c;
     238  --deep-orange-11: #ff9e80;
     239  --deep-orange-12: #ff6e40;
     240  --deep-orange-13: #ff3d00;
     241  --deep-orange-14: #dd2c00;
     242  --brown: #795548;
     243  --brown-1: #efebe9;
     244  --brown-2: #d7ccc8;
     245  --brown-3: #bcaaa4;
     246  --brown-4: #a1887f;
     247  --brown-5: #8d6e63;
     248  --brown-6: #795548;
     249  --brown-7: #6d4c41;
     250  --brown-8: #5d4037;
     251  --brown-9: #4e342e;
     252  --brown-10: #3e2723;
     253  --brown-11: #d7ccc8;
     254  --brown-12: #bcaaa4;
     255  --brown-13: #8d6e63;
     256  --brown-14: #5d4037;
     257  --grey: #9e9e9e;
     258  --grey-1: #fafafa;
     259  --grey-2: #f5f5f5;
     260  --grey-3: #eeeeee;
     261  --grey-4: #e0e0e0;
     262  --grey-5: #bdbdbd;
     263  --grey-6: #9e9e9e;
     264  --grey-7: #757575;
     265  --grey-8: #616161;
     266  --grey-9: #424242;
     267  --grey-10: #212121;
     268  --grey-11: #f5f5f5;
     269  --grey-12: #eeeeee;
     270  --grey-13: #bdbdbd;
     271  --grey-14: #616161;
     272  --blue-grey: #607d8b;
     273  --blue-grey-1: #eceff1;
     274  --blue-grey-2: #cfd8dc;
     275  --blue-grey-3: #b0bec5;
     276  --blue-grey-4: #90a4ae;
     277  --blue-grey-5: #78909c;
     278  --blue-grey-6: #607d8b;
     279  --blue-grey-7: #546e7a;
     280  --blue-grey-8: #455a64;
     281  --blue-grey-9: #37474f;
     282  --blue-grey-10: #263238;
     283  --blue-grey-11: #cfd8dc;
     284  --blue-grey-12: #b0bec5;
     285  --blue-grey-13: #78909c;
     286  --blue-grey-14: #455a64;
     287}
     288
     289body.q-body--prevent-scroll {
     290  position: initial !important;
     291}
     292
    1293#wpcontent {
    2294  padding: 20px;
    … …  
    5297.gap-5 {
    6298  gap: 5px;
     299}
     300
     301.gap-10 {
     302  gap: 10px;
    7303}
    8304
    … …  
    40336}
    41337
    42 #adminmenu #toplevel_page_vtables .wp-menu-image img {
    43   opacity: 1;
    44 }
     338.q-table td,
     339.q-table th {
     340  white-space: normal;
     341  word-wrap: break-word;
     342}
     343
     344#q-notify .row {
     345  padding-top: 0;
     346  padding-bottom: 0;
     347}
     348
     349/* ===== TheTable styles ===== */
     350
     351.q-item__section--avatar {
     352  min-width: unset;
     353}
     354
     355.settings-open {
     356  margin-right: 325px;
     357}
     358
     359.fullscreen .q-table__top {
     360  padding: 0;
     361}
     362
     363.fullscreen .q-table__top .vtable__top {
     364  padding: 12px 16px;
     365}
     366
     367.fullscreen.q-table--dense .vtable__top {
     368  padding: 8px 16px;
     369}
     370
     371.cell-value {
     372  display: block;
     373  white-space: initial;
     374  width: inherit;
     375}
     376
     377.q-table--cell-separator .q-table__top {
     378  border-bottom: none !important;
     379}
     380
     381.q-table--cell-separator .q-table__top .vtable__top {
     382  border-bottom: 1px solid rgba(0, 0, 0, 0.12);
     383}
     384
     385.vtable {
     386  margin: 10px 0;
     387}
     388
     389.vtable .q-table__bottom {
     390  padding: 0;
     391}
     392
     393.vtable .q-table__bottom > div {
     394  border-radius: 0 0 4px 4px;
     395  padding: 4px 10px 4px 10px;
     396  min-height: inherit;
     397}
     398
     399.vtable .q-table__bottom--nodata {
     400  padding-left: 10px;
     401  padding-right: 10px;
     402}
     403
     404.vtable .q-field__bottom {
     405  padding-left: 0;
     406  padding-right: 0;
     407  position: absolute;
     408  background: #fff;
     409}
     410
     411.vtable .q-table__top {
     412  padding: 0;
     413  overflow: hidden;
     414}
     415
     416.vtable .q-table__top > div {
     417  padding: 12px 16px;
     418  min-height: inherit;
     419}
     420
     421.vtable .q-table--dense .q-table__top {
     422  padding: 0;
     423  overflow: hidden;
     424}
     425
     426.vtable .q-table--dense .q-table__top > div {
     427  padding: 8px 16px;
     428  min-height: inherit;
     429}
     430
     431.edit-icon {
     432  position: absolute;
     433}
     434.q-td .edit-icon {
     435  top: -8px;
     436}
     437/* Quasar makes body cells positioned, header cells not — the edit icon needs
     438   the th as its containing block. */
     439.q-table thead th {
     440  position: relative;
     441}
     442.q-table thead th .edit-icon {
     443  top: -6px;
     444}
     445.vtable__top h1 {
     446  display: inline-block;
     447  position: relative;
     448}
     449.vtable__top h1 .edit-icon {
     450  top: -10px;
     451  right: -45px;
     452}
     453.vtable__top:hover .edit-icon,
     454.q-td:hover .edit-icon,
     455.q-table thead th:hover .edit-icon,
     456.q-item__section:hover .edit-icon {
     457  display: block !important;
     458}
     459
     460.admin-bar-presented-on-fullscreen {
     461  margin-top: 30px;
     462  margin-left: 160px;
     463}
     464
     465.fullscreen-btn {
     466  height: 40px;
     467  width: 40px;
     468}
     469
     470.v-enter-active,
     471.v-leave-active {
     472  transition: opacity 0.5s ease;
     473}
     474
     475.v-enter-from,
     476.v-leave-to {
     477  opacity: 0;
     478}
     479
     480.q-table th.sortable {
     481  cursor: pointer;
     482  min-width: 116px;
     483}
     484
     485.cell-raw-html > * {
     486  cursor: pointer;
     487}
     488
     489.q-table__container {
     490  margin: 2px;
     491}
     492
     493.pre-wrap {
     494  white-space: pre-wrap;
     495}
     496
     497.flex-no-wrap {
     498  flex-wrap: nowrap;
     499}
  • vtables/trunk/quasar.config.js

    r3036822 r3754379  
    5555
    5656      vueRouterMode: 'hash', // available values: 'hash', 'history'
    57       // vueRouterBase,
     57
     58      // Empty on purpose: with the hash router the base only pollutes generated
     59      // hrefs, and leaving it unset would make Quasar derive it from publicPath
     60      // below — i.e. bake the placeholder token into the router too.
     61      vueRouterBase: '',
     62
    5863      // vueDevtools,
    5964      // vueOptionsAPI: false,
    … …  
    6166      // rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
    6267
    63       publicPath: ctx.dev ? '/' : './wp-content/plugins/vtables/dist/spa',
     68      // Placeholder, NOT a real path. Vite 2.9 inlines the public path as a
     69      // string literal into the bundle (lazy-chunk base, CSS url()), so a real
     70      // path here would hardcode the install location and break sites where WP
     71      // lives in a subdirectory, wp-content is relocated, or the plugin folder
     72      // is renamed. scripts/patch-asset-base.mjs rewrites this token after the
     73      // build into a runtime-resolved base — see the script's header.
     74      publicPath: ctx.dev ? '/' : '/__vtables_base__/',
    6475      // analyze: true,
    6576      // env: {},
    … …  
    95106      // https: true
    96107      open: false, // opens browser window automatically
    97       port: 9001,
     108      // Must match VTABLES_BASE_URL in env.php. Kept apart from vTables Pro's
     109      // 9001: Quasar silently moves to the next free port when this one is
     110      // taken, and WordPress would then load the other plugin's dev bundle.
     111      port: 9000,
     112      proxy: {
     113        '/wp-json': {
     114          target: 'http://localhost:8000',
     115          changeOrigin: true,
     116        },
     117      },
    98118    },
    99119
    100120    // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
    101121    framework: {
    102       config: {},
     122      config: {
     123        notify: {
     124          position: 'bottom-right',
     125        },
     126      },
    103127
    104128      // iconSet: 'material-icons', // Quasar icon set
    … …  
    122146    // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#sourcefiles
    123147    sourceFiles: {
    124       rootComponent: 'src/domains/general/ui/App.vue',
    125       store: 'src/domains/general/store/index',
    126       router: 'src/domains/general/router/index',
     148      // rootComponent: 'src/domains/general/ui/App.vue',
     149      // store: 'src/domains/general/store/index',
     150      // router: 'src/domains/general/router/index',
    127151      //   registerServiceWorker: 'src-pwa/register-service-worker',
    128152      //   serviceWorker: 'src-pwa/custom-service-worker',
  • vtables/trunk/readme.txt

    r3576934 r3754379  
    1 === Data table plugin: Spreadsheets | Google Sheets | WooCommerce product tables ===
     1=== vTables – Data Tables from CSV, Excel and JSON ===
    22Contributors: iamvs
    3 Author URI: https://iamvs.com/
    4 Plugin URI: https://vtables.pro/
    53Donate link: https://vtables.pro/
    6 Tags: data table, spreadsheet, google sheets, woocommerce-table, product-table
    7 Requires at least: 4.7
    8 Tested up to: 7.0
    9 Stable tag: 0.2.1
    10 Requires PHP: 7.0
     4Tags: table, data table, spreadsheet, csv, excel
     5Requires at least: 6.0
     6Tested up to: 7.1
     7Requires PHP: 7.4
     8Stable tag: 0.3
    119License: GPLv3
    1210License URI: https://www.gnu.org/licenses/gpl-3.0.html
    1311
    14 WordPress data table plugin: spreadsheets, google sheets, WooCommerce product tables. Transform your data into stunning data tables.
     12Build interactive data tables from CSV, Excel and JSON files, or from scratch, and embed them anywhere with a shortcode.
    1513
    1614== Description ==
    1715
    18 [Website](https://vtables.pro/) | [Video Guide](https://www.youtube.com/watch?v=RRMnKfhCut8) | [User Guide](https://vtables.pro/documentation) | [FAQ](https://vtables.pro/faq) | [Developer](https://iamvs.com)
     16vTables turns spreadsheets into clean, responsive tables for your site. Import a file or start from an empty grid, edit it right in the WordPress admin, style it, and place it on any page or post with a shortcode.
    1917
    20 Unlock Google Sheets and WooCommerce integrations with **vTables Pro Add-on** – [Download for FREE](https://vtables.pro)
     18[Website](https://vtables.pro/) | [User Guide](https://vtables.pro/documentation) | [FAQ](https://vtables.pro/faq)
    2119
    22 Discover the ultimate solution for transforming your WordPress site's data presentation with our spreadsheet plugin. Seamlessly convert spreadsheets, Google Sheets, and WooCommerce products into captivating tables that captivate your audience effortlessly.
     20= Features =
    2321
    24 https://www.youtube.com/watch?v=RRMnKfhCut8
     22* **Import** CSV, JSON, XLS and XLSX files (large files are uploaded in chunks and imported in the background).
     23* **Build tables from scratch**: set the number of columns and rows, then fill the cells in.
     24* **Edit in place**: double-click any cell or header to edit it; add, rename and delete rows and columns from the right-click menu.
     25* **Unlimited tables**, and as many tables per page as you like.
     26* **Shortcode embed**: `[vtables id="1"]` shows the table; `[vtables id="1" row="2" column="email"]` prints a single cell value inside your text.
     27* **Smart cells**: image URLs become thumbnails, links become clickable, `<button href="…">Label</button>` becomes a button, and basic HTML (links, images, line breaks) is rendered safely.
     28* **Style**: colors for the title bar, header, body, pagination bar, links and buttons; dense and flat layouts; row/column separators; sticky header; table height and maximum width.
     29* **Pagination** for large tables: rows are loaded page by page from the database, so tables with many thousands of rows stay fast.
     30* **Export** any table as CSV, JSON or XLSX.
     31* Works with the block editor, classic editor and page builders that accept shortcodes (Divi, Elementor, WPBakery, …).
     32* Translated into English, German, Spanish, French, Hindi, Italian, Norwegian, Portuguese, Russian, Swedish, Turkish and Chinese.
    2533
    26 === Data sources ===
     34= vTables Pro =
    2735
    28 1. CSV spreadsheet;
    29 2. JSON data;
    30 2. Google Sheets;
    31 3. WooCommerce products.
     36[vTables Pro](https://vtables.pro) is a separate, paid add-on that runs on top of this plugin and uses the same tables. It adds:
    3237
    33 === Features ===
     38* Data sources: Google Sheets, external CSV/JSON URLs and WooCommerce product tables, with automatic sync.
     39* Visitor tools: search, sorting and row filters.
     40* Layout: column visibility, sticky first/last column, column widths, grouped headers, responsive card view, rows per page, custom CSS.
     41* More cell formats: video, PDF, audio, Google Maps, star rating, progress bar and JSON.
    3442
    35 1. Unlimited Tables:
    36 Create and customize data tables to your heart's content. There are no limits here – whether you're managing a small business or curating content, vTables empowers you to showcase your data in unlimited ways. 🌟
     43This plugin is fully functional on its own and never requires Pro.
    3744
    38 2. Unlimited Tables per Page:
    39 Break free from layout constraints and seamlessly display multiple tables on a single page. With vTables, your users can explore a wealth of information without any hindrance. 🚀
     45= Source code and third-party libraries =
    4046
    41 3. Extract specific values from a sheet
    42 Effortlessly retrieve specific cell values from your spreadsheets using custom attributes within a shortcode. For example, [vtables id="1" row="1" column="email"] lets you grab exactly what you need from your data. 💡
     47The admin screen and the embedded table are a Vue application. Its complete, unminified source ships with the plugin in the `src/` folder, together with the build setup (`package.json`, `quasar.config.js`, `scripts/`). To rebuild: `npm ci && npm run build` (PHP dependencies: `composer install --no-dev`).
    4348
    44 4. Google Sheets Integration
    45 Keep your WordPress data tables in sync with Google Sheets effortlessly. With just a click, your tables reflect any updates made to your spreadsheet. 🎶
    46 *available in **vTables Pro Add-on** – [Download for FREE](https://vtables.pro)*
     49Bundled libraries, all GPL-compatible:
    4750
    48 5. WooCommerce Integration
    49 Enhance your e-commerce strategy by seamlessly managing and displaying product information. Connect your WooCommerce shop with vTables for a cohesive user experience. 🌟
    50 *available in **vTables Pro Add-on** – [Download for FREE](https://vtables.pro)*
     51* Vue, Vue Router, Pinia, Vue I18n (MIT)
     52* Quasar Framework (MIT) and Material Icons (Apache License 2.0)
     53* Axios, Lodash, Papa Parse, VueUse, Vuelidate (MIT)
     54* SimpleXLSX and SimpleXLS by Sergey Shuchkin (MIT)
    5155
    52 6. Styling
    53 Customize your data tables to match your site's design seamlessly. With extended styling options, choose color schemes, and more to create visually stunning tables. 🎨
     56vTables does not track users and makes no requests to external services. The only external links in the admin are to vtables.pro, and they open only when you click them.
    5457
    55 7. Export
    56 Prepare reports, share insights, or collaborate with your team by seamlessly exporting tables in various formats such as CSV and JSON. 🔗
    57 *available in **vTables Pro Add-on** – [Download for FREE](https://vtables.pro)*
     58== Installation ==
    5859
    59 8. Premium Features:
    60 Unlock exclusive premium features like sorting, search, sticky columns, and more to enhance user interaction.
    61 *available for [PREMIUM](https://vtables.pro/pricing/) plans only*
    62 
    63 9. vTables seamlessly integrates with leading site builders such as Divi, Elementor, and WPBakery.
    64 Whether you're utilizing Divi table, Elementor table, or WPBakery table, vTables seamlessly integrates with leading site builders. Incorporate tables, CSV spreadsheets, and Google Sheets effortlessly into your site's design.
    65 
    66 
    67 == Why Choose vTables (PRO): Spreadsheets, Google Sheets, WooCommerce product tables ==
    68 
    69 - Simplifies complex data management tasks.
    70 - Boosts productivity with efficient spreadsheet tools.
    71 - Perfect for store owners looking to streamline their operations.
    72 - Import and export product data using CSV and JSON.
    73 - Seamless integration with Google Sheets.
    74 - Seamless integration with WooCommerce.
    75 - Create and manage WooCommerce product tables effortlessly.
    76 - Supports multiple spreadsheet formats and customization options.
    77 - User-friendly interface with drag-and-drop functionality.
    78 
    79 
    80 == Requirements ==
    81 
    82 – WordPress version 6.4 or higher
    83 – PHP version 8.0 or higher
    84 – Browser compatibility: Edge >= 88, Firefox >= 78, Chrome >= 87, Safari >= 13.1
    85 
    86 
    87 == Notice ==
    88 If you're using a caching plugin, make sure to exclude the vTables plugin from caching. In your cache plugin settings, locate the **"Do Not Cache URIs"** field and add **"vtables"** to the list. After making this change, clear the cache to ensure the new settings take effect.
    89 If you've made a change and don't see updates on the page, try re-saving it to trigger a page-level cache reset, or clear the cache manually through your caching plugin.
    90 
     601. In the WordPress admin go to **Plugins → Add New**, search for **vTables**, and click **Install Now**, then **Activate**. (Or upload the `vtables` folder to `/wp-content/plugins/` and activate it on the Plugins screen.)
     612. Open **vTables** in the admin menu and click **Add table**.
     623. Choose **Create** to start from an empty grid, or **Upload** to import a CSV, JSON, XLS or XLSX file.
     634. Copy the table's shortcode (for example `[vtables id="1"]`) and paste it into any page or post.
    9164
    9265== Frequently Asked Questions ==
    9366
    94 = Can users without a technical background or development experience use the vTables? =
     67= How do I show a table on a page? =
    9568
    96 vTables is specifically designed to be the most beginner-friendly and non-technical WordPress table plugin available. Our documentation and user manuals are meticulously crafted to ensure that even individuals with limited technical proficiency can harness the full potential of our table builder effortlessly.
     69Every table has a shortcode, shown in the table list and on the table's page, like `[vtables id="1"]`. Paste it into a Shortcode block (or directly into the classic editor or your page builder).
    9770
    98 = Who is vTables plugin suitable for? =
     71= How do I print a single value from a table? =
    9972
    100 vTables – is a [WordPress table plugin](https://vtables.pro) that caters to a broad audience, from beginners to experts. The user-friendly interface empowers anyone to comfortably create and customize dynamic tables without the need for coding skills.
     73Add `row` and `column`: `[vtables id="1" row="2" column="email"]` prints the value of the `email` column in the second row. Column names are case-insensitive and spaces become underscores.
    10174
    102 = Is it possible to import tables from other plugins into vTables? =
     75= Can I upload an Excel file? =
    10376
    104 Certainly, you can seamlessly import tables from other plugins into vTables by exporting them in CSV format.
     77Yes. XLSX and XLS files are imported directly; the first row becomes the header.
    10578
    106 = How can vTables handle long tables with a substantial amount of data? =
     79= Can I put images, links and buttons in cells? =
    10780
    108 Managing extensive tables with large datasets is made simple with vTables. You can utilize features such as Pagination to break down tables into segments. Additionally, the Search/Filter option enables quick retrieval of specific information from even the most colossal table datasets.
     81Yes. Paste an image URL (ending in .jpg, .png, .gif, .webp, .svg, …) to show a thumbnail — clicking it opens the full image — or any http(s) link to show a clickable link. For a button, use `<button href="https://example.com">Buy now</button>`.
    10982
    110 = Can I import tables from Google Sheet or Google Drive? =
     83= I updated from vTables 0.2.x. What changed? =
    11184
    112 Yes, vTables allows you to import tables from Google Sheet / Google Drive with the added functionality provided by the **vTables Pro Add-on** – [Download for FREE](https://vtables.pro).
     85Your tables, their data and their shortcodes keep working, and the update runs automatically in the background:
    11386
    114 = How to upload an EXCEL file? =
     87* Table rows are moved into a dedicated database table, a few tables at a time, so large tables load page by page.
     88* Some settings that were free in 0.2.x — search, sorting, sticky columns, custom CSS and rows per page — are now part of vTables Pro. Your saved values are kept untouched; they apply again if you install vTables Pro. Until then the table shows with the free options, and a one-time notice lists how many tables are affected.
     89* Columns you had hidden stay hidden.
    11590
    116 To upload an EXCEL file, save it as a CSV. To do this, open your spreadsheet in EXCEL, click "File" -> "Save As," and select the "CSV" format. After that, you will be able to upload and convert this file into a table.
     91= Does vTables work with caching plugins? =
    11792
    118 = Can I upload images to table cells? =
     93Yes. The embedded table is served from its own URL (`/vtables-embed/{id}/`) that tells caching and optimisation plugins not to cache or rewrite it. If a page still shows old content after an update, clear your page cache once.
    11994
    120 You can simply paste a URL (it should contain an image extension) to an image  into the desired cell, and the plugin will do the rest.
    121 You can also turn on the "Image Popup" option in the settings to show an image in a popup when clicked.
     95= What is the relationship with vTables Pro? =
    12296
    123 Currently available with **vTables Pro Add-on** – [Download for FREE](https://vtables.pro).
     97vTables Pro is an optional paid add-on. It needs this plugin to be active and works on the same tables, so you can install it at any time without re-creating anything — and if you remove it later, your file-based tables keep working here.
    12498
    125 = Can I add buttons to table cells? =
     99= Is my data deleted when I delete the plugin? =
    126100
    127 Yes, you can simply paste a link into the desired cell, and the plugin will do the rest. You can also convert your links into buttons. To do this, simply switch the toggle "Links as" to the "Button" option. [available with PREMIUM plans](https://vtables.pro/pricing)
    128 
    129 = Can I connect Google Sheets to the plugin? =
    130 
    131 You can easily connect Google Sheets with your tables.
    132 
    133 Available with **vTables Pro Add-on** – [Download for FREE](https://vtables.pro).
    134 
    135 = Can I import a spreadsheet from Google Sheets? =
    136 
    137 Yes, you can easily import Google Sheets into the plugin.
    138 
    139 Available with **vTables Pro Add-on** – [Download for FREE](https://vtables.pro).
    140 
    141 = Can I create a WooCommerce product table? =
    142 
    143 Sure, you can create a product table in just a few clicks. On the "Tables" page, click the "Add Table" button, select "Woo Products," name your table, select the desired category/categories with products, and click "Import."
    144 
    145 Available with **vTables Pro Add-on** – [Download for FREE](https://vtables.pro).
    146 
    147 = Can I create a WooCommerce product table with external products? =
    148 
    149 Sure, you can create a product table with external products in just a few clicks. On the "Tables" page, click the "Add Table" button, select "Woo Products," name your table, select the desired category/categories with products, and click "Import." Alternatively, if you haven't set up WooCommerce, you can create a spreadsheet or Google Sheet with external links and import that sheet.
    150 
    151 Available with **vTables Pro Add-on** – [Download for FREE](https://vtables.pro).
     101No. Deleting the plugin removes its settings, but keeps your tables, so reinstalling brings them back. To delete the tables as well, add `define( 'VTABLES_REMOVE_ALL_DATA', true );` to wp-config.php before deleting the plugin (this is ignored while vTables Pro is installed, since it uses the same tables).
    152102
    153103= Can I suggest a feature? =
    154104
    155 Sure, please use the contact form on the official website.
     105Sure — please use the contact form on [vtables.pro](https://vtables.pro).
    156106
    157107== Screenshots ==
    158108
    159 1. "Dashboard"
    160 2. "CSV spreadsheet"
    161 3. "Google Sheets"
    162 4. "WooCommerce product table"
    163 5. "Table settings"
     1091. Tables list
     1102. A table imported from a CSV file
     1113. Google Sheets table (vTables Pro)
     1124. WooCommerce product table (vTables Pro)
     1135. Table settings
     114
     115== Upgrade Notice ==
     116
     117= 3.0.0 =
     118Rebuilt admin and table viewer, faster pagination, XLS/XLSX import and security hardening. Tables and shortcodes keep working; search, sorting, sticky columns, custom CSS and rows per page move to vTables Pro, and their saved values are kept.
    164119
    165120== Changelog ==
     121
     122= 3.0.0 =
     123* New admin and table viewer, with in-place editing of cells, headers, rows and columns.
     124* New: XLS and XLSX import; chunked background import for large files.
     125* New: tables are stored row by row and paged from the database; existing tables are migrated in the background.
     126* New: export as XLSX.
     127* New: the embedded table is served by WordPress (`/vtables-embed/{id}/`) and opts out of page caching and script optimisation.
     128* Security: all admin actions require a nonce and the `manage_options` capability; the public endpoints only read single tables; SQL ordering is allowlisted; database errors are no longer shown to visitors; uploads in progress are stored in a protected folder.
     129* Changed: search, sorting, sticky columns, custom CSS and rows per page are now vTables Pro features; saved values are kept.
     130* Removed: the `vtables/v1` REST routes (replaced by admin-ajax actions).
    166131
    167132= 0.2.1 =
  • vtables/trunk/src/boot/i18n.ts

    r3576926 r3754379  
    33
    44import messages from 'src/i18n';
     5import { getSysInfo } from 'boot/wpapi';
    56
    67export type MessageLanguages = keyof typeof messages;
    78// Type-define 'en-US' as the master schema for the resource
    8 export type MessageSchema = typeof messages['en-US'];
     9export type MessageSchema = (typeof messages)['en-US'];
    910
    1011// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
    … …  
    2223/* eslint-enable @typescript-eslint/no-empty-interface */
    2324
     25function resolveLocale(): MessageLanguages {
     26  const wpLocale: string = getSysInfo().locale ?? '';
     27
     28  if (wpLocale.startsWith('es')) return 'es';
     29  if (wpLocale.startsWith('de')) return 'de';
     30  if (wpLocale.startsWith('fr')) return 'fr';
     31  if (wpLocale.startsWith('it')) return 'it';
     32  if (wpLocale.startsWith('ru')) return 'ru';
     33  if (wpLocale.startsWith('pt')) return 'pt';
     34  if (wpLocale.startsWith('hi')) return 'hi';
     35  if (wpLocale.startsWith('nb')) return 'nb';
     36  if (wpLocale.startsWith('sv')) return 'sv';
     37  if (wpLocale.startsWith('tr')) return 'tr';
     38  if (wpLocale === 'zh_CN') return 'zh-CN';
     39  if (wpLocale.startsWith('zh')) return 'zh-HK';
     40  return 'en-US';
     41}
     42
    2443export default boot(({ app }) => {
    2544  const i18n = createI18n({
    26     locale: 'en-US',
     45    locale: resolveLocale(),
     46    fallbackLocale: 'en-US',
    2747    legacy: false,
    2848    messages,
  • vtables/trunk/src/boot/services.ts

    r3576926 r3754379  
    1 import { api } from 'src/domains/general/services';
     1import { api } from 'src/services';
    22
    33export { api };
  • vtables/trunk/src/boot/wpapi.ts

    r3576926 r3754379  
    1 import WP from 'wpapi';
     1/**
     2 * WordPress-provided runtime values, localized as `vtables_sys_info`:
     3 * - admin screen: wp_localize_script in LoadAssets::vtables_enqueue_assets_admin
     4 *   (ajaxurl, nonce, upload limit, locale);
     5 * - public embed document: printed inline by Embed::render_document
     6 *   (ajaxurl, locale — never a nonce).
     7 * The SPA reads its own window first; the parent window is a fallback for
     8 * embeds cached before the values were printed into the document.
     9 */
     10export type SysInfo = {
     11  ajaxurl?: string;
     12  nonce?: string;
     13  max_upload_size?: number | string;
     14  locale?: string;
     15  plain_links?: boolean | string;
     16  pro_url?: string;
     17};
    218
    3 const windowObj: any = window;
     19export function getSysInfo(): SysInfo {
     20  type WithSysInfo = { vtables_sys_info?: SysInfo };
     21  const own = (window as unknown as WithSysInfo).vtables_sys_info;
     22  if (own) return own;
    423
    5 const baseUrl = windowObj.WP_API_Settings?.base_url || '/wp-json/';
     24  try {
     25    return (window.parent as unknown as WithSysInfo)?.vtables_sys_info ?? {};
     26  } catch {
     27    // Cross-origin parent: nothing to read.
     28    return {};
     29  }
     30}
    631
    7 const wpapi = new WP({
    8   endpoint: baseUrl,
    9   nonce: windowObj.WP_API_Settings.nonce,
    10 });
     32function getAjaxUrl(): string {
     33  return getSysInfo().ajaxurl || '/wp-admin/admin-ajax.php';
     34}
    1135
    12 wpapi.vtables = wpapi.registerRoute('vtables/v1', '/tables/(?P<id>\\d+)');
     36function getSecurity(): string {
     37  return getSysInfo().nonce || '';
     38}
    1339
    14 wpapi.vtables_source = wpapi.registerRoute(
    15   'vtables/v1',
    16   '/source/(?P<id>\\d+)'
    17 );
     40function getUploadMaxFilesize(): number {
     41  const size = Number(getSysInfo().max_upload_size);
     42  // Conservative fallback: 2 MB
     43  return size > 0 ? size : 2 * 1024 * 1024;
     44}
    1845
    19 wpapi.vtables_settings = wpapi.registerRoute(
    20   'vtables/v1',
    21   '/settings/(?P<id>\\d+)'
    22 );
     46/** Where the "Pro" teasers link to. */
     47function getProUrl(): string {
     48  return getSysInfo().pro_url || 'https://vtables.pro';
     49}
    2350
    24 wpapi.license = wpapi.registerRoute('vtables/v1', '/license');
     51const ajaxUrl = getAjaxUrl();
    2552
    26 export { wpapi, baseUrl };
     53export { ajaxUrl, getSecurity, getUploadMaxFilesize, getProUrl };
  • vtables/trunk/src/css/app.scss

    r3576926 r3754379  
    1 @import url('/public/css/vtables.css');
     1.material-icons {
     2  font-family: Material Icons;
     3  font-weight: 400;
     4  font-style: normal;
     5  display: inline-block;
     6  line-height: 1;
     7  text-transform: none;
     8  letter-spacing: normal;
     9  word-wrap: normal;
     10  white-space: nowrap;
     11  direction: ltr;
     12  -webkit-font-smoothing: antialiased;
     13  text-rendering: optimizeLegibility;
     14  -moz-osx-font-smoothing: grayscale;
     15  font-feature-settings: 'liga';
     16}
  • vtables/trunk/src/i18n/en-US/index.ts

    r3576926 r3754379  
    55  abort_upload: 'Abort Upload',
    66  actions: 'Actions',
     7  activate: 'Activate',
    78  active: 'Active',
     9  inactive: 'Inactive',
     10  and: 'and',
     11  add_column: 'Add column',
    812  add_column_after: 'Add column after',
    913  add_column_before: 'Add column before',
     14  add_condition: 'Add condition',
     15  add_group: 'Add group',
     16  add_row: 'Add row',
    1017  add_row_after: 'Add row after',
    1118  add_row_before: 'Add row before',
     19  add_subgroup: 'Add subgroup',
    1220  add_table: 'Add table',
     21  add_to_cart: 'Add to cart',
     22  add_to_cart_button_label: 'Add to cart button label',
    1323  add_top_row: 'Add top row',
     24  appearence: 'Appearence',
     25  applied: 'Applied',
     26  apply: 'Apply',
     27  apply_changes: 'Apply changes',
     28  ascending: 'Ascending',
     29  auto_sync: 'Auto Sync',
     30  auto_sync_interval: 'Auto-sync interval',
     31  manual_sync: 'Manual sync',
    1432  back_to_dashboard: 'Back to dashboard',
    1533  background_color: 'Background color',
    1634  baseline: 'Baseline',
    1735  binary_sorting: 'Binary sorting',
     36  body: 'Body',
    1837  bordered: 'Bordered',
    1938  borderless: 'Borderless',
    … …  
    2645  button_text: 'Button text',
    2746  button_text_color: 'Button text color',
     47  buy_now: 'Buy now',
     48  buy_now_mode: 'Buy now mode',
    2849  cancel: 'Cancel',
    2950  card_css_class: 'Card CSS class',
    … …  
    3253  clear: 'Clear',
    3354  clear_all: 'Clear All',
     55  clear_group_tree: 'Clear tree',
     56  close: 'Close',
    3457  color: 'Color',
    3558  column: 'Column',
     59  column_groups: 'Col. groups',
     60  column_name: 'Column Name',
    3661  columns: 'Columns',
    3762  columns_max_width: 'Columns max width',
     63  columns_min_width: 'Columns min width',
     64  columns_desktop: 'Columns (desktop)',
     65  columns_mobile: 'Columns (mobile)',
     66  columns_tablet: 'Columns (tablet)',
    3867  confirm: 'Confirm',
     68  connected: 'Connected',
    3969  content: 'Content',
    4070  copied: 'Copied',
    4171  copy: 'Copy',
     72  create: 'Create',
     73  create_table: 'Create table',
    4274  custom_css: 'Custom CSS',
     75  daily: 'Daily',
    4376  dark: 'Dark',
    4477  date: 'Date',
     78  deactivate: 'Deactivate',
     79  descending: 'Descending',
     80  desktop: 'Desktop',
    4581  delete: 'Delete',
    4682  delete_column: 'Delete column',
     83  delete_group: 'Delete group',
    4784  delete_row: 'Delete row',
    4885  dense: 'Dense',
    4986  dense_mode: 'Dense mode',
     87  dimensions: 'Dimensions',
     88  dismiss: 'Dismiss',
    5089  done: 'Done',
     90  configure_indexes: 'Configure indexed columns',
     91  configure_columns: 'Configure columns',
     92  create_without_indexes: 'Create without indexes',
     93  index_column: 'Index',
     94  data_type: 'Data type',
     95  type_text: 'Text',
     96  type_number: 'Number',
     97  type_date: 'Date',
     98  type_boolean: 'Boolean',
     99  start_import: 'Start import',
     100  skip: 'Skip',
    51101  download: 'Download',
    52   dimensions: 'Dimensions',
    53102  edit: 'Edit',
    54103  edit_table: 'Edit table',
     104  enable_column_groups: 'Enable column groups',
     105  every_1_minute: 'Every 1 minute',
     106  every_5_minutes: 'Every 5 minutes',
     107  every_15_minutes: 'Every 15 minutes',
     108  every_30_minutes: 'Every 30 minutes',
     109  every_30_seconds: 'Every 30 seconds',
     110  every_hour: 'Every hour',
     111  exp_date: 'Exp. date',
     112  expire_soon: 'Expire soon',
     113  expired: 'Expired',
    55114  export: 'Export',
     115  external: 'External',
    56116  failed: 'Action failed',
    57117  fetch: 'Fetch',
    … …  
    59119  filesize: 'File Size',
    60120  filled: 'Filled',
     121  filter: 'Filter',
     122  filter_column: 'Column',
     123  filter_logic: 'Filter logic',
     124  filter_operator: 'Operator',
     125  filter_value: 'Value',
    61126  flat: 'Flat',
    62127  format: 'Format',
    … …  
    64129  fullscreen_mode: 'Fullscreen mode (applies for Table view only)',
    65130  general: 'General',
     131  get_license: 'Get License',
     132  get_new_license: 'Get new license',
     133  get_plan: 'Get Plan',
     134  go_to_cart: 'Go to cart',
     135  google_sheets_api_key: 'Google Sheets API Key',
    66136  grid: 'Grid',
    67137  grid_header: 'Grid header',
     138  group_name: 'Group name',
     139  group_tree: 'Group tree',
    68140  header: 'Header',
    69141  header_background_color: 'Header background',
    70142  header_css_class: 'Header CSS class',
     143  header_groups: 'Header Groups',
    71144  header_text_color: 'Header text color',
     145  hidden: 'Hidden',
    72146  hide: 'Hide',
    73147  hide_bottom: 'Hide bottom',
    74148  hide_header: 'Hide header',
    75149  hide_pagination: 'Hide pagination',
     150  hide_settings: 'Hide settings',
    76151  hide_title: 'Hide title',
    77152  horizontal: 'Horizontal',
    78   hyperlink_color: 'Hyperlink color',
    79   hyperlink_text: 'Hyperlink text',
    80   hyperlinks_type: 'Hyperlinks type',
     153  i_want_to: 'I want to..',
    81154  image: 'Image',
     155  image_height: 'Image height',
     156  image_popup: 'Image popup',
     157  image_width: 'Image width',
    82158  import: 'Import',
    83159  import_all_products: 'Import all products',
    84   i_want_to: 'I want to',
     160  import_products_from_categories: 'Import products from categories',
     161  in_stock_label: 'In stock label',
     162  initialize_from_columns: 'Initialize from columns',
     163  json_preview_lines: 'JSON preview lines',
     164  license: 'License',
     165  lifetime: 'Lifetime',
    85166  link: 'Link',
     167  logs: 'Logs',
     168  migration_status: 'Migration status',
     169  migration_completed: 'Completed',
     170  migration_in_progress: 'In progress',
     171  link_color: 'Link color',
     172  link_text: 'Link text',
     173  link_to_cart: 'Link to cart',
     174  links_as: 'Links as',
    86175  local: 'Local',
    87176  local_csv_file: 'local CSV file',
    88177  merged_selected_cells: 'Merge selected cells',
     178  learn_more: 'Learn more',
     179  pro: 'Pro',
     180  pro_teaser: {
     181    external: 'Import tables from any CSV or JSON URL and keep them in sync automatically.',
     182    google_sheets: 'Connect a Google Sheet and keep the table in sync automatically.',
     183    woocommerce: 'Build product tables from your WooCommerce catalog, with add to cart.',
     184    columns: 'Show or hide columns, pin the first or last column, and set column widths.',
     185    column_groups: 'Group columns under shared multi-level headers.',
     186    css: 'Add your own CSS and classes to style the table.',
     187    filter: 'Filter rows by conditions, for visitors and in the admin.',
     188    pagination: 'Choose how many rows each page shows.',
     189    view: 'Show the table as responsive cards on desktop, tablet and mobile.',
     190    rows: 'Image size, lightbox preview and vertical alignment for cells, plus video, PDF, audio, map, rating, progress and JSON cells.',
     191    search: 'Add a search box so visitors can find rows.',
     192    sorting: 'Let visitors sort the table by any column.',
     193  },
    89194  messages: {
    90     available_in_pro_version: 'Available in PRO version',
    91     available_in_pro_version_under_advanced_subscription_plan:
    92       'Available in PRO version (Advanced subscription)',
    93     available_in_pro_version_under_lite_subscription_plan:
    94       'Available in PRO version (Lite subscription)',
     195    available_in_pro: 'Available in vTables Pro.',
     196    table_requires_pro: 'This table uses a vTables Pro data source or row filter. Its data is kept unchanged; it can be opened and shown with vTables Pro.',
     197    add_filter_conditions_to_table: 'Filter table rows using custom conditions',
    95198    add_search_field_to_table: 'Add search field to table',
    96199    add_your_first_table: 'Add your first table',
     200    adds_link_to_cart_in_the_notifications:
     201      'Adds link to cart in the notifications',
    97202    all_selected_tables: 'all selected tables',
    98203    allows_showing_images_in_table_if_it_has_columns_with_images_paths:
    … …  
    100205    allows_users_open_fullsize_image_in_popup:
    101206      'Allows user to open a full-size image in a popup',
     207    and_set_it_in_the: 'and set it in the',
    102208    applies_a_default_border: 'Applies a default border to the component',
    103209    applies_dense_mode: 'Applies a dense mode (applies for Table view only)',
    … …  
    105211    are_you_sure_you_want_to_reset_your_current_settings:
    106212      'Are you sure you want to reset current settings?',
     213    available_for_external_google_sheets_woocommerce:
     214      'Sync is available for WooCommerce, Google Sheets, and external URL tables only',
     215    available_for_table_view_only: 'Available for Table view only',
     216    available_for_woocommerce_tables_only:
     217      'Available for WooCommerce tables only',
     218    available_in: 'Available in',
     219    available_with: 'Available with',
     220    cant_extract_sheet_id: "Can't extract sheet ID",
     221    categories_not_found: 'Categories not found',
     222    category_doesnt_exists_or_empty: "Category doesn't exist or empty",
     223    changes_have_been_applied: 'Changes have been applied',
    107224    changes_have_been_saved: 'Changes have been saved',
     225    column_groups_summary:
     226      '{groups} groups · {depth} levels deep · {assigned} of {total} columns assigned',
    108227    column_mismatch: 'Column mismatch.',
     228    confirm_clear_group_tree:
     229      'This will permanently remove the entire group hierarchy. This cannot be undone.',
     230    confirm_remove_license:
     231      'This will remove the license key from this site. You can add it again later.',
    109232    couldnt_parse_file_content: "Couldn't parse file content",
     233    custom_css_scoped_to_this_table:
     234      "CSS is automatically scoped to this table. Write plain selectors, e.g. .q-table th {'{'} background: #eee; {'}'}",
    110235    css_classes_to_apply_to_card:
    111236      'CSS classes to apply to the card (when in grid mode)',
    … …  
    115240      "CSS classes to apply to native HTML table element's wrapper (DIV)",
    116241    css_classes_to_apply_to_title: 'CSS classes to apply to the title',
     242    current_data_will_be_overwritten:
     243      'Current data will be overwritten by data from external source (Google Sheets)',
    117244    data_has_been_synced: 'The data has been synced',
    118245    display_data_as_table_or_grid: 'Display data as a table or grid',
    119     drag_and_drop_csv_file_to_upload: 'Drag and Drop a CSV file(s) to upload',
     246    dont_have_a_key_yet_get_one_from_the:
     247      "Don't have a key yet? Get one from the",
     248    drag_and_drop_csv_file_to_upload:
     249      'Drag and Drop a CSV, JSON or Excel file(s) to upload',
     250    drag_to_reorder: 'Drag to reorder',
     251    drop_column_here: 'Drop a column here',
     252    external_source_column_name_too_long: 'This source has a column name longer than {max} characters — it does not look like a data file.',
     253    external_source_is_not_tabular: 'This URL returns a web page, not a data file. Link directly to a CSV or JSON file.',
     254    external_source_too_many_columns: 'This source has {count} columns (limit {max}) — it does not look like a data file.',
     255    file_too_large: 'File is too large',
     256    file_size_limit_is_50mb: 'Maximum file size is 50 MB',
     257    file_size_limit_is_10mb: 'Maximum file size is 10 MB',
     258    table_source_unrenderable: 'This table has {count} columns and cannot be displayed. It was most likely imported from a web page instead of a data file — delete it and re-import from a direct CSV or JSON link.',
     259    uploading_large_file: 'Uploading large file…',
     260    choose_up_to_5_columns_to_index:
     261      'Choose up to 5 columns to index for faster sorting, filtering and search. Pick the data type for each so comparisons behave correctly.',
     262    enable_column_groups_hint:
     263      'Group columns under shared header labels with arbitrary nesting depth.',
     264    enable_virtual_scroll:
     265      'Enable virtual scrolling to efficiently render large datasets. A fixed table height must be set in the Dimensions tab.',
     266    external_source_not_found: 'External source not found',
    120267    field_is_required: 'Field is required',
     268    unable_to_configure_indexes:
     269      'The table was created, but the indexed columns could not be configured.',
     270    must_be_whole_number_between:
     271      'Must be a whole number between {min} and {max}',
    121272    file_not_found_or_not_accessible: 'File not found or not accessible',
     273    fetched_rows_so_far: 'Fetched {n} rows so far…',
    122274    for_first_sticky_column: 'for first sticky column',
    123275    for_last_sticky_column: 'for last sticky column',
    124276    for_sticky_columns_make_sure_you_set_table_width:
    125       'For sticky columns make sure you set table width on "Dimensions" tab.',
     277      'For sticky columns make sure you set table width on "Dimensions" tab if your table does not have a horizontal scrollbar.',
    126278    for_sticky_header_make_sure_you_set_table_height:
    127       'For sticky header make sure you set table height on "Dimensions" tab.',
     279      'For sticky header make sure you set table height on "Dimensions" tab if your table does not have a vertical scrollbar.',
    128280    fullscreen_mode_available_only_for_table_view:
    129281      'Fullscreen mode available only for Table view',
     282    google_api_key_has_been_removed: 'Google Sheets API key has been removed',
     283    google_api_key_has_been_saved: 'Google Sheets API key has been saved',
     284    google_api_key_is_invalid:
     285      'This Google Sheets API key could not be validated',
     286    google_api_key_problem_banner:
     287      'There is a problem with your Google Sheets API key. Tables connected to Google Sheets may stop syncing.',
     288    google_sheets_api_key_hint:
     289      'When set, Google Sheets imports, resyncs, and the scheduled auto-sync use the official Google Sheets API instead of the public CSV endpoint.',
     290    google_sheets_api_key_invalid_notice:
     291      'Your Google Sheets API key is invalid or restricted, so data is fetched through a public endpoint instead — this is not reliable and may return incomplete data.',
     292    google_sheets_no_api_key_notice:
     293      'Importing and syncing Google Sheets without an API key is not reliable — data is fetched through a public endpoint and may be incomplete or fail to load.',
    130294    how_many_columns_from_left_will_be_sticky:
    131295      'How many columns from left will be sticky',
     296    migration_log_hint:
     297      'Events recorded while tables created by older plugin versions are moved to the new row storage in the background.',
     298    no_migration_events_yet: 'No migration events yet.',
    132299    how_many_rows_from_top_will_be_sticky:
    133300      'How many rows from the top will be sticky',
     301    license_has_been_activated: 'License has been activated',
     302    license_has_been_deactivated: 'License has been deactivated',
     303    license_has_been_removed: 'License has been removed',
     304    layout_on_desktop_screens: 'Layout on desktop screens (≥ 1440px)',
     305    layout_on_mobile_screens: 'Layout on mobile screens (< 600px)',
     306    layout_on_tablet_screens: 'Layout on tablet screens (600px – 1439px)',
    134307    make_first_column_sticky: 'Make first column "sticky"',
    135308    make_header_sticky: 'Make header "sticky"',
    136309    make_last_column_sticky: 'Make last column "sticky"',
     310    message_has_been_sent_thank_you: 'Message has been sent! Thank you!',
    137311    minimum_number_of_rows_to_render:
    138312      'Minimum number of rows to render in the virtual list',
     313    multiple_upload_available_with_premium_plans:
     314      'Multiple upload available with Premium plans',
    139315    only_letters_numbers_dashes_and_underscores_allowed:
    140316      'Only letters, numbers, dashes and underscores allowed',
    141317    paste_a_google_sheet_url: 'Paste a google sheet URL',
     318    please_check_the_category: 'Please check the category',
     319    please_deactivate_license_before_using_it_on_different_site:
     320      'Please deactivate license before using it on different site',
    142321    please_ensure_you_only_use_alphanumeric_characters_dashes_underscores_spaces_and_parantheses_for_filenames:
    143322      'Please ensure you only use alphanumeric characters, dashes, underscores, spaces, and parentheses for filenames.',
     323    please_get_and_activate_new_license:
     324      'Please get and activate a new license',
     325    please_get_license: 'Please get a license',
     326    please_make_sure_cart_link_set_in_woocommerce_settings:
     327      'Please make sure cart link set in WooCommerce settings',
    144328    please_make_sure_the_columns_align_:
    145329      'Please make sure the columns in new spreadsheet align with the table columns.',
    146330    please_make_sure_the_file_is_valid: 'Please make sure the file is valid',
     331    please_make_sure_woocommerce_categories_exist:
     332      'Please make sure WooCommerce categories exist',
     333    please_make_sure_woocommerce_is_intalled_and_activated:
     334      'Please make sure woocommerce is installed and activated',
    147335    please_set_table_height: 'Please set table height',
     336    please_try_again_later: 'Please try again later',
     337    please_turn_on_enable_ajax_option_in_the_woo_settings:
     338      'Please select "Enable AJAX add to cart buttons on archives" option in the WooCommerce settings for a better user experience.',
    148339    please_use_unique_value: 'Please use unique value',
    149340    ratio_of_number_of_rows_after:
    … …  
    151342    ratio_of_number_of_rows_before:
    152343      'Ratio of number of rows in visible zone to render before it',
     344    redirect_to_the_cart_page_after_successful_addition:
     345      'Redirect to the cart page after successful addition',
     346    review_your_key_in_the: 'Review your key in the',
    153347    select_separators: 'Select separators',
     348    set_add_to_cart_button_label: 'Set add-to-cart button label',
     349    set_auto_sync_interval:
     350      'How often the table data is automatically refreshed from the external source',
     351    manual_sync_tooltip:
     352      'Immediately update the table with fresh data from its source. Available for WooCommerce, Google Sheets, and external URL tables.',
     353    use_google_sheets_tab_for_this_url: 'This is a Google Sheets link — import it from the “Google Sheets” tab.',
     354    woocommerce_auto_sync_is_realtime:
     355      'WooCommerce tables update in real time as products change; the daily task runs a full index reconciliation',
    154356    set_background_color: 'Set background color',
    155357    set_background_color_for_bottom_section:
    … …  
    164366    set_button_text: 'Set button text',
    165367    set_button_text_color: 'Set button text color',
     368    set_cart_link_text: 'Set link text',
    166369    set_cell_vertical_alignment:
    167370      'Set cell vertical alignment (applies for Table view only)',
    168371    set_column_sort_order: 'Set column sort order',
    169372    set_columns_max_width: 'Set columns max width',
    170     set_hyperlink_color: 'Set text color',
    171     set_hyperlink_text: 'Set hyperlink text',
     373    set_columns_min_width: 'Set columns min width',
     374    set_in_stock_label: 'Set in_stock label',
     375    set_json_preview_lines:
     376      'Set the number of lines shown in JSON cell preview before truncating',
     377    set_link_color: 'Set text color',
     378    set_link_text: 'Set link text',
     379    set_notification_background_color: 'Set notification background color',
     380    set_notification_heading: 'Set notification heading',
     381    set_notification_message: 'Set notification message',
     382    set_notification_text_color: 'Set notification text color',
     383    set_notification_timer:
     384      'Set notification timer in milliseconds, set -1 to make a notification permanent',
     385    set_out_of_stock_label: 'Set out_of_stock label',
    172386    set_pagination_label: 'Set rows per page label',
    173387    set_pagination_options: 'Set pagination options',
     388    set_path_to_open_cart: 'Set path to cart in notifications',
    174389    set_records_per_page: 'Set records per page',
     390    set_background_color_for_search_field:
     391      'Set background color for search field',
    175392    set_search_field_color: 'Set search field color',
    176393    set_search_input_style: 'Set search input style',
     394    set_style_of_stock_badge: 'Set style of stock badge',
    177395    set_table_height: 'Set table height (applies for Table view only)',
    178396    set_table_max_width: 'Set table max width (applies for Table view only)',
    … …  
    187405    settings_have_been_reset: 'Settings have been reset',
    188406    settings_have_been_saved: 'Settings have been saved',
    189     show_hyperlinks_in_table_if_it_has_columns_with_hyperlinks:
    190       'Show hyperlinks as links/buttons in table if it has column/s with hyperlinks',
     407    show_links_in_table_if_it_has_columns_with_links:
     408      'Show links as links/buttons in table if it has column/s with links',
     409    shows_notification_when_item_added_to_cart:
     410      'Shows notification when item added to cart',
    191411    size_in_px_of_sticky_header:
    192412      'Size in pixels of the sticky header (if using one); A correct value will improve scroll precision.',
    … …  
    194414      'Skip the third state (unsorted) when user toggles column sort direction',
    195415    something_went_wrong: 'Something went wrong..',
    196     please_try_again_later: 'Please try again later',
     416    start_by_initializing_tree:
     417      'Start by initializing the tree from your existing columns, or add a group manually.',
     418    table_type_not_available_under_your_plan:
     419      "{tableType} tables aren't available under your {plan} plan.",
    197420    table_with_name_already_exists: 'Table with name already exists',
    198421    this_is_supported_only_on_modern_browsers:
    199422      'This is supported only on modern browsers',
     423    this_setting_applies_to_table_view_only:
     424      'This setting applies to Table view only and has no effect when the table is displayed in Grid mode.',
     425    to_add_product_to_cart_please_use_shortcode_on_frontpage:
     426      'To add product to cart please use shortcode on front page',
     427    trial_ended_sync_disabled:
     428      'Your trial has ended. Upgrade to continue syncing.',
     429    trial_table_limit_reached:
     430      'Trial allows only 1 table. Delete your existing table to create a different one, or upgrade.',
    200431    turn_on_off_sorting: 'Turn ON/OFF sorting for columns',
    201432    unable_to_cancel_changes: 'Unable to cancel changes.',
    … …  
    203434    unable_to_reset_settings: 'Unable to reset settings',
    204435    unable_to_save_changes: 'Unable to save changes.',
     436    unsaved_changes: 'Unsaved changes',
     437    unsaved_changes_will_be_lost:
     438      'You have unsaved table changes. If you leave this page, they will be lost.',
    205439    use_separator_between_rows_cols_cells:
    206440      'Use a separator between rows, columns or all cells',
    207441    use_to_display_large_number_of_rows:
    208442      'Use to display large number of rows (for non-grid mode only)',
    209     message_has_been_sent_thank_you: 'Message has been sent! Thank you!',
    210     file_has_been_fetched: 'File has been fetched',
    211     external_source_not_found: 'External source not found',
    212     cant_extract_sheet_id: "Can't extract sheet ID",
    213     license_has_been_activated: 'License has been activated',
    214     license_has_been_deactivated: 'License has been deactivated',
    215     please_deactivate_license_before_using_it_on_different_site:
    216       'Please deactivate license before using it on different site',
    217     available_for_table_view_only: 'Available for Table view only',
     443    virtual_scroll_slice_ratio_after:
     444      'Ratio of extra rows pre-rendered below the visible area. Higher values reduce blank rows when scrolling down.',
     445    virtual_scroll_slice_ratio_before:
     446      'Ratio of extra rows pre-rendered above the visible area. Higher values reduce blank rows when scrolling up.',
     447    virtual_scroll_slice_size:
     448      'Number of rows rendered at a time. Increase for smoother scrolling, decrease to improve performance on large datasets.',
     449    virtual_scroll_sticky_size_start:
     450      'Height in pixels reserved for sticky rows pinned to the top of the virtual list.',
     451    woocommerce_cart_link_not_found: 'WooCommerce cart link not found',
     452    woocommerce_not_found: 'WooCommerce not found',
     453    wraps_product_name_into_a_link: 'Wraps product name into a link',
    218454    your_license_expire_soon: 'Your license expire soon',
    219     your_license_is_expired: 'Your license is expired',
    220     please_get_and_activate_new_license:
    221       'Please get and activate a new license',
     455    your_license_is_expired: 'Your license has expired',
     456    your_plan_is_expired: 'Your {plan} plan is expired.',
    222457  },
    223   valid: 'Valid',
    224   expired: 'Expired',
    225   expire_soon: 'Expire soon',
    226   license: 'License',
    227   exp_date: 'Exp. date',
    228458  middle: 'Middle',
     459  mobile: 'Mobile',
    229460  modified: 'Modified',
     461  more: 'More',
     462  new_group: 'New group',
    230463  none: 'None',
     464  not_connected: 'Not connected',
    231465  nothing_found: 'Nothing found',
     466  notification_background_color: 'Notification bg color',
     467  notification_heading: 'Notification heading',
     468  notification_message: 'Notification message',
     469  notification_text_color: 'Notification text color',
     470  notification_timer: 'Notification timer',
     471  notifications: 'Notifications',
     472  number_of_columns: 'Columns',
     473  number_of_rows: 'Rows',
    232474  off: 'Off',
    233475  on: 'On',
    234476  open: 'Open',
    235   or: 'Or',
     477  open_in_google_sheets: 'Open in Google Sheets',
     478  operator_contains: 'Contains',
     479  operator_ends_with: 'Ends with',
     480  operator_equals: 'Equals',
     481  operator_greater_than: 'Greater than',
     482  operator_is_empty: 'Is empty',
     483  operator_is_not_empty: 'Is not empty',
     484  operator_less_than: 'Less than',
     485  operator_not_contains: 'Does not contain',
     486  operator_not_equals: 'Does not equal',
     487  operator_starts_with: 'Starts with',
     488  or: 'or',
     489  out_of_stock_label: 'Out of stock label',
    236490  outlined: 'Outlined',
     491  page: 'Page',
    237492  pagination: 'Pagination',
    238493  pagination_label: 'Pagination label',
    239494  pagination_options: 'Pagination options',
     495  path_to_cart: 'Path to cart',
    240496  pick_file: 'Pick File',
     497  plan: 'Plan',
     498  plans: 'plans',
    241499  preview_mode: 'Preview mode',
     500  product_link: 'Product link',
     501  products: 'Products',
    242502  records_per_age: 'Records per page',
    243503  remote: 'Remote',
     504  refresh: 'Refresh',
    244505  remove: 'Remove',
     506  remove_license: 'Remove license',
    245507  remove_selected: 'Remove selected',
    246508  remove_top_row: 'Remove top row',
    247509  remove_uploaded_files: 'Remove Uploaded Files',
     510  rename_column: 'Rename column',
     511  rename_group: 'Rename group',
     512  renew: 'Renew',
    248513  reset: 'Reset',
     514  reset_done: 'Reset!',
     515  responsive: 'Responsive',
    249516  rows: 'Rows',
    250517  save: 'Save',
    … …  
    253520  search_field_color: 'Search field color',
    254521  search_input_style: 'Search input style',
     522  select_categories: 'Select categories',
    255523  select_color: 'Select color',
     524  select_option: 'Select option',
    256525  separator: 'Separator',
    257526  settings: 'Settings',
    … …  
    261530  show_hide_columns: 'Show/Hide columns',
    262531  show_images: 'Show images',
     532  show_more: 'Show more',
     533  show_notifications: 'Show notifications',
     534  show_settings: 'Show settings',
    263535  sort_order: 'Sort order',
    264536  sorting: 'Sorting',
    … …  
    267539  square: 'Square',
    268540  standard: 'Standard',
     541  status: 'Status',
    269542  sticky_first_column: 'Sticky 1st col.',
    270543  sticky_header: 'Sticky header',
    271544  sticky_last_column: 'Sticky last col.',
     545  stock_badge: 'Stock badge',
     546  style: 'Style',
    272547  success: 'Action was successful',
    273548  sync: 'Sync',
    274549  table: 'table',
     550  table_as: 'table as',
    275551  table_css_class: 'Table CSS class',
    276552  table_height: 'Table height',
    277   table_width: 'Table width',
    278553  table_max_width: 'Table max width',
    279554  table_name: 'Table name',
     555  table_width: 'Table width',
    280556  tables: 'Tables',
     557  tablet: 'Tablet',
    281558  text_color: 'Text color',
    282   thumbnail_height: 'Thumbnail height',
    283   thumbnail_width: 'Thumbnail width',
    284559  title: 'Title',
    285560  title_css_class: 'Title CSS class',
    286561  top: 'Top',
     562  twice_daily: 'Twice a day',
    287563  turn_links_into_buttons: 'Turn links into buttons',
    288564  type: 'Type',
     565  unassigned_columns: 'Unassigned columns',
     566  upgrade: 'Upgrade',
    289567  upload: 'Upload',
    290568  upload_files: 'Upload Files',
    291569  upload_more: 'Upload More',
    292570  uploaded: 'Uploaded',
     571  valid: 'Valid',
     572  invalid: 'Invalid',
    293573  vertical: 'Vertical',
    294574  vertical_alignment: 'Vertical alignment',
  • vtables/trunk/src/i18n/index.ts

    r3576926 r3754379  
    11import enUS from './en-US';
     2import es from './es';
     3import de from './de';
     4import fr from './fr';
     5import hi from './hi';
     6import it from './it';
     7import nb from './nb';
     8import pt from './pt';
     9import ru from './ru';
     10import sv from './sv';
     11import tr from './tr';
     12import zhCN from './zh-CN';
     13import zhHK from './zh-HK';
    214
    315export default {
    4   'en-US': enUS
     16  'en-US': enUS,
     17  es: es,
     18  de: de,
     19  fr: fr,
     20  hi: hi,
     21  it: it,
     22  nb: nb,
     23  pt: pt,
     24  ru: ru,
     25  sv: sv,
     26  tr: tr,
     27  'zh-CN': zhCN,
     28  'zh-HK': zhHK,
    529};
  • vtables/trunk/uninstall.php

    r3036822 r3754379  
    11<?php
     2if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) exit;
     3
     4/**
     5 * UNINSTALL
     6 *
     7 * Removes this plugin's options, scheduled events and upload working files.
     8 * The tables themselves (wp_vtables, wp_vtables_source) are shared with
     9 * vTables Pro and hold the user's content, so they are only dropped on
     10 * explicit request — add to wp-config.php before deleting the plugin:
     11 *
     12 *   define( 'VTABLES_REMOVE_ALL_DATA', true );
     13 *
     14 * and even then never while vTables Pro is still installed.
     15 */
     16
     17function vtables_uninstall_site() {
     18  global $wpdb;
     19
     20  foreach ( array(
     21    'vtables_db_version',
     22    'vtables_rewrite_version',
     23    'vtables_rows_migrated',
     24    'vtables_rows_migration_skipped',
     25    'vtables_migration_log',
     26  ) as $option ) {
     27    delete_option( $option );
     28  }
     29
     30  wp_clear_scheduled_hook( 'vtables_migrate_rows_tick' );
     31
     32  // Everything below is shared with vTables Pro (same names), so it stays
     33  // while Pro is installed.
     34  if ( file_exists( WP_PLUGIN_DIR . '/vtables-pro/vtables-pro.php' ) ) {
     35    return;
     36  }
     37
     38  // Per-table count-cache versions and their transients, plus import sessions.
     39  $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     40    "DELETE FROM {$wpdb->options}
     41     WHERE option_name LIKE 'vtables\\_count\\_ver\\_%'
     42        OR option_name LIKE '\\_transient\\_vtables\\_count\\_%'
     43        OR option_name LIKE '\\_transient\\_timeout\\_vtables\\_count\\_%'
     44        OR option_name LIKE '\\_transient\\_vtables\\_import\\_%'
     45        OR option_name LIKE '\\_transient\\_timeout\\_vtables\\_import\\_%'"
     46  );
     47
     48  if ( defined( 'VTABLES_REMOVE_ALL_DATA' ) && VTABLES_REMOVE_ALL_DATA ) {
     49    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}vtables_source" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange
     50    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}vtables" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange
     51  }
     52}
     53
     54function vtables_uninstall_remove_dir( $dir ) {
     55  if ( ! is_dir( $dir ) ) {
     56    return;
     57  }
     58
     59  foreach ( array_diff( (array) scandir( $dir ), array( '.', '..' ) ) as $file ) {
     60    $path = $dir . $file;
     61    if ( is_dir( $path ) ) {
     62      vtables_uninstall_remove_dir( $path . '/' );
     63    } else {
     64      wp_delete_file( $path );
     65    }
     66  }
     67
     68  rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
     69}
     70
     71if ( is_multisite() ) {
     72  foreach ( get_sites( array( 'fields' => 'ids' ) ) as $vtables_site_id ) {
     73    switch_to_blog( $vtables_site_id );
     74    vtables_uninstall_site();
     75    restore_current_blog();
     76  }
     77} else {
     78  vtables_uninstall_site();
     79}
     80
     81// Upload working files only (chunks/assembled imports in progress) — never
     82// the user's media. Left alone while vTables Pro, which shares it, is installed.
     83if ( ! file_exists( WP_PLUGIN_DIR . '/vtables-pro/vtables-pro.php' ) ) {
     84  $vtables_upload_info = wp_upload_dir();
     85  vtables_uninstall_remove_dir( trailingslashit( $vtables_upload_info['basedir'] ) . 'vtables_uploads/' );
     86}
  • vtables/trunk/vtables.php

    r3576926 r3754379  
    22
    33/**
    4  * Plugin Name: vTables
    5  * Description: Create stunning, interactive tables effortlessly. No coding required. The ultimate WordPress plugin, lets you design powerful tables in a few clicks. Transform your data visualization now.
    6  * Author: vTables
    7  * Author URI: https://vtables.pro
    8  * Version: 0.2.1
    9  * License: GPLv3
     4 * Plugin Name:       vTables – Data Tables from CSV, Excel and JSON
     5 * Plugin URI:        https://vtables.pro
     6 * Description:       Build interactive data tables from CSV, Excel and JSON files, or from scratch, and embed them anywhere with a shortcode.
     7 * Version:           0.3
     8 * Requires at least: 6.0
     9 * Requires PHP:      7.4
     10 * Author:            vTables
     11 * Author URI:        https://vtables.pro
     12 * License:           GPLv3
     13 * License URI:       https://www.gnu.org/licenses/gpl-3.0.html
     14 * Text Domain:       vtables
    1015 */
    1116
    1217if ( ! defined( 'ABSPATH' ) ) exit;
    1318
    14 define('VTABLES_URL', plugin_dir_url(__FILE__));
    15 define('VTABLES_DIR', plugin_dir_path(__FILE__));
    16 define('VTABLES_VERSION', '0.2.1');
     19define( 'VTABLES_VERSION', '0.3' );
     20define( 'VTABLES_FILE', __FILE__ );
     21define( 'VTABLES_DIR', plugin_dir_path( __FILE__ ) );
     22define( 'VTABLES_URL', plugin_dir_url( __FILE__ ) );
     23
     24require_once VTABLES_DIR . 'includes/compat.php';
    1725
    1826class vTables {
    1927  public function vtables_boot() {
    20     $this->vtables_add_module_to_script();
    2128    $this->vtables_load_classes();
    22     $this->vtables_add_upgrade_link();
     29    $this->vtables_settings_policy();
     30    $this->vtables_maybe_upgrade();
    2331    $this->vtables_register_shortcodes();
    24     $this->vtables_activate_plugin();
     32    $this->vtables_embed();
    2533    $this->vtables_render_menu();
     34    $this->vtables_action_links();
    2635    $this->vtables_db_endpoints();
    27   }
    28 
    29   /**
    30    * ADD MODULE TO SCRIPT HOOK
    31    */
    32   public function vtables_add_module_to_script() {
    33     add_filter('script_loader_tag', array($this, 'vtables_add_module_to_script_handler'), 10, 3);
    34   }
    35 
    36   /**
    37    * ADD MODULE TO SCRIPT HANDLER
    38    */
    39   function vtables_add_module_to_script_handler($tag, $handle, $src) {
    40     if ($handle === 'vtables-script-boot') {
    41       $tag = str_replace('<script ', '<script type="module" id="vtables-script-boot" ', $tag);
    42     }
    43     return $tag;
     36    $this->vtables_migrate_rows();
    4437  }
    4538
    … …  
    4841   */
    4942  public function vtables_load_classes() {
    50     require VTABLES_DIR . 'includes/autoload.php';
    51   }
    52 
    53   /**
    54    * ADD LINK TO UPGRADE HOOK
    55    */
    56   public function vtables_add_upgrade_link() {
    57     add_filter( 'plugin_action_links_' . plugin_basename(__FILE__), array($this, 'vtables_upgrade_link_handler') );
    58   }
    59 
    60   /**
    61    * ADD LINK TO UPGRADE HANDLER
    62    */
    63   function vtables_upgrade_link_handler( array $links ) {
    64     $settings_link = '<b><a target="_blank" href="https://vtables.pro">' . __('Download Pro Add-on for free', 'vtables') . '</a></b>';
    65       $links[] = $settings_link;
    66     return $links;
     43    require_once VTABLES_DIR . 'includes/autoload.php';
     44
     45    // SimpleXLSX / SimpleXLS for spreadsheet imports.
     46    if ( file_exists( VTABLES_DIR . 'vendor/php/autoload.php' ) ) {
     47      require_once VTABLES_DIR . 'vendor/php/autoload.php';
     48    }
     49  }
     50
     51  /**
     52   * SETTINGS POLICY
     53   * Loaded before the endpoints: save_settings/create_table filter every
     54   * settings blob through it.
     55   */
     56  public function vtables_settings_policy() {
     57    include_once VTABLES_DIR . 'includes/settings-policy.php';
    6758  }
    6859
    … …  
    7162   */
    7263  public function vtables_render_menu() {
    73     add_action('admin_menu', function () {
    74       if (!current_user_can('manage_options')) {
     64    add_action( 'admin_menu', function () {
     65      if ( ! current_user_can( 'manage_options' ) ) {
    7566        return;
    7667      }
    77       global $submenu;
     68
    7869      add_menu_page(
    7970        'vTables',
    … …  
    8172        'manage_options',
    8273        'vtables',
    83         array($this, 'vtables_render_admin_page'),
    84         plugins_url('public/icons/vtables-menu-icon-color.png', __FILE__),
     74        array( $this, 'vtables_render_admin_page' ),
     75        plugins_url( 'public/icons/vtables-menu-icon-color.png', __FILE__ ),
    8576        25
    8677      );
    87     });
     78
     79      global $submenu;
     80      $submenu['vtables']['tables'] = array( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
     81        __( 'Tables', 'vtables' ),
     82        'manage_options',
     83        'admin.php?page=vtables#/',
     84      );
     85
     86      // The Logs page only reports the legacy row migration, so it appears
     87      // once there is something to report.
     88      if ( ! empty( get_option( 'vtables_migration_log' ) ) ) {
     89        $submenu['vtables']['logs'] = array( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
     90          __( 'Logs', 'vtables' ),
     91          'manage_options',
     92          'admin.php?page=vtables#/logs',
     93        );
     94      }
     95    } );
    8896  }
    8997
    … …  
    93101  public function vtables_render_admin_page() {
    94102    $loadAssets = new \vTables\Classes\LoadAssets();
    95     $loadAssets->vtables_enqueue_assets();
    96 
    97     $WP_API = apply_filters('vTables/admin_app_vars', array(
    98       'assets_url' => VTABLES_URL . 'dist/assets/',
    99       'ajaxurl' => admin_url('admin-ajax.php'),
    100       'nonce' => wp_create_nonce( 'wp_rest' ),
    101       'base_url' => rest_url(),
    102     ));
    103 
    104     wp_localize_script('vtables-script-boot', 'WP_API_Settings', $WP_API);
    105 
     103    $loadAssets->vtables_enqueue_assets_admin();
    106104    echo '<div id="q-app"></div>';
    107105  }
    108106
    109107  /**
     108   * PLUGIN ACTION LINKS
     109   * One "Upgrade" link on the Plugins screen, only while Pro isn't installed.
     110   */
     111  public function vtables_action_links() {
     112    add_filter( 'plugin_action_links_' . plugin_basename( VTABLES_FILE ), function ( $links ) {
     113      if ( vtables_is_pro_installed() ) {
     114        return $links;
     115      }
     116
     117      $links[] = '<a href="' . esc_url( 'https://vtables.pro' ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Upgrade to Pro', 'vtables' ) . '</a>';
     118      return $links;
     119    } );
     120  }
     121
     122  /**
    110123   * REGISTER SHORTCODES
    111124   */
    112125  public function vtables_register_shortcodes() {
    113     global $shortcode_count;
    114     $shortcode_count = 0;
    115 
    116     // render shortcode
    117     function vtables_shortcode_render_handler($atts = []) {
    118       global $wpdb;
    119       global $shortcode_count;
    120       $id = isset($atts['id']) ? intval($atts['id']) : 0;
    121 
    122       // render specific cell value
    123       $row_number = isset($atts['row']) ? intval($atts['row']) : 0;
    124       $col_name = isset($atts['column']) ? sanitize_text_field($atts['column']) : '';
    125 
    126       if ($row_number > 0 && !empty($col_name)) {
    127         $col_name_snakecase = strtolower(str_replace(' ', '_', $col_name));
    128         $table_name = $wpdb->prefix . 'vtables';
    129         $query = $wpdb->prepare("SELECT * FROM $table_name WHERE id = %d", $id);
    130         $results = $wpdb->get_results($query);
    131 
    132         if (empty($results)) {
    133           return 'Value not found';
    134         }
    135 
    136         $source = json_decode($results[0]->source);
    137         $rows = $source->rows;
    138         $row_index = $row_number - 1;
    139 
    140         if (!isset($rows[$row_index])) {
    141           return 'Row not found.';
    142         }
    143 
    144         $cell_value = isset($rows[$row_index]->{$col_name_snakecase}) ? $rows[$row_index]->{$col_name_snakecase} : '';
    145 
    146         return $cell_value;
    147       }
    148 
    149 
    150       // render table
    151       $shortcode_count++;
    152 
    153       ob_start();
    154 
    155       ?>
    156 
    157       <div class="vtables-iframe-wrapper">
    158         <iframe
    159           name="<?php echo esc_attr($shortcode_count); ?>"
    160           data-id="<?php echo esc_attr($id); ?>"
    161           data-count="<?php echo esc_attr($shortcode_count); ?>"
    162           data-baseurl="<?php echo esc_url(rest_url()); ?>"
    163           src="<?php echo esc_url(VTABLES_URL . 'includes/shortcode.html'); ?>"
    164           style="width: 100%; height: 150px; border: 0 none;"
    165         ></iframe>
    166       <div>
    167 
    168 
    169       <?php
    170 
    171       return ob_get_clean();
    172     }
    173     add_shortcode( 'vtables', 'vtables_shortcode_render_handler' );
    174   }
    175 
    176   /**
    177    * ACTIVATE PLUGIN
    178    */
    179   public function vtables_activate_plugin() {
    180     register_activation_hook(__FILE__, function ($newWorkWide) {
    181       require_once(VTABLES_DIR . 'includes/Classes/Activator.php');
    182       $activator = new \vTables\Classes\Activator();
    183       $activator->vtables_migrate_databases($newWorkWide);
    184     });
     126    add_shortcode( 'vtables', array( $this, 'vtables_shortcode_render_handler' ) );
     127  }
     128
     129  /**
     130   * EMBED DOCUMENT
     131   * Serves the [vtables] iframe document from WordPress (/vtables-embed/{id}/),
     132   * on the same URL scheme as vTables Pro. See includes/Classes/Embed.php.
     133   */
     134  public function vtables_embed() {
     135    ( new \vTables\Classes\Embed() )->register();
     136  }
     137
     138  /**
     139   * RENDER SPECIFIC VALUE
     140   */
     141  public function vtables_render_specific_value( $row_number, $col_name, $id ) {
     142    global $wpdb;
     143
     144    $col_name_snakecase = strtolower( str_replace( ' ', '_', $col_name ) );
     145    $row_index = $row_number - 1;
     146
     147    $table = vtables_table_meta( $id );
     148
     149    if ( $table === null ) {
     150      return __( 'Value not found', 'vtables' );
     151    }
     152
     153    // Pro-only sources, and tables whose saved row filter only Pro applies.
     154    if ( vtables_table_requires_pro( $table ) ) {
     155      return '';
     156    }
     157
     158    // Rows live in wp_vtables_source (one record per row, keyed by table_id +
     159    // row_index). Tables that still keep their rows inline (0.2.x and older,
     160    // not yet migrated) fall through to wp_vtables.source->rows below.
     161    $has_migrated_rows = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     162      "SELECT COUNT(*) FROM {$wpdb->prefix}vtables_source WHERE table_id = %d", $id
     163    ) ) > 0;
     164
     165    if ( $has_migrated_rows ) {
     166      $row_json = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     167        "SELECT row_json FROM {$wpdb->prefix}vtables_source WHERE table_id = %d AND row_index = %d",
     168        $id,
     169        $row_index
     170      ) );
     171
     172      if ( $row_json === null ) {
     173        return __( 'Row not found.', 'vtables' );
     174      }
     175
     176      $row = json_decode( $row_json );
     177      return ( is_object( $row ) && isset( $row->{$col_name_snakecase} ) ) ? $row->{$col_name_snakecase} : '';
     178    }
     179
     180    $legacy_source = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     181      "SELECT source FROM {$wpdb->prefix}vtables WHERE id = %d", $id
     182    ) );
     183
     184    $source = json_decode( (string) $legacy_source );
     185    $rows = ( is_object( $source ) && isset( $source->rows ) ) ? $source->rows : array();
     186
     187    if ( ! isset( $rows[ $row_index ] ) ) {
     188      return __( 'Row not found.', 'vtables' );
     189    }
     190
     191    return isset( $rows[ $row_index ]->{$col_name_snakecase} ) ? $rows[ $row_index ]->{$col_name_snakecase} : '';
     192  }
     193
     194  /**
     195   * RENDER SHORTCODES
     196   */
     197  public function vtables_shortcode_render_handler( $atts = array() ) {
     198    $atts = is_array( $atts ) ? $atts : array();
     199    $id = isset( $atts['id'] ) ? intval( $atts['id'] ) : 0;
     200
     201    // render specific cell value
     202    $row_number = isset( $atts['row'] ) ? intval( $atts['row'] ) : 0;
     203    $col_name = isset( $atts['column'] ) ? sanitize_text_field( $atts['column'] ) : '';
     204    if ( $row_number > 0 && ! empty( $col_name ) ) {
     205      $value = $this->vtables_render_specific_value( $row_number, $col_name, $id );
     206
     207      // json-type cells decode to arrays/objects.
     208      if ( ! is_scalar( $value ) ) {
     209        $value = wp_json_encode( $value );
     210      }
     211
     212      // Cell data is untrusted, so escape on output. Allowlist mirrors the
     213      // SPA's sanitizeCellHtml (src/composables/useCellRendering.ts) to keep
     214      // both render paths in parity.
     215      return wp_kses( (string) $value, array(
     216        'a'      => array( 'href' => true, 'title' => true, 'target' => true, 'rel' => true, 'class' => true ),
     217        'img'    => array( 'src' => true, 'alt' => true, 'title' => true, 'class' => true, 'width' => true, 'height' => true ),
     218        'br'     => array(),
     219        'button' => array( 'href' => true, 'title' => true, 'target' => true, 'rel' => true, 'class' => true ),
     220      ) );
     221    }
     222
     223    // Tables from Pro-only sources (Google Sheets, external URLs, WooCommerce)
     224    // or with a saved row filter render nothing for visitors; admins get a
     225    // pointer instead of a blank spot.
     226    if ( vtables_table_requires_pro( vtables_table_meta( $id ) ) ) {
     227      if ( ! current_user_can( 'manage_options' ) ) {
     228        return '';
     229      }
     230
     231      return '<p class="vtables-requires-pro">' . esc_html(
     232        /* translators: %d: table ID. */
     233        sprintf( __( 'vTables table #%d needs vTables Pro (Pro data source or row filter), so it is hidden from visitors.', 'vtables' ), $id )
     234      ) . '</p>';
     235    }
     236
     237    $iframe_url = \vTables\Classes\Embed::url( $id );
     238
     239    ob_start();
     240    ?>
     241
     242    <div class="vtables-iframe-wrapper">
     243      <iframe
     244        src="<?php echo esc_url( $iframe_url ); ?>"
     245        title="<?php echo esc_attr__( 'vTables table', 'vtables' ); ?>"
     246        loading="lazy"
     247        referrerpolicy="same-origin"
     248        style="width: 100%; height: 150px; border: 0 none;"
     249      ></iframe>
     250    </div>
     251
     252    <?php
     253
     254    return ob_get_clean();
     255  }
     256
     257  /**
     258   * VERSION-GATED UPGRADE
     259   * register_activation_hook does not fire when a plugin is updated in place,
     260   * and 0.2.x never stored a schema version, so the migrations run on the
     261   * first request after an update — gated on the 'vtables_db_version' option,
     262   * which makes this a single autoloaded get_option() in the common case.
     263   */
     264  public function vtables_maybe_upgrade() {
     265    if ( get_option( 'vtables_db_version' ) === VTABLES_VERSION ) {
     266      return;
     267    }
     268
     269    $activator = new \vTables\Classes\Activator();
     270    $activator->vtables_migrate_databases( false );
     271
     272    update_option( 'vtables_db_version', VTABLES_VERSION );
    185273  }
    186274
    … …  
    189277   */
    190278  public function vtables_db_endpoints() {
    191     register_rest_field(
    192       'user',
    193       'user_email',
    194       [
    195         'get_callback' => static function (array $user): string {
    196           return get_userdata($user['id'])->user_email;
    197         },
    198       ]
    199     );
    200 
    201     include VTABLES_DIR . 'includes/endpoints.php';
    202   }
    203 
     279    include_once VTABLES_DIR . 'includes/endpoints.php';
     280  }
     281
     282  /**
     283   * LEGACY ROW MIGRATION (WP-CRON)
     284   * Moves 0.2.x inline source->rows into wp_vtables_source in the background.
     285   * Included after db_endpoints() — the worker persists through
     286   * vtables_replace_source_rows() and friends defined there.
     287   */
     288  public function vtables_migrate_rows() {
     289    include_once VTABLES_DIR . 'includes/migrate-rows.php';
     290  }
    204291}
    205292
    206 if (
    207   in_array( 'vtables-pro/vtables-pro.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ) ) ||
    208   in_array( 'vtables.pro/vtables-pro.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ) )
    209 ) {
    210   // Do nothing as Pro version installed and activated.
    211 } else {
    212   (new vTables())->vtables_boot();
     293/**
     294 * ACTIVATE PLUGIN
     295 * Registered at file level: WordPress fires the activation hook on a request
     296 * where plugins_loaded has already run.
     297 */
     298register_activation_hook( __FILE__, 'vtables_activate' );
     299function vtables_activate( $network_wide = false ) {
     300  require_once VTABLES_DIR . 'includes/autoload.php';
     301  $activator = new \vTables\Classes\Activator();
     302  $activator->vtables_migrate_databases( $network_wide );
     303
     304  // 'init' already fired for this request, so the embed rule has to be
     305  // registered here or the flush would rebuild the rewrite array without it.
     306  \vTables\Classes\Embed::register_rewrite();
     307  flush_rewrite_rules( false );
    213308}
    214309
    215 
    216 
    217 
    218 
     310/**
     311 * DEACTIVATE PLUGIN
     312 */
     313register_deactivation_hook( __FILE__, 'vtables_deactivate' );
     314function vtables_deactivate() {
     315  wp_clear_scheduled_hook( 'vtables_migrate_rows_tick' );
     316  delete_option( 'vtables_rewrite_version' );
     317  flush_rewrite_rules( false );
     318}
     319
     320/**
     321 * BOOT
     322 * Deferred to plugins_loaded so every plugin has been included first: when
     323 * vTables Pro is running it owns the admin screen, the shortcode, the embed
     324 * document and the endpoints (on the same tables and URLs), and this plugin
     325 * stays dormant.
     326 */
     327add_action( 'plugins_loaded', 'vtables_bootstrap' );
     328function vtables_bootstrap() {
     329  if ( vtables_is_pro_running() ) {
     330    return;
     331  }
     332
     333  ( new vTables() )->vtables_boot();
     334}
Note: See TracChangeset for help on using the changeset viewer.