Plugin Directory

Changeset 3713160


Ignore:
Timestamp:
09/25/2026 02:04:22 PM (4 weeks ago)
Author:
loureiro70
Message:

Update to version 1.0.2 from GitHub

Location:
lodestone-product-compliance
Files:
10 added
42 edited
1 copied

Legend:

Unmodified
Added
Removed
  • lodestone-product-compliance/tags/1.0.2/admin/class-admin.php

    r3705566 r3713160  
    4242                add_filter( 'posts_where', [ $this, 'warm_where' ], 10, 2 );
    4343
     44                // The rating prompt's handler. Its panel is printed by the operators view.
     45                ( new Review_Prompt() )->hooks();
     46
    4447                $this->maybe_add_notice();
    4548        }
    … …  
    128131                        // PREFIX, not SLUG: Notices derives its option names from this, and Uninstaller
    129132                        // sweeps options by the lodecomply_ prefix. SLUG here would orphan the dismissal flag.
    130                         $this->notices = new \Portfolio\Core\Notices( Plugin::PREFIX, Plugin::MENU_SLUG );
     133                        $this->notices = new \Portfolio\Core\Notices( Plugin::PREFIX, Plugin::MENU_SLUG, 'manage_woocommerce' );
    131134                }
    132135
    … …  
    205208                if ( 'dashboard' === $view ) {
    206209                        self::warm_index();
     210                } elseif ( 'languages' === $view ) {
     211                        // The other screen that lists index rows as links. See Status_Store::prune().
     212                        Status_Store::prune();
    207213                }
    208214
    … …  
    212218
    213219                require $file;
     220
     221                /*
     222                 * Where to ask, at the foot of every tab. Not a notice: a line of text inside this page.
     223                 * Resolved forum threads help the listing get found, and nothing here said where to ask.
     224                 */
     225                \Portfolio\Core\Support_Link::render(
     226                        [
     227                                'slug'     => Plugin::SLUG,
     228                                'css'      => 'lodecomply',
     229                                'question' => __( 'Questions?', 'lodestone-product-compliance' ),
     230                                'forum'    => __( 'Ask in the WordPress.org support forum', 'lodestone-product-compliance' ),
     231                                'paying'   => lodecomply_fs()->can_use_premium_code(),
     232                                'pro'      => __( 'Pro customers can also write to', 'lodestone-product-compliance' ),
     233                        ]
     234                );
    214235
    215236                echo '</div>';
    … …  
    226247         */
    227248        private static function warm_index(): void {
     249                /*
     250                 * Rows for products that no longer exist, or are in the bin, come out first. The lifecycle
     251                 * hooks in Index keep new ones from appearing, but 1.0.0 and 1.0.1 had none, so a store
     252                 * that deleted products before upgrading still has their rows -- counted in every figure
     253                 * on this screen and listed under "Products to fix first" as a link with no name.
     254                 */
     255                Status_Store::prune();
     256
    228257                $cursor = (int) get_option( self::OPTION_WARM_CURSOR, 0 );
    229258
    … …  
    237266                 * revisiting products that were indexed and later changed -- which does not matter,
    238267                 * because those are re-evaluated on save by Product_Data.
     268                 *
     269                 * suppress_filters => false is what makes the cursor work at all. get_posts() defaults it
     270                 * to true, and with it true WP_Query never applies posts_where, so warm_where() never ran
     271                 * and every visit re-read the same first WARM_BATCH products: on any catalogue past 100,
     272                 * product 101 onward was never checked by this screen. Green Claims shipped the identical
     273                 * bug in its scanner and fixed it on 10 Sep 2026; the fix did not reach this plugin until
     274                 * 24 Sep.
    239275                 */
    240276                $product_ids = get_posts(
    … …  
    249285                                'update_post_meta_cache' => false,
    250286                                'update_post_term_cache' => false,
    251                                 'lodecomply_min_id'             => $cursor,
     287                                'suppress_filters'       => false,
     288                                'lodecomply_min_id'      => $cursor,
    252289                        ]
    253290                );
    … …  
    266303
    267304                update_option( self::OPTION_WARM_CURSOR, (int) max( $product_ids ), false );
     305        }
     306
     307        /**
     308         * The text of a link to a product, never empty.
     309         *
     310         * WooCommerce lets a product be saved with no title, and a link whose text is empty has no
     311         * accessible name: a screen reader announces "link" and nothing else. This portfolio sells an
     312         * accessibility auditor, and its own screens are held to that. The same empty link is what a
     313         * row for a deleted product rendered as before Index removed them.
     314         */
     315        public static function product_label( int $product_id ): string {
     316                $title = trim( wp_strip_all_tags( (string) get_the_title( $product_id ) ) );
     317
     318                if ( '' !== $title ) {
     319                        return $title;
     320                }
     321
     322                /* translators: %d: product ID. */
     323                return sprintf( __( 'Product #%d (no title)', 'lodestone-product-compliance' ), $product_id );
    268324        }
    269325
  • lodestone-product-compliance/tags/1.0.2/admin/views/dashboard.php

    r3705566 r3713160  
    169169                                        <th scope="row">
    170170                                                <a href="<?php echo esc_url( (string) get_edit_post_link( $lodecomply_product_id ) ); ?>">
    171                                                         <?php echo esc_html( get_the_title( $lodecomply_product_id ) ); ?>
     171                                                        <?php echo esc_html( Admin::product_label( $lodecomply_product_id ) ); ?>
    172172                                                </a>
    173173                                        </th>
  • lodestone-product-compliance/tags/1.0.2/admin/views/operators.php

    r3705566 r3713160  
    1616// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
    1717$lodecomply_assigned = isset( $_GET['lodecomply_assigned'] ) ? absint( wp_unslash( $_GET['lodecomply_assigned'] ) ) : 0;
     18// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
     19$lodecomply_more = isset( $_GET['lodecomply_more'] ) && '1' === sanitize_key( wp_unslash( $_GET['lodecomply_more'] ) );
     20// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
     21$lodecomply_only_empty = ! isset( $_GET['lodecomply_only_empty'] ) || '0' !== sanitize_key( wp_unslash( $_GET['lodecomply_only_empty'] ) );
     22// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
     23$lodecomply_scope_empty = isset( $_GET['lodecomply_scope'] ) && 'none' === sanitize_key( wp_unslash( $_GET['lodecomply_scope'] ) );
    1824
    1925$lodecomply_categories = get_terms(
    … …  
    3238</p>
    3339
    34 <?php if ( 'assigned' === $lodecomply_notice ) : ?>
     40<?php
     41/*
     42 * A run that changed nothing is not a success, and must not look like one: it used to print
     43 * "0 products updated." in the same green notice as a run that filled a catalogue. It gets an
     44 * information notice that says why nothing changed. A run that did change something says whether
     45 * there is more -- Bulk knows, so the screen no longer guesses from a full batch.
     46 */
     47?>
     48<?php if ( 'assigned' === $lodecomply_notice && 0 === $lodecomply_assigned ) : ?>
     49        <div class="notice notice-info inline">
     50                <p>
     51                        <?php
     52                        if ( $lodecomply_scope_empty ) {
     53                                esc_html_e( 'Nothing needed changing: there are no products in the category you chose.', 'lodestone-product-compliance' );
     54                        } elseif ( $lodecomply_only_empty ) {
     55                                esc_html_e( 'Nothing needed changing: every product in scope already had a value for this role, and existing values were left alone. Untick "Only fill in products that have no value yet" to replace them.', 'lodestone-product-compliance' );
     56                        } else {
     57                                esc_html_e( 'Nothing needed changing: every product in scope already uses this operator record for this role.', 'lodestone-product-compliance' );
     58                        }
     59                        ?>
     60                </p>
     61        </div>
     62<?php elseif ( 'assigned' === $lodecomply_notice ) : ?>
    3563        <div class="notice notice-success inline">
    3664                <p>
    … …  
    4270                        );
    4371                        ?>
    44                         <?php if ( Bulk::BATCH === $lodecomply_assigned ) : ?>
    45                                 <?php esc_html_e( 'That was a full batch, so there may be more to do — run it again.', 'lodestone-product-compliance' ); ?>
     72                        <?php if ( $lodecomply_more ) : ?>
     73                                <?php esc_html_e( 'There are more products in scope still to do — run it again with the same choices to continue.', 'lodestone-product-compliance' ); ?>
     74                        <?php else : ?>
     75                                <?php esc_html_e( 'That was every product in scope.', 'lodestone-product-compliance' ); ?>
    4676                        <?php endif; ?>
    4777                </p>
    … …  
    5282        </div>
    5383<?php endif; ?>
     84
     85<?php
     86// The rating prompt, right after a bulk assignment that filled a catalogue. Review_Prompt holds every rule.
     87if ( Review_Prompt::should_show() ) {
     88        Review_Prompt::render();
     89}
     90?>
    5491
    5592<p>
  • lodestone-product-compliance/tags/1.0.2/admin/views/pro.php

    r3705566 r3713160  
    6161        <li>
    6262                <strong><?php esc_html_e( 'WP-CLI.', 'lodestone-product-compliance' ); ?></strong>
    63                 <?php esc_html_e( 'Audit a catalogue and export a dossier from a script, across as many stores as you run.', 'lodestone-product-compliance' ); ?>
     63                <?php esc_html_e( 'Audit a whole catalogue and list what is still missing from a script, across as many stores as you run — with a non-zero exit when anything is incomplete, so it can sit in a deployment check or a nightly job.', 'lodestone-product-compliance' ); ?>
    6464        </li>
    6565</ul>
  • lodestone-product-compliance/tags/1.0.2/admin/views/settings.php

    r3705566 r3713160  
    5353                <?php
    5454                if ( $lodecomply_days > 0 ) {
     55                        // _n(): on the day before the obligation this read "Mandatory in 1 days". The dashboard's
     56                        // sentence about the same date already went through _n(); this one never did.
    5557                        printf(
    56                                 /* translators: 1: number of days, 2: the date the obligation starts. */
    57                                 esc_html__( 'Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early.', 'lodestone-product-compliance' ),
     58                                esc_html(
     59                                        /* translators: 1: number of days, 2: the date the obligation starts. */
     60                                        _n(
     61                                                'Mandatory in %1$s day, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early.',
     62                                                'Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early.',
     63                                                $lodecomply_days,
     64                                                'lodestone-product-compliance'
     65                                        )
     66                                ),
    5867                                esc_html( number_format_i18n( $lodecomply_days ) ),
    5968                                esc_html( date_i18n( (string) get_option( 'date_format' ), (int) strtotime( LODECOMPLY_GUARANTEE_NOTICE_DATE ) ) )
    … …  
    108117
    109118<p class="lodecomply-honesty">
    110         <?php esc_html_e( 'It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it does not contact any external service, and it never states that your store is compliant. Nothing here is legal advice.', 'lodestone-product-compliance' ); ?>
     119        <?php esc_html_e( 'It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it sends none of your product information to any external service, and it never states that your store is compliant. Nothing here is legal advice.', 'lodestone-product-compliance' ); ?>
    111120</p>
  • lodestone-product-compliance/tags/1.0.2/assets/css/admin.css

    r3705566 r3713160  
    117117        color: #8a2424;
    118118}
     119
     120/* Where to ask a question, at the foot of every tab. Quiet on purpose: it is not a notice. */
     121.lodecomply-support {
     122        margin-top: 2em;
     123        color: #646970;
     124}
     125
     126/* The rating prompt, after a bulk assignment. Green rule: it follows a success and is not an upsell. */
     127.lodecomply-review-prompt {
     128        max-width: 46em;
     129        margin: 1.5em 0;
     130        padding: 16px 20px;
     131        border: 1px solid #c3c4c7;
     132        border-left: 4px solid #00a32a;
     133        border-radius: 4px;
     134        background: #fff;
     135}
     136
     137.lodecomply-review-prompt h2 {
     138        margin-top: 0;
     139        font-size: 15px;
     140}
     141
     142.lodecomply-review-prompt .button + .button {
     143        margin-left: 8px;
     144}
     145
     146.lodecomply-review-prompt-footnote {
     147        margin-bottom: 0;
     148        color: #646970;
     149        font-size: 12px;
     150}
  • lodestone-product-compliance/tags/1.0.2/includes/class-autoloader.php

    r3705566 r3713160  
    5656        }
    5757
     58        /**
     59         * Whether this build carries premium code.
     60         *
     61         * This is what `is_premium` in the Freemius bootstrap is derived from, and it is derived
     62         * rather than declared on purpose. It used to read a constant -- A11YSCAN_IS_PREMIUM and its
     63         * three siblings -- that **nothing in this repository ever defined**, so `is_premium` was
     64         * false in every build of every plugin, paid ones included. The SDK therefore treated a
     65         * customer's paid package as a free install: `can_use_premium_code()` returned false, every
     66         * Pro tab stayed gated, and no licence-activation screen was ever offered, because the SDK
     67         * only offers one to a build that says it is premium. Found 21 Sep 2026 by buying a licence
     68         * and running the package Freemius delivered -- the first time that had ever been done.
     69         *
     70         * A build is premium exactly when it contains premium-marked files, which is already the
     71         * whole stripping mechanism, so this cannot drift from the truth the way a constant did.
     72         * It reads the filesystem once per request and the result is cached for the process.
     73         *
     74         * Note the marker is assembled in PREMIUM_SUFFIX rather than written out; see its docblock.
     75         */
     76        public static function is_premium_build(): bool {
     77                static $premium = null;
     78
     79                if ( null !== $premium ) {
     80                        return $premium;
     81                }
     82
     83                $premium = false;
     84
     85                if ( '' !== self::$dir ) {
     86                        foreach ( self::ROOTS as $root ) {
     87                                $found = glob( self::$dir . '/' . $root . '/*' . self::PREMIUM_SUFFIX . '.php' );
     88
     89                                if ( ! empty( $found ) ) {
     90                                        $premium = true;
     91
     92                                        break;
     93                                }
     94                        }
     95                }
     96
     97                return $premium;
     98        }
     99
    58100        public static function load( string $class_name ): void {
    59101                if ( 0 !== strpos( $class_name, self::PREFIX ) ) {
  • lodestone-product-compliance/tags/1.0.2/includes/class-bulk.php

    r3705566 r3713160  
    1111 * be run again; that is a slower interaction than a progress bar but it cannot half-finish a
    1212 * catalogue and leave no record of where it stopped.
     13 *
     14 * "Run it again" only works if the next run starts where this one stopped. With "only empty"
     15 * ticked that was always true, because a filled product stops matching. With it unticked it was
     16 * not: the query matched every product in scope, newest first, so every run rewrote the same
     17 * newest 200 and a catalogue past 200 could never be finished -- while each run reported "200
     18 * products updated". Both modes now select only products that do not already hold this record,
     19 * lowest id first, so each run's work removes itself from the next run's query, and the count is
     20 * of products that actually changed.
    1321 *
    1422 * @package LodeComply
    … …  
    4250                // phpcs:enable WordPress.Security.NonceVerification.Missing
    4351
    44                 $assigned = 0;
    45                 $notice   = 'error';
     52                $args = [
     53                        'view'              => 'operators',
     54                        'lodecomply_notice' => 'error',
     55                ];
    4656
    4757                /*
    … …  
    5767                        && Operators::POST_TYPE === get_post_type( $operator_id )
    5868                ) {
    59                         $assigned = self::assign( $field, $operator_id, $term_id, $only_empty );
    60                         $notice   = 'assigned';
    61                 }
    62 
    63                 wp_safe_redirect(
    64                         Admin::url(
    65                                 [
    66                                         'view'         => 'operators',
    67                                         'lodecomply_notice'   => $notice,
    68                                         'lodecomply_assigned' => $assigned,
    69                                 ]
    70                         )
    71                 );
     69                        $result = self::assign( $field, $operator_id, $term_id, $only_empty );
     70
     71                        $args['lodecomply_notice']     = 'assigned';
     72                        $args['lodecomply_assigned']   = $result['changed'];
     73                        $args['lodecomply_only_empty'] = $only_empty ? '1' : '0';
     74
     75                        if ( $result['more'] ) {
     76                                $args['lodecomply_more'] = '1';
     77                        }
     78
     79                        if ( ! $result['in_scope'] ) {
     80                                $args['lodecomply_scope'] = 'none';
     81                        }
     82
     83                        // One edit that filled a catalogue is the moment this plugin earns a rating request.
     84                        // Counted in products that changed: rewriting a value to itself fills nothing.
     85                        Review_Prompt::after_assign( $result['changed'], get_current_user_id() );
     86                }
     87
     88                wp_safe_redirect( Admin::url( $args ) );
    7289
    7390                exit;
    … …  
    7693        /**
    7794         * Assigns an operator to products, optionally limited to one product category.
     95         *
     96         * Only products that do not already hold this record are selected -- with $only_empty, only
     97         * those holding no value at all -- lowest id first, at most BATCH per run. Each run therefore
     98         * picks up where the last one stopped, whichever mode it is in.
    7899         *
    79100         * @param string $field       'manufacturer' or 'responsible_person'.
    … …  
    81102         * @param int    $term_id     Product category to limit to, or 0 for the whole catalogue.
    82103         * @param bool   $only_empty  Skip products that already have a value for this field.
    83          * @return int Number of products changed.
     104         * @return array{changed: int, more: bool, in_scope: bool} Products changed; whether products
     105         *         still needing it remain after this batch; whether the scope holds any product at all.
    84106         */
    85         public static function assign( string $field, int $operator_id, int $term_id = 0, bool $only_empty = true ): int {
     107        public static function assign( string $field, int $operator_id, int $term_id = 0, bool $only_empty = true ): array {
    86108                $meta_key = Fields::meta_key( $field );
    87 
    88                 $args = [
    89                         'post_type'              => 'product',
    90                         'post_status'            => 'any',
    91                         'posts_per_page'         => self::BATCH,
    92                         'fields'                 => 'ids',
    93                         'no_found_rows'          => true,
    94                         'update_post_meta_cache' => false,
    95                         'update_post_term_cache' => false,
    96                 ];
     109                $value    = (string) $operator_id;
     110                $base     = self::scope_args( $term_id );
     111                $args     = $base;
     112
     113                $args['posts_per_page'] = self::BATCH + 1;
    97114
    98115                if ( $only_empty ) {
    … …  
    117134                                ],
    118135                        ];
    119                 }
     136                } else {
     137                        // Everything that does not already point at this record, set or not.
     138                        $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin-only, batched, and the alternative is a full catalogue scan in PHP.
     139                                'relation' => 'OR',
     140                                [
     141                                        'key'     => $meta_key,
     142                                        'compare' => 'NOT EXISTS',
     143                                ],
     144                                [
     145                                        'key'     => $meta_key,
     146                                        'value'   => $value,
     147                                        'compare' => '!=',
     148                                ],
     149                        ];
     150                }
     151
     152                $product_ids = array_map( 'intval', get_posts( $args ) );
     153                $more        = count( $product_ids ) > self::BATCH;
     154                $changed     = 0;
     155
     156                foreach ( array_slice( $product_ids, 0, self::BATCH ) as $product_id ) {
     157                        // The query already excludes these; checked again because this is what gets counted.
     158                        if ( $value === (string) get_post_meta( $product_id, $meta_key, true ) ) {
     159                                continue;
     160                        }
     161
     162                        update_post_meta( $product_id, $meta_key, $value );
     163                        Evaluator::evaluate( $product_id );
     164
     165                        ++$changed;
     166                }
     167
     168                /*
     169                 * A run that changed nothing is one of two different things, and the screen says which:
     170                 * every product in scope already had what was asked for, or there is no product in scope
     171                 * at all -- an empty category, say. Only worth a query when nothing changed.
     172                 */
     173                $in_scope = true;
     174
     175                if ( 0 === $changed && ! $more ) {
     176                        $base['posts_per_page'] = 1;
     177                        $in_scope               = (bool) get_posts( $base );
     178                }
     179
     180                return [
     181                        'changed'  => $changed,
     182                        'more'     => $more,
     183                        'in_scope' => $in_scope,
     184                ];
     185        }
     186
     187        /**
     188         * The products a run may touch, before any condition on their current value.
     189         *
     190         * @param int $term_id Product category to limit to, or 0 for the whole catalogue.
     191         * @return array<string, mixed>
     192         */
     193        private static function scope_args( int $term_id ): array {
     194                $args = [
     195                        'post_type'              => 'product',
     196                        'post_status'            => 'any',
     197                        'posts_per_page'         => self::BATCH,
     198                        'orderby'                => 'ID',
     199                        'order'                  => 'ASC',
     200                        'fields'                 => 'ids',
     201                        'no_found_rows'          => true,
     202                        'update_post_meta_cache' => false,
     203                        'update_post_term_cache' => false,
     204                ];
    120205
    121206                if ( $term_id > 0 ) {
    … …  
    129214                }
    130215
    131                 $product_ids = get_posts( $args );
    132                 $assigned    = 0;
    133 
    134                 foreach ( $product_ids as $product_id ) {
    135                         $product_id = (int) $product_id;
    136 
    137                         update_post_meta( $product_id, $meta_key, (string) $operator_id );
    138                         Evaluator::evaluate( $product_id );
    139 
    140                         ++$assigned;
    141                 }
    142 
    143                 return $assigned;
     216                return $args;
    144217        }
    145218}
  • lodestone-product-compliance/tags/1.0.2/includes/class-evaluator.php

    r3705566 r3713160  
    2626        public const STATE_EXEMPT     = 'exempt';
    2727
     28        /** Products re-evaluated synchronously when an operator record changes. */
     29        public const REEVALUATE_LIMIT = 500;
     30
    2831        /**
    2932         * Evaluates one product and writes the result to the index.
    … …  
    3437                $payload = Fields::payload( $product_id );
    3538                $result  = self::evaluate_payload( $payload, has_post_thumbnail( $product_id ) );
     39
     40                /*
     41                 * Only a product the catalogue audit covers is written to the index. Every caller hands
     42                 * this an id it believes is a live product, but not every caller is right: the products
     43                 * list's Bin view asks status() about binned products, which used to evaluate them straight
     44                 * back into the counts they had just left. Anything else gets its answer computed and
     45                 * returned, and any row it had is removed. See Status_Store::prune() for what counts.
     46                 */
     47                if ( ! self::is_indexable( $product_id ) ) {
     48                        Status_Store::delete_status( $product_id );
     49
     50                        return $result;
     51                }
    3652
    3753                Status_Store::write_status( $product_id, $result['state'], $result['missing'] );
    … …  
    133149
    134150        /**
     151         * Whether a product belongs in the index: a product, not in the bin, not an auto-draft.
     152         *
     153         * The same set the dashboard warmer walks (WP_Query's 'any') and the dashboard's catalogue
     154         * count adds up. A binned product is on its way out of the shop, not a listing anyone is
     155         * offering; if it is restored, Index re-evaluates it.
     156         */
     157        public static function is_indexable( int $product_id ): bool {
     158                $post = $product_id > 0 ? get_post( $product_id ) : null;
     159
     160                if ( ! $post instanceof \WP_Post || 'product' !== $post->post_type ) {
     161                        return false;
     162                }
     163
     164                return ! in_array( $post->post_status, [ 'trash', 'auto-draft' ], true );
     165        }
     166
     167        /**
    135168         * Re-evaluates every product currently pointing at an operator record.
    136169         *
    137          * Called when an operator is edited, because one edit there can change the answer for
    138          * hundreds of products. Bounded and batched: a catalogue where a single manufacturer covers
    139          * ten thousand products must not turn one save into a timeout, so anything past the limit is
    140          * simply dropped from the index and recomputed lazily the next time it is read.
    141          */
    142         public static function invalidate_for_operator( int $operator_id ): void {
     170         * Called when an operator is edited, binned, restored or deleted, because one change there can
     171         * change the answer for hundreds of products. Bounded: a catalogue where a single manufacturer
     172         * covers ten thousand products must not turn one save into a timeout. So the first $limit
     173         * products are re-evaluated now, and every product past the limit is DROPPED from the index --
     174         * which is what this docblock always claimed and, until 24 Sep 2026, the code did not do: it
     175         * re-evaluated 500 and left the rest reporting the answer from before the edit.
     176         *
     177         * A dropped product is recomputed the next time anything reads it (Evaluator::status(), which
     178         * the products list calls) and by the dashboard warmer's next pass. Until then it is absent
     179         * from the counts rather than present with an answer that is no longer true, and the dashboard
     180         * already says how many products have been checked so far.
     181         *
     182         * @param int $operator_id The record that changed.
     183         * @param int $limit       How many to re-evaluate now; the rest are dropped.
     184         */
     185        public static function invalidate_for_operator( int $operator_id, int $limit = self::REEVALUATE_LIMIT ): void {
    143186                if ( $operator_id <= 0 ) {
    144187                        return;
    145188                }
    146189
     190                // Ids only, so even a very large set is a small array; the cost is in evaluating them.
    147191                $product_ids = get_posts(
    148192                        [
    149193                                'post_type'              => 'product',
    150194                                'post_status'            => 'any',
    151                                 'posts_per_page'         => 500,
     195                                'posts_per_page'         => -1,
     196                                'orderby'                => 'ID',
     197                                'order'                  => 'ASC',
    152198                                'fields'                 => 'ids',
    153199                                'no_found_rows'          => true,
    … …  
    168214                );
    169215
    170                 foreach ( $product_ids as $product_id ) {
     216                $limit = max( 0, $limit );
     217
     218                foreach ( array_slice( $product_ids, 0, $limit ) as $product_id ) {
    171219                        self::evaluate( (int) $product_id );
    172220                }
     221
     222                Status_Store::delete_statuses( array_slice( $product_ids, $limit ) );
    173223        }
    174224
  • lodestone-product-compliance/tags/1.0.2/includes/class-operators.php

    r3705566 r3713160  
    249249
    250250        /**
    251          * The stored values of one operator record.
    252          *
    253          * Returns an empty array for 0 or for a record that no longer exists, which is what lets
    254          * Evaluator treat "points at a deleted operator" the same as "not set" rather than fataling.
     251         * The values of one operator record, as the shop may use them.
     252         *
     253         * Returns an empty array for 0, for a record that no longer exists, and for one that is not
     254         * published -- in the bin, a draft, anything else. That is what lets Evaluator treat "points
     255         * at a deleted or binned operator" the same as "not set" rather than fataling, and what stops
     256         * the storefront printing a record the merchant has thrown away. Until 24 Sep 2026 this
     257         * checked existence only, so a binned record's name and address kept appearing on every
     258         * product page that pointed at it. Published is the same line options() draws: it is the only
     259         * status the product screen and bulk assign offer.
     260         *
     261         * The record's own edit screen reads stored_values() instead, because a draft or binned
     262         * record's form must still show what it holds -- an empty form would save as empty.
    255263         *
    256264         * @return array<string, string>
    257265         */
    258266        public static function values( int $operator_id ): array {
     267                $post = $operator_id > 0 ? get_post( $operator_id ) : null;
     268
     269                if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) {
     270                        return [];
     271                }
     272
     273                return self::stored_values( $operator_id );
     274        }
     275
     276        /**
     277         * What one operator record holds, whatever its status. For its own edit form only.
     278         *
     279         * @return array<string, string>
     280         */
     281        public static function stored_values( int $operator_id ): array {
    259282                if ( $operator_id <= 0 ) {
    260283                        return [];
    … …  
    343366                wp_nonce_field( 'lodecomply_operator_save', 'lodecomply_operator_nonce' );
    344367
    345                 $values = self::values( (int) $post->ID );
     368                $values = self::stored_values( (int) $post->ID );
    346369
    347370                require lodecomply()->dir( 'admin/views/operator-fields.php' );
  • lodestone-product-compliance/tags/1.0.2/includes/class-plugin.php

    r3705566 r3713160  
    9696
    9797                ( new Operators() )->hooks();
     98                ( new Index() )->hooks();
    9899                ( new Display() )->hooks();
    99100                ( new Guarantee() )->hooks();
  • lodestone-product-compliance/tags/1.0.2/includes/class-status-store.php

    r3705566 r3713160  
    101101
    102102        /**
     103         * Drops many products from the index at once, so they are re-evaluated when next read.
     104         *
     105         * @param int[] $product_ids Products to drop.
     106         */
     107        public static function delete_statuses( array $product_ids ): void {
     108                global $wpdb;
     109
     110                $table = Activator::status_table();
     111
     112                foreach ( array_chunk( array_values( array_filter( array_map( 'intval', $product_ids ) ) ), 500 ) as $chunk ) {
     113                        $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
     114
     115                        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name and a generated list of %d placeholders, one per id; every value is a placeholder, which the sniff cannot see through implode().
     116                        $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE product_id IN ({$placeholders})", $chunk ) );
     117                }
     118        }
     119
     120        /**
     121         * Removes rows for anything that is not a product the catalogue audit covers.
     122         *
     123         * The index is "what is missing right now" for products that exist for sale or are being
     124         * prepared for it: every status the warmer walks, which is WP_Query's 'any' -- so not the bin
     125         * and not an auto-draft. A deleted product, a binned one, or an id that was never a product
     126         * has no business in a count. Index keeps new rows of that kind from appearing; this clears
     127         * the ones written before it existed. Snapshots are untouched: they are evidence, and a
     128         * deleted product's evidence is still evidence.
     129         */
     130        public static function prune(): void {
     131                global $wpdb;
     132
     133                $table = Activator::status_table();
     134
     135                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names, not values; every value is a placeholder.
     136                $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE product_id NOT IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status NOT IN ( %s, %s ) )", 'product', 'trash', 'auto-draft' ) );
     137        }
     138
     139        /**
    103140         * Counts by state, for the dashboard headline.
    104141         *
  • lodestone-product-compliance/tags/1.0.2/includes/freemius.php

    r3705566 r3713160  
    6464                                        'public_key'   => LODECOMPLY_FS_PUBLIC_KEY,
    6565                                        'menu_slug'    => \LodeComply\Plugin::MENU_SLUG,
    66                                         'is_premium'   => defined( 'LODECOMPLY_IS_PREMIUM' ) && LODECOMPLY_IS_PREMIUM,
     66                                        'is_premium'   => \LodeComply\Autoloader::is_premium_build(),
    6767                                ]
    6868                        );
  • lodestone-product-compliance/tags/1.0.2/languages/lodestone-product-compliance.pot

    r3705566 r3713160  
    55"Project-Id-Version: Lodestone Product Compliance – GPSR for WooCommerce\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/lodestone-product-compliance/\n"
    7 "POT-Creation-Date: 2026-09-20 01:30:06+0000\n"
     7"POT-Creation-Date: 2026-09-25 13:43:40+0000\n"
    88"MIME-Version: 1.0\n"
    99"Content-Type: text/plain; charset=UTF-8\n"
    … …  
    1313
    1414#. translators: %s: link to the plugin's dashboard, with anchor text.
    15 #: admin/class-admin.php:119
     15#: admin/class-admin.php:122
    1616msgid "Product Compliance is ready. See which products are still missing GPSR information in %s."
    1717msgstr ""
    1818
    1919#. translators: %s: link to the plugin's dashboard, with anchor text.
    20 #: admin/class-admin.php:120
     20#: admin/class-admin.php:123
    2121msgid "the Compliance dashboard"
    2222msgstr ""
    2323
    24 #: admin/class-admin.php:194
     24#: admin/class-admin.php:197
    2525msgid "You are not allowed to view product compliance data."
    2626msgstr ""
    2727
    28 #: admin/class-admin.php:347
     28#: admin/class-admin.php:229
     29msgid "Questions?"
     30msgstr ""
     31
     32#: admin/class-admin.php:230
     33msgid "Ask in the WordPress.org support forum"
     34msgstr ""
     35
     36#: admin/class-admin.php:232
     37msgid "Pro customers can also write to"
     38msgstr ""
     39
     40#. translators: %d: product ID.
     41#: admin/class-admin.php:323
     42msgid "Product #%d (no title)"
     43msgstr ""
     44
     45#: admin/class-admin.php:403
    2946msgid "Catalogue"
    3047msgstr ""
    3148
    32 #: admin/class-admin.php:348
    33 #: admin/views/operators.php:28
     49#: admin/class-admin.php:404
     50#: admin/views/operators.php:34
    3451#: includes/class-operators.php:160
    3552msgid "Operator records"
    3653msgstr ""
    3754
    38 #: admin/class-admin.php:349
     55#: admin/class-admin.php:405
    3956#: admin/views/settings.php:17
    4057msgid "Settings"
    4158msgstr ""
    4259
    43 #: admin/class-admin.php:360
     60#: admin/class-admin.php:416
    4461msgid "Languages"
    4562msgstr ""
    4663
    47 #: admin/class-admin.php:361
     64#: admin/class-admin.php:417
    4865msgid "Coverage"
    4966msgstr ""
    5067
    51 #: admin/class-admin.php:372
     68#: admin/class-admin.php:428
    5269msgid "Pro"
    5370msgstr ""
    5471
    55 #: admin/class-admin.php:375
     72#: admin/class-admin.php:431
    5673msgid "Product Compliance sections"
    5774msgstr ""
    5875
    59 #: admin/class-admin.php:399
     76#: admin/class-admin.php:455
    6077msgid "You are not allowed to change these settings."
    6178msgstr ""
    6279
    63 #: admin/class-admin.php:59
    64 #: admin/class-admin.php:72
    65 #: admin/class-admin.php:80
     80#: admin/class-admin.php:62
     81#: admin/class-admin.php:75
     82#: admin/class-admin.php:83
    6683msgid "Product Compliance"
    6784msgstr ""
    6885
    69 #: admin/class-admin.php:60
    70 #: admin/class-admin.php:81
     86#: admin/class-admin.php:63
     87#: admin/class-admin.php:84
    7188#: includes/class-product-data.php:144
    7289msgid "Compliance"
    7390msgstr ""
    7491
    75 #: admin/class-admin.php:73
     92#: admin/class-admin.php:76
    7693msgid "This plugin adds product safety information to WooCommerce products, so it needs WooCommerce to be active. Nothing has been changed on your site and no data has been removed."
     94msgstr ""
     95
     96#: admin/class-review-prompt.php:57
     97msgid "Did that save you some time?"
     98msgstr ""
     99
     100#: admin/class-review-prompt.php:58
     101msgid "Ratings are how a plugin gets found in the WordPress.org directory. If filling a whole catalogue in one step helped, a rating helps the next shop with the same job find it."
     102msgstr ""
     103
     104#: admin/class-review-prompt.php:59
     105msgid "Rate it on WordPress.org"
     106msgstr ""
     107
     108#: admin/class-review-prompt.php:60
     109msgid "No thanks"
     110msgstr ""
     111
     112#: admin/class-review-prompt.php:61
     113msgid "Rating opens wordpress.org in a new tab. Asked once — whichever you choose, this will not appear again."
     114msgstr ""
     115
     116#: admin/class-review-prompt.php:62
     117msgid "You are not allowed to do that."
    77118msgstr ""
    78119
    … …  
    203244
    204245#: admin/views/operator-fields.php:26
    205 #: admin/views/operators.php:80
     246#: admin/views/operators.php:117
    206247msgid "Role"
    207248msgstr ""
    … …  
    212253
    213254#: admin/views/operator-fields.php:30
    214 #: admin/views/operators.php:83
     255#: admin/views/operators.php:120
    215256#: includes/class-fields.php:117
    216257msgid "Manufacturer"
    … …  
    218259
    219260#: admin/views/operator-fields.php:31
    220 #: admin/views/operators.php:84
     261#: admin/views/operators.php:121
    221262#: includes/class-fields.php:121
    222263msgid "EU responsible person"
    … …  
    248289
    249290#: admin/views/operator-fields.php:57
    250 #: admin/views/operators.php:145
     291#: admin/views/operators.php:182
    251292msgid "Country"
    252293msgstr ""
    … …  
    264305msgstr ""
    265306
    266 #: admin/views/operators.php:102
     307#: admin/views/operators.php:104
     308msgid "No operator records yet. Add the manufacturer of your products first — most catalogues need only a handful of records in total."
     309msgstr ""
     310
     311#: admin/views/operators.php:109
     312msgid "Assign to many products at once"
     313msgstr ""
     314
     315#: admin/views/operators.php:126
     316#: includes/class-operators.php:161
     317msgid "Operator record"
     318msgstr ""
     319
     320#: admin/views/operators.php:136
     321msgid "Limit to category"
     322msgstr ""
     323
     324#: admin/views/operators.php:139
    267325msgid "All products"
    268326msgstr ""
    269327
    270 #: admin/views/operators.php:116
     328#: admin/views/operators.php:153
    271329msgid "Existing values"
    272330msgstr ""
    273331
    274 #: admin/views/operators.php:120
     332#: admin/views/operators.php:157
    275333msgid "Only fill in products that have no value yet"
    276334msgstr ""
    277335
    278336#. translators: %s: the number of products handled per run.
    279 #: admin/views/operators.php:126
     337#: admin/views/operators.php:163
    280338msgid "Up to %s products per run. If there are more, run it again."
    281339msgstr ""
    282340
    283 #: admin/views/operators.php:135
     341#: admin/views/operators.php:172
    284342msgid "Assign"
    285343msgstr ""
    286344
    287 #: admin/views/operators.php:138
     345#: admin/views/operators.php:175
    288346msgid "Your records"
    289347msgstr ""
    290348
    291 #: admin/views/operators.php:141
     349#: admin/views/operators.php:178
    292350msgid "Operator records and whether each is complete"
    293351msgstr ""
    294352
    295 #: admin/views/operators.php:144
     353#: admin/views/operators.php:181
    296354msgid "Name"
    297355msgstr ""
    298356
    299 #: admin/views/operators.php:146
     357#: admin/views/operators.php:183
    300358msgid "Details"
    301359msgstr ""
    302360
    303 #: admin/views/operators.php:160
     361#: admin/views/operators.php:197
    304362msgid "not set"
    305363msgstr ""
    306364
    307 #: admin/views/operators.php:163
     365#: admin/views/operators.php:200
    308366msgid "outside the EU"
    309367msgstr ""
    310368
    311 #: admin/views/operators.php:170
     369#: admin/views/operators.php:207
    312370msgid "Complete"
    313371msgstr ""
    314372
    315 #: admin/views/operators.php:171
     373#: admin/views/operators.php:208
    316374msgid "Needs a postal address and an email or web address"
    317375msgstr ""
    318376
    319 #: admin/views/operators.php:31
     377#: admin/views/operators.php:37
    320378msgid "A manufacturer or EU responsible person is stored once here and attached to as many products as you like. Correcting an address is then one edit rather than hundreds."
    321379msgstr ""
    322380
     381#: admin/views/operators.php:53
     382msgid "Nothing needed changing: there are no products in the category you chose."
     383msgstr ""
     384
     385#: admin/views/operators.php:55
     386msgid "Nothing needed changing: every product in scope already had a value for this role, and existing values were left alone. Untick \"Only fill in products that have no value yet\" to replace them."
     387msgstr ""
     388
     389#: admin/views/operators.php:57
     390msgid "Nothing needed changing: every product in scope already uses this operator record for this role."
     391msgstr ""
     392
    323393#. translators: %s: number of products updated.
    324 #: admin/views/operators.php:40
     394#: admin/views/operators.php:68
    325395msgid "%s product updated."
    326396msgid_plural "%s products updated."
    … …  
    328398msgstr[1] ""
    329399
    330 #: admin/views/operators.php:45
    331 msgid "That was a full batch, so there may be more to do — run it again."
    332 msgstr ""
    333 
    334 #: admin/views/operators.php:51
     400#: admin/views/operators.php:73
     401msgid "There are more products in scope still to do — run it again with the same choices to continue."
     402msgstr ""
     403
     404#: admin/views/operators.php:75
     405msgid "That was every product in scope."
     406msgstr ""
     407
     408#: admin/views/operators.php:81
    335409msgid "Nothing was assigned. Choose an operator record and try again."
    336410msgstr ""
    337411
    338 #: admin/views/operators.php:57
     412#: admin/views/operators.php:94
    339413#: includes/class-operators.php:162
    340414msgid "Add operator record"
    341415msgstr ""
    342416
    343 #: admin/views/operators.php:60
     417#: admin/views/operators.php:97
    344418msgid "Manage all records"
    345 msgstr ""
    346 
    347 #: admin/views/operators.php:67
    348 msgid "No operator records yet. Add the manufacturer of your products first — most catalogues need only a handful of records in total."
    349 msgstr ""
    350 
    351 #: admin/views/operators.php:72
    352 msgid "Assign to many products at once"
    353 msgstr ""
    354 
    355 #: admin/views/operators.php:89
    356 #: includes/class-operators.php:161
    357 msgid "Operator record"
    358 msgstr ""
    359 
    360 #: admin/views/operators.php:99
    361 msgid "Limit to category"
    362419msgstr ""
    363420
    … …  
    452509
    453510#: admin/views/pro.php:63
    454 msgid "Audit a catalogue and export a dossier from a script, across as many stores as you run."
     511msgid "Audit a whole catalogue and list what is still missing from a script, across as many stores as you run — with a non-zero exit when anything is incomplete, so it can sit in a deployment check or a nightly job."
    455512msgstr ""
    456513
    … …  
    536593msgstr ""
    537594
    538 #: admin/views/settings.php:107
     595#: admin/views/settings.php:105
     596msgid "Notice wording"
     597msgstr ""
     598
     599#: admin/views/settings.php:108
     600msgid "Leave empty to use the built-in plain-language summary."
     601msgstr ""
     602
     603#: admin/views/settings.php:116
    539604msgid "What this plugin does not do"
    540605msgstr ""
    541606
    542 #: admin/views/settings.php:110
    543 msgid "It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it does not contact any external service, and it never states that your store is compliant. Nothing here is legal advice."
     607#: admin/views/settings.php:119
     608msgid "It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it sends none of your product information to any external service, and it never states that your store is compliant. Nothing here is legal advice."
    544609msgstr ""
    545610
    … …  
    573638
    574639#. translators: 1: number of days, 2: the date the obligation starts.
    575 #: admin/views/settings.php:57
    576 msgid "Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early."
    577 msgstr ""
     640#: admin/views/settings.php:60
     641msgid "Mandatory in %1$s day, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early."
     642msgid_plural "Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early."
     643msgstr[0] ""
     644msgstr[1] ""
    578645
    579646#. translators: %s: the date the obligation started.
    580 #: admin/views/settings.php:64
     647#: admin/views/settings.php:73
    581648msgid "Mandatory since %s under Commission Implementing Regulation (EU) 2025/1960."
    582649msgstr ""
    583650
    584 #: admin/views/settings.php:73
     651#: admin/views/settings.php:82
    585652msgid "Read this before relying on the default wording."
    586653msgstr ""
    587654
    588 #: admin/views/settings.php:74
     655#: admin/views/settings.php:83
    589656msgid "The European Commission publishes an official harmonised notice, with prescribed wording and artwork, in every EU language. This plugin does not reproduce it, because an approximation that looks official would be worse for you than a plain reminder. The text below is an accurate plain-language summary of the legal guarantee. Paste the official wording for your market into the box when you have it."
    590657msgstr ""
    591658
    592 #: admin/views/settings.php:80
     659#: admin/views/settings.php:89
    593660msgid "Display"
    594661msgstr ""
    595662
    596 #: admin/views/settings.php:84
     663#: admin/views/settings.php:93
    597664msgid "Show the legal guarantee notice on product pages"
    598665msgstr ""
    599666
    600 #: admin/views/settings.php:90
     667#: admin/views/settings.php:99
    601668msgid "Start showing it now, before the obligation begins"
    602669msgstr ""
    603670
    604 #: admin/views/settings.php:96
    605 msgid "Notice wording"
    606 msgstr ""
    607 
    608 #: admin/views/settings.php:99
    609 msgid "Leave empty to use the built-in plain-language summary."
    610 msgstr ""
    611 
    612 #: includes/class-bulk.php:34
     671#: includes/class-bulk.php:42
    613672msgid "You are not allowed to edit product compliance data."
    614673msgstr ""
    … …  
    622681msgstr ""
    623682
    624 #: includes/class-evaluator.php:129
     683#: includes/class-evaluator.php:145
    625684msgid "Product image"
    626685msgstr ""
    … …  
    726785msgstr ""
    727786
    728 #: includes/class-operators.php:331
     787#: includes/class-operators.php:354
    729788msgid "Operator details"
    730789msgstr ""
  • lodestone-product-compliance/tags/1.0.2/lodestone-product-compliance.php

    r3705566 r3713160  
    22/**
    33 * Plugin Name:       Lodestone Product Compliance – GPSR for WooCommerce
     4 * Plugin URI:        https://madebylodestone.com/product-compliance
    45 * Description:       GPSR product safety information for WooCommerce. Records manufacturer and EU responsible person details, shows them in the product listing, adds the EU legal guarantee notice, and audits your whole catalogue for what is still missing.
    5  * Version:           1.0.1
     6 * Version:           1.0.2
    67 * Requires at least: 6.4
    78 * Requires PHP:      7.4
    … …  
    1920defined( 'ABSPATH' ) || exit;
    2021
    21 define( 'LODECOMPLY_VERSION', '1.0.0' );
     22define( 'LODECOMPLY_VERSION', '1.0.2' );
    2223define( 'LODECOMPLY_FILE', __FILE__ );
    2324
  • lodestone-product-compliance/tags/1.0.2/readme.txt

    r3705566 r3713160  
    66Requires PHP: 7.4
    77Requires Plugins: woocommerce
    8 Stable tag: 1.0.1
     8Stable tag: 1.0.2
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3333* Stores manufacturers and EU responsible persons as **reusable records**. One importer behind 300
    3434  products is one record, so correcting an address is one edit and not three hundred.
    35 * Assigns an operator record to a whole product category in one action.
     35* Assigns an operator record to a whole product category, or the whole catalogue, in bulk — 200
     36  products per run, and running it again carries on where the last run stopped.
    3637* Shows the information **in the offer** — a block under the add-to-cart area and an optional
    3738  product tab — and lets you switch either off if your theme already prints it.
    … …  
    6465a printable per-product compliance record, a CSV export, coverage over time, per-language warning
    6566coverage for multilingual stores, the GARAN durability guarantee, and WP-CLI.
     67
     68= One question, asked once =
     69
     70We ask you for exactly one thing, once. After a bulk assignment fills five or more products in one
     71step, the screen you land on asks whether you would rate this plugin on WordPress.org. It is not an
     72admin notice, it is only shown to people who can manage plugins, and whichever button you press —
     73including "No thanks" — it never appears again.
    6674
    6775== Frequently Asked Questions ==
    … …  
    108116= Does it send my data anywhere? =
    109117
    110 No. There are no outbound requests at all. If you later opt in to the Pro version, Freemius asks
    111 for its own consent separately, on its own screen, and you can decline.
     118No. The plugin itself makes no outbound requests, and nothing about your products or customers
     119leaves your site. Licensing is handled by the Freemius SDK, which sends nothing on its own unless
     120you opt in. Two things you do yourself do reach Freemius: opening the upgrade page loads its
     121prices and checkout, and the optional feedback form shown when you deactivate is sent if you fill
     122it in.
    112123
    113124= Will it slow down my product pages? =
    … …  
    118129= I have 5,000 products. Will the audit handle that? =
    119130
    120 Yes. Products are checked in batches as you visit the dashboard rather than all at once, so a large
    121 catalogue fills in over a few visits instead of timing out. The screen tells you how many have been
     131Yes. There is no limit on the number of products. Every product is checked the moment it is saved in
     132wp-admin, and the ones nobody has saved there since you installed the plugin are checked 100 at a time each time you
     133open the dashboard, rather than all at once, so a large catalogue fills in over repeated visits
     134instead of timing out — 5,000 products take about fifty. The screen tells you how many have been
    122135checked so far.
    123136
    … …  
    131144== Changelog ==
    132145
     146= 1.0.2 =
     147* Fixed: the catalogue audit never got past the first 100 products on a larger shop. Every
     148  product is now reached, a batch per visit to the screen.
     149* Fixed: assigning an operator record without "only fill in products with no value yet" rewrote
     150  the same 200 products on every run. Each run now carries on where the last one stopped, and the
     151  screen says whether there is more to do.
     152* Fixed: deleted and binned products stayed in the counts and the list of products to fix, and a
     153  binned operator record's name and address kept appearing on product pages.
     154* Fixed: the Settings screen would have read "Mandatory in 1 days" the day before the guarantee
     155  notice becomes mandatory.
     156* Fixed: a bulk assignment that changed nothing was shown as a success reading "0 products updated".
     157* Fixed: a shop manager who dismissed the one admin notice saw it come back on the next page.
     158* Pro: CSV exports no longer write a cell a spreadsheet would run as a formula, and write product
     159  titles as text rather than HTML entities. The dossier refuses an id that is not a product.
     160* A "Questions?" line at the foot of each screen links the support forum.
     161* After a bulk assignment fills a catalogue, the screen asks once whether you would rate the plugin.
     162* The FAQ and Settings now say exactly when the Freemius SDK contacts Freemius, instead of "no
     163  outbound requests at all".
     164
    133165= 1.0.1 =
    134166* Listed as "Lodestone Product Compliance - GPSR for WooCommerce", so it can be
  • lodestone-product-compliance/tags/1.0.2/vendor/composer/autoload_classmap.php

    r3705566 r3713160  
    88return array(
    99    'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
     10    'Portfolio\\Core\\Csv' => $vendorDir . '/portfolio/core/src/Csv.php',
    1011    'Portfolio\\Core\\Freemius' => $vendorDir . '/portfolio/core/src/Freemius.php',
    1112    'Portfolio\\Core\\Notices' => $vendorDir . '/portfolio/core/src/Notices.php',
    1213    'Portfolio\\Core\\Plugin' => $vendorDir . '/portfolio/core/src/Plugin.php',
    1314    'Portfolio\\Core\\Pricing_A11y' => $vendorDir . '/portfolio/core/src/Pricing_A11y.php',
     15    'Portfolio\\Core\\Review_Prompt' => $vendorDir . '/portfolio/core/src/Review_Prompt.php',
     16    'Portfolio\\Core\\Support_Link' => $vendorDir . '/portfolio/core/src/Support_Link.php',
    1417);
  • lodestone-product-compliance/tags/1.0.2/vendor/composer/autoload_static.php

    r3705566 r3713160  
    2727    public static $classMap = array (
    2828        'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
     29        'Portfolio\\Core\\Csv' => __DIR__ . '/..' . '/portfolio/core/src/Csv.php',
    2930        'Portfolio\\Core\\Freemius' => __DIR__ . '/..' . '/portfolio/core/src/Freemius.php',
    3031        'Portfolio\\Core\\Notices' => __DIR__ . '/..' . '/portfolio/core/src/Notices.php',
    3132        'Portfolio\\Core\\Plugin' => __DIR__ . '/..' . '/portfolio/core/src/Plugin.php',
    3233        'Portfolio\\Core\\Pricing_A11y' => __DIR__ . '/..' . '/portfolio/core/src/Pricing_A11y.php',
     34        'Portfolio\\Core\\Review_Prompt' => __DIR__ . '/..' . '/portfolio/core/src/Review_Prompt.php',
     35        'Portfolio\\Core\\Support_Link' => __DIR__ . '/..' . '/portfolio/core/src/Support_Link.php',
    3336    );
    3437
  • lodestone-product-compliance/tags/1.0.2/vendor/composer/installed.php

    r3705566 r3713160  
    22    'root' => array(
    33        'name' => 'portfolio/lodestone-product-compliance',
    4         'pretty_version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    5         'version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    6         'reference' => 'fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
     4        'pretty_version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     5        'version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     6        'reference' => 'be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
    77        'type' => 'project',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    3030        ),
    3131        'portfolio/lodestone-product-compliance' => array(
    32             'pretty_version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    33             'version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    34             'reference' => 'fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
     32            'pretty_version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     33            'version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     34            'reference' => 'be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
    3535            'type' => 'project',
    3636            'install_path' => __DIR__ . '/../../',
  • lodestone-product-compliance/tags/1.0.2/vendor/portfolio/core/src/Freemius.php

    r3705566 r3713160  
    4242                }
    4343
    44                 return fs_dynamic_init( [
     44                $fs = fs_dynamic_init( [
    4545                        'id'              => $args['id'],
    4646                        'slug'            => $args['slug'],
    … …  
    101101                        'is_org_compliant' => $args['is_org_compliant'] ?? true,
    102102                ] );
     103
     104                /*
     105                 * The SDK carries two admin notices that no line of this repository adds, and the
     106                 * portfolio's budget is one notice, ever, across all four plugins:
     107                 *
     108                 *   _add_trial_notice()             gated on 'show_trial', and it recurs;
     109                 *   _add_affiliate_program_notice() gated on 'show_affiliate_program_notice'.
     110                 *
     111                 * Both default to true and both are dormant only because the matching setting is off in
     112                 * the Freemius *dashboard*. Switching on a trial or the affiliate programme there -- one
     113                 * click, no commit, nothing to review -- would put a third party's notice into every
     114                 * plugin at once. So both are answered here, for every product, before either is ever
     115                 * switched on. A trial can still be offered; it is offered from the Pro tab, which is one
     116                 * of the two places upsell is allowed. tests/test-core-freemius-notices.php holds this,
     117                 * and holds the SDK to these two filter names.
     118                 *
     119                 * $fs->add_filter() rather than add_filter(): the SDK prefixes every hook with its
     120                 * product's slug, so only the instance's own method reaches the right one.
     121                 */
     122                if ( is_object( $fs ) && method_exists( $fs, 'add_filter' ) ) {
     123                        $fs->add_filter( 'show_trial', '__return_false' );
     124                        $fs->add_filter( 'show_affiliate_program_notice', '__return_false' );
     125
     126                        /*
     127                         * And one the SDK does add, to every paying customer: installing Pro over the free
     128                         * plugin raises a sticky "W00t! Premium plugin version was successfully activated."
     129                         * -- one per plugin, so a customer with three stacked three high on our own screen, in
     130                         * markup axe fails for colour contrast. Seen in a browser, 24 Sep 2026. The Pro tabs
     131                         * appearing is the confirmation. Only that notice: licence and activation problems are
     132                         * things a customer needs to read, and still pass through.
     133                         */
     134                        $fs->add_filter( 'show_admin_notice', [ self::class, 'filter_sdk_notice' ], 10, 2 );
     135                }
     136
     137                return $fs;
     138        }
     139
     140        /**
     141         * The SDK's show_admin_notice filter: hides the premium-activated celebration, nothing else.
     142         *
     143         * @param mixed $show Whether the SDK means to show it.
     144         * @param mixed $msg  The notice: id, message, title, type.
     145         */
     146        public static function filter_sdk_notice( $show, $msg ) {
     147                if ( is_array( $msg ) && 'premium_activated' === ( $msg['id'] ?? '' ) ) {
     148                        return false;
     149                }
     150
     151                return $show;
    103152        }
    104153}
  • lodestone-product-compliance/tags/1.0.2/vendor/portfolio/core/src/Notices.php

    r3705566 r3713160  
    3939
    4040        private string $slug;
     41        private string $cap;
    4142        private array  $queued = [];
    4243
    … …  
    4748         *                       own screens. Optional only so that a caller written before this
    4849         *                       existed still works.
     50         * @param string $cap    The capability the plugin shows its notice at, which is therefore
     51         *                       the one that may dismiss it. Until 24 Sep 2026 dismissal was fixed
     52         *                       at manage_options while the notices were shown to editors, authors
     53         *                       and shop managers, so for them the X did nothing but return a 403
     54         *                       and the notice came back on the next page.
    4955         */
    50         public function __construct( string $slug, string $screen = '' ) {
     56        public function __construct( string $slug, string $screen = '', string $cap = 'manage_options' ) {
    5157                $this->slug = $slug;
     58                $this->cap  = '' !== $cap ? $cap : 'manage_options';
    5259
    5360                // Registered, not stored: the only consumer is the static check below, which has to
    … …  
    160167        public function ajax_dismiss(): void {
    161168                check_ajax_referer( $this->slug . '_dismiss', 'nonce' );
    162                 if ( ! current_user_can( 'manage_options' ) ) {
     169                if ( ! current_user_can( $this->cap ) ) {
    163170                        wp_send_json_error( null, 403 );
    164171                }
  • lodestone-product-compliance/trunk/admin/class-admin.php

    r3705566 r3713160  
    4242                add_filter( 'posts_where', [ $this, 'warm_where' ], 10, 2 );
    4343
     44                // The rating prompt's handler. Its panel is printed by the operators view.
     45                ( new Review_Prompt() )->hooks();
     46
    4447                $this->maybe_add_notice();
    4548        }
    … …  
    128131                        // PREFIX, not SLUG: Notices derives its option names from this, and Uninstaller
    129132                        // sweeps options by the lodecomply_ prefix. SLUG here would orphan the dismissal flag.
    130                         $this->notices = new \Portfolio\Core\Notices( Plugin::PREFIX, Plugin::MENU_SLUG );
     133                        $this->notices = new \Portfolio\Core\Notices( Plugin::PREFIX, Plugin::MENU_SLUG, 'manage_woocommerce' );
    131134                }
    132135
    … …  
    205208                if ( 'dashboard' === $view ) {
    206209                        self::warm_index();
     210                } elseif ( 'languages' === $view ) {
     211                        // The other screen that lists index rows as links. See Status_Store::prune().
     212                        Status_Store::prune();
    207213                }
    208214
    … …  
    212218
    213219                require $file;
     220
     221                /*
     222                 * Where to ask, at the foot of every tab. Not a notice: a line of text inside this page.
     223                 * Resolved forum threads help the listing get found, and nothing here said where to ask.
     224                 */
     225                \Portfolio\Core\Support_Link::render(
     226                        [
     227                                'slug'     => Plugin::SLUG,
     228                                'css'      => 'lodecomply',
     229                                'question' => __( 'Questions?', 'lodestone-product-compliance' ),
     230                                'forum'    => __( 'Ask in the WordPress.org support forum', 'lodestone-product-compliance' ),
     231                                'paying'   => lodecomply_fs()->can_use_premium_code(),
     232                                'pro'      => __( 'Pro customers can also write to', 'lodestone-product-compliance' ),
     233                        ]
     234                );
    214235
    215236                echo '</div>';
    … …  
    226247         */
    227248        private static function warm_index(): void {
     249                /*
     250                 * Rows for products that no longer exist, or are in the bin, come out first. The lifecycle
     251                 * hooks in Index keep new ones from appearing, but 1.0.0 and 1.0.1 had none, so a store
     252                 * that deleted products before upgrading still has their rows -- counted in every figure
     253                 * on this screen and listed under "Products to fix first" as a link with no name.
     254                 */
     255                Status_Store::prune();
     256
    228257                $cursor = (int) get_option( self::OPTION_WARM_CURSOR, 0 );
    229258
    … …  
    237266                 * revisiting products that were indexed and later changed -- which does not matter,
    238267                 * because those are re-evaluated on save by Product_Data.
     268                 *
     269                 * suppress_filters => false is what makes the cursor work at all. get_posts() defaults it
     270                 * to true, and with it true WP_Query never applies posts_where, so warm_where() never ran
     271                 * and every visit re-read the same first WARM_BATCH products: on any catalogue past 100,
     272                 * product 101 onward was never checked by this screen. Green Claims shipped the identical
     273                 * bug in its scanner and fixed it on 10 Sep 2026; the fix did not reach this plugin until
     274                 * 24 Sep.
    239275                 */
    240276                $product_ids = get_posts(
    … …  
    249285                                'update_post_meta_cache' => false,
    250286                                'update_post_term_cache' => false,
    251                                 'lodecomply_min_id'             => $cursor,
     287                                'suppress_filters'       => false,
     288                                'lodecomply_min_id'      => $cursor,
    252289                        ]
    253290                );
    … …  
    266303
    267304                update_option( self::OPTION_WARM_CURSOR, (int) max( $product_ids ), false );
     305        }
     306
     307        /**
     308         * The text of a link to a product, never empty.
     309         *
     310         * WooCommerce lets a product be saved with no title, and a link whose text is empty has no
     311         * accessible name: a screen reader announces "link" and nothing else. This portfolio sells an
     312         * accessibility auditor, and its own screens are held to that. The same empty link is what a
     313         * row for a deleted product rendered as before Index removed them.
     314         */
     315        public static function product_label( int $product_id ): string {
     316                $title = trim( wp_strip_all_tags( (string) get_the_title( $product_id ) ) );
     317
     318                if ( '' !== $title ) {
     319                        return $title;
     320                }
     321
     322                /* translators: %d: product ID. */
     323                return sprintf( __( 'Product #%d (no title)', 'lodestone-product-compliance' ), $product_id );
    268324        }
    269325
  • lodestone-product-compliance/trunk/admin/views/dashboard.php

    r3705566 r3713160  
    169169                                        <th scope="row">
    170170                                                <a href="<?php echo esc_url( (string) get_edit_post_link( $lodecomply_product_id ) ); ?>">
    171                                                         <?php echo esc_html( get_the_title( $lodecomply_product_id ) ); ?>
     171                                                        <?php echo esc_html( Admin::product_label( $lodecomply_product_id ) ); ?>
    172172                                                </a>
    173173                                        </th>
  • lodestone-product-compliance/trunk/admin/views/operators.php

    r3705566 r3713160  
    1616// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
    1717$lodecomply_assigned = isset( $_GET['lodecomply_assigned'] ) ? absint( wp_unslash( $_GET['lodecomply_assigned'] ) ) : 0;
     18// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
     19$lodecomply_more = isset( $_GET['lodecomply_more'] ) && '1' === sanitize_key( wp_unslash( $_GET['lodecomply_more'] ) );
     20// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
     21$lodecomply_only_empty = ! isset( $_GET['lodecomply_only_empty'] ) || '0' !== sanitize_key( wp_unslash( $_GET['lodecomply_only_empty'] ) );
     22// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result.
     23$lodecomply_scope_empty = isset( $_GET['lodecomply_scope'] ) && 'none' === sanitize_key( wp_unslash( $_GET['lodecomply_scope'] ) );
    1824
    1925$lodecomply_categories = get_terms(
    … …  
    3238</p>
    3339
    34 <?php if ( 'assigned' === $lodecomply_notice ) : ?>
     40<?php
     41/*
     42 * A run that changed nothing is not a success, and must not look like one: it used to print
     43 * "0 products updated." in the same green notice as a run that filled a catalogue. It gets an
     44 * information notice that says why nothing changed. A run that did change something says whether
     45 * there is more -- Bulk knows, so the screen no longer guesses from a full batch.
     46 */
     47?>
     48<?php if ( 'assigned' === $lodecomply_notice && 0 === $lodecomply_assigned ) : ?>
     49        <div class="notice notice-info inline">
     50                <p>
     51                        <?php
     52                        if ( $lodecomply_scope_empty ) {
     53                                esc_html_e( 'Nothing needed changing: there are no products in the category you chose.', 'lodestone-product-compliance' );
     54                        } elseif ( $lodecomply_only_empty ) {
     55                                esc_html_e( 'Nothing needed changing: every product in scope already had a value for this role, and existing values were left alone. Untick "Only fill in products that have no value yet" to replace them.', 'lodestone-product-compliance' );
     56                        } else {
     57                                esc_html_e( 'Nothing needed changing: every product in scope already uses this operator record for this role.', 'lodestone-product-compliance' );
     58                        }
     59                        ?>
     60                </p>
     61        </div>
     62<?php elseif ( 'assigned' === $lodecomply_notice ) : ?>
    3563        <div class="notice notice-success inline">
    3664                <p>
    … …  
    4270                        );
    4371                        ?>
    44                         <?php if ( Bulk::BATCH === $lodecomply_assigned ) : ?>
    45                                 <?php esc_html_e( 'That was a full batch, so there may be more to do — run it again.', 'lodestone-product-compliance' ); ?>
     72                        <?php if ( $lodecomply_more ) : ?>
     73                                <?php esc_html_e( 'There are more products in scope still to do — run it again with the same choices to continue.', 'lodestone-product-compliance' ); ?>
     74                        <?php else : ?>
     75                                <?php esc_html_e( 'That was every product in scope.', 'lodestone-product-compliance' ); ?>
    4676                        <?php endif; ?>
    4777                </p>
    … …  
    5282        </div>
    5383<?php endif; ?>
     84
     85<?php
     86// The rating prompt, right after a bulk assignment that filled a catalogue. Review_Prompt holds every rule.
     87if ( Review_Prompt::should_show() ) {
     88        Review_Prompt::render();
     89}
     90?>
    5491
    5592<p>
  • lodestone-product-compliance/trunk/admin/views/pro.php

    r3705566 r3713160  
    6161        <li>
    6262                <strong><?php esc_html_e( 'WP-CLI.', 'lodestone-product-compliance' ); ?></strong>
    63                 <?php esc_html_e( 'Audit a catalogue and export a dossier from a script, across as many stores as you run.', 'lodestone-product-compliance' ); ?>
     63                <?php esc_html_e( 'Audit a whole catalogue and list what is still missing from a script, across as many stores as you run — with a non-zero exit when anything is incomplete, so it can sit in a deployment check or a nightly job.', 'lodestone-product-compliance' ); ?>
    6464        </li>
    6565</ul>
  • lodestone-product-compliance/trunk/admin/views/settings.php

    r3705566 r3713160  
    5353                <?php
    5454                if ( $lodecomply_days > 0 ) {
     55                        // _n(): on the day before the obligation this read "Mandatory in 1 days". The dashboard's
     56                        // sentence about the same date already went through _n(); this one never did.
    5557                        printf(
    56                                 /* translators: 1: number of days, 2: the date the obligation starts. */
    57                                 esc_html__( 'Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early.', 'lodestone-product-compliance' ),
     58                                esc_html(
     59                                        /* translators: 1: number of days, 2: the date the obligation starts. */
     60                                        _n(
     61                                                'Mandatory in %1$s day, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early.',
     62                                                'Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early.',
     63                                                $lodecomply_days,
     64                                                'lodestone-product-compliance'
     65                                        )
     66                                ),
    5867                                esc_html( number_format_i18n( $lodecomply_days ) ),
    5968                                esc_html( date_i18n( (string) get_option( 'date_format' ), (int) strtotime( LODECOMPLY_GUARANTEE_NOTICE_DATE ) ) )
    … …  
    108117
    109118<p class="lodecomply-honesty">
    110         <?php esc_html_e( 'It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it does not contact any external service, and it never states that your store is compliant. Nothing here is legal advice.', 'lodestone-product-compliance' ); ?>
     119        <?php esc_html_e( 'It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it sends none of your product information to any external service, and it never states that your store is compliant. Nothing here is legal advice.', 'lodestone-product-compliance' ); ?>
    111120</p>
  • lodestone-product-compliance/trunk/assets/css/admin.css

    r3705566 r3713160  
    117117        color: #8a2424;
    118118}
     119
     120/* Where to ask a question, at the foot of every tab. Quiet on purpose: it is not a notice. */
     121.lodecomply-support {
     122        margin-top: 2em;
     123        color: #646970;
     124}
     125
     126/* The rating prompt, after a bulk assignment. Green rule: it follows a success and is not an upsell. */
     127.lodecomply-review-prompt {
     128        max-width: 46em;
     129        margin: 1.5em 0;
     130        padding: 16px 20px;
     131        border: 1px solid #c3c4c7;
     132        border-left: 4px solid #00a32a;
     133        border-radius: 4px;
     134        background: #fff;
     135}
     136
     137.lodecomply-review-prompt h2 {
     138        margin-top: 0;
     139        font-size: 15px;
     140}
     141
     142.lodecomply-review-prompt .button + .button {
     143        margin-left: 8px;
     144}
     145
     146.lodecomply-review-prompt-footnote {
     147        margin-bottom: 0;
     148        color: #646970;
     149        font-size: 12px;
     150}
  • lodestone-product-compliance/trunk/includes/class-autoloader.php

    r3705566 r3713160  
    5656        }
    5757
     58        /**
     59         * Whether this build carries premium code.
     60         *
     61         * This is what `is_premium` in the Freemius bootstrap is derived from, and it is derived
     62         * rather than declared on purpose. It used to read a constant -- A11YSCAN_IS_PREMIUM and its
     63         * three siblings -- that **nothing in this repository ever defined**, so `is_premium` was
     64         * false in every build of every plugin, paid ones included. The SDK therefore treated a
     65         * customer's paid package as a free install: `can_use_premium_code()` returned false, every
     66         * Pro tab stayed gated, and no licence-activation screen was ever offered, because the SDK
     67         * only offers one to a build that says it is premium. Found 21 Sep 2026 by buying a licence
     68         * and running the package Freemius delivered -- the first time that had ever been done.
     69         *
     70         * A build is premium exactly when it contains premium-marked files, which is already the
     71         * whole stripping mechanism, so this cannot drift from the truth the way a constant did.
     72         * It reads the filesystem once per request and the result is cached for the process.
     73         *
     74         * Note the marker is assembled in PREMIUM_SUFFIX rather than written out; see its docblock.
     75         */
     76        public static function is_premium_build(): bool {
     77                static $premium = null;
     78
     79                if ( null !== $premium ) {
     80                        return $premium;
     81                }
     82
     83                $premium = false;
     84
     85                if ( '' !== self::$dir ) {
     86                        foreach ( self::ROOTS as $root ) {
     87                                $found = glob( self::$dir . '/' . $root . '/*' . self::PREMIUM_SUFFIX . '.php' );
     88
     89                                if ( ! empty( $found ) ) {
     90                                        $premium = true;
     91
     92                                        break;
     93                                }
     94                        }
     95                }
     96
     97                return $premium;
     98        }
     99
    58100        public static function load( string $class_name ): void {
    59101                if ( 0 !== strpos( $class_name, self::PREFIX ) ) {
  • lodestone-product-compliance/trunk/includes/class-bulk.php

    r3705566 r3713160  
    1111 * be run again; that is a slower interaction than a progress bar but it cannot half-finish a
    1212 * catalogue and leave no record of where it stopped.
     13 *
     14 * "Run it again" only works if the next run starts where this one stopped. With "only empty"
     15 * ticked that was always true, because a filled product stops matching. With it unticked it was
     16 * not: the query matched every product in scope, newest first, so every run rewrote the same
     17 * newest 200 and a catalogue past 200 could never be finished -- while each run reported "200
     18 * products updated". Both modes now select only products that do not already hold this record,
     19 * lowest id first, so each run's work removes itself from the next run's query, and the count is
     20 * of products that actually changed.
    1321 *
    1422 * @package LodeComply
    … …  
    4250                // phpcs:enable WordPress.Security.NonceVerification.Missing
    4351
    44                 $assigned = 0;
    45                 $notice   = 'error';
     52                $args = [
     53                        'view'              => 'operators',
     54                        'lodecomply_notice' => 'error',
     55                ];
    4656
    4757                /*
    … …  
    5767                        && Operators::POST_TYPE === get_post_type( $operator_id )
    5868                ) {
    59                         $assigned = self::assign( $field, $operator_id, $term_id, $only_empty );
    60                         $notice   = 'assigned';
    61                 }
    62 
    63                 wp_safe_redirect(
    64                         Admin::url(
    65                                 [
    66                                         'view'         => 'operators',
    67                                         'lodecomply_notice'   => $notice,
    68                                         'lodecomply_assigned' => $assigned,
    69                                 ]
    70                         )
    71                 );
     69                        $result = self::assign( $field, $operator_id, $term_id, $only_empty );
     70
     71                        $args['lodecomply_notice']     = 'assigned';
     72                        $args['lodecomply_assigned']   = $result['changed'];
     73                        $args['lodecomply_only_empty'] = $only_empty ? '1' : '0';
     74
     75                        if ( $result['more'] ) {
     76                                $args['lodecomply_more'] = '1';
     77                        }
     78
     79                        if ( ! $result['in_scope'] ) {
     80                                $args['lodecomply_scope'] = 'none';
     81                        }
     82
     83                        // One edit that filled a catalogue is the moment this plugin earns a rating request.
     84                        // Counted in products that changed: rewriting a value to itself fills nothing.
     85                        Review_Prompt::after_assign( $result['changed'], get_current_user_id() );
     86                }
     87
     88                wp_safe_redirect( Admin::url( $args ) );
    7289
    7390                exit;
    … …  
    7693        /**
    7794         * Assigns an operator to products, optionally limited to one product category.
     95         *
     96         * Only products that do not already hold this record are selected -- with $only_empty, only
     97         * those holding no value at all -- lowest id first, at most BATCH per run. Each run therefore
     98         * picks up where the last one stopped, whichever mode it is in.
    7899         *
    79100         * @param string $field       'manufacturer' or 'responsible_person'.
    … …  
    81102         * @param int    $term_id     Product category to limit to, or 0 for the whole catalogue.
    82103         * @param bool   $only_empty  Skip products that already have a value for this field.
    83          * @return int Number of products changed.
     104         * @return array{changed: int, more: bool, in_scope: bool} Products changed; whether products
     105         *         still needing it remain after this batch; whether the scope holds any product at all.
    84106         */
    85         public static function assign( string $field, int $operator_id, int $term_id = 0, bool $only_empty = true ): int {
     107        public static function assign( string $field, int $operator_id, int $term_id = 0, bool $only_empty = true ): array {
    86108                $meta_key = Fields::meta_key( $field );
    87 
    88                 $args = [
    89                         'post_type'              => 'product',
    90                         'post_status'            => 'any',
    91                         'posts_per_page'         => self::BATCH,
    92                         'fields'                 => 'ids',
    93                         'no_found_rows'          => true,
    94                         'update_post_meta_cache' => false,
    95                         'update_post_term_cache' => false,
    96                 ];
     109                $value    = (string) $operator_id;
     110                $base     = self::scope_args( $term_id );
     111                $args     = $base;
     112
     113                $args['posts_per_page'] = self::BATCH + 1;
    97114
    98115                if ( $only_empty ) {
    … …  
    117134                                ],
    118135                        ];
    119                 }
     136                } else {
     137                        // Everything that does not already point at this record, set or not.
     138                        $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin-only, batched, and the alternative is a full catalogue scan in PHP.
     139                                'relation' => 'OR',
     140                                [
     141                                        'key'     => $meta_key,
     142                                        'compare' => 'NOT EXISTS',
     143                                ],
     144                                [
     145                                        'key'     => $meta_key,
     146                                        'value'   => $value,
     147                                        'compare' => '!=',
     148                                ],
     149                        ];
     150                }
     151
     152                $product_ids = array_map( 'intval', get_posts( $args ) );
     153                $more        = count( $product_ids ) > self::BATCH;
     154                $changed     = 0;
     155
     156                foreach ( array_slice( $product_ids, 0, self::BATCH ) as $product_id ) {
     157                        // The query already excludes these; checked again because this is what gets counted.
     158                        if ( $value === (string) get_post_meta( $product_id, $meta_key, true ) ) {
     159                                continue;
     160                        }
     161
     162                        update_post_meta( $product_id, $meta_key, $value );
     163                        Evaluator::evaluate( $product_id );
     164
     165                        ++$changed;
     166                }
     167
     168                /*
     169                 * A run that changed nothing is one of two different things, and the screen says which:
     170                 * every product in scope already had what was asked for, or there is no product in scope
     171                 * at all -- an empty category, say. Only worth a query when nothing changed.
     172                 */
     173                $in_scope = true;
     174
     175                if ( 0 === $changed && ! $more ) {
     176                        $base['posts_per_page'] = 1;
     177                        $in_scope               = (bool) get_posts( $base );
     178                }
     179
     180                return [
     181                        'changed'  => $changed,
     182                        'more'     => $more,
     183                        'in_scope' => $in_scope,
     184                ];
     185        }
     186
     187        /**
     188         * The products a run may touch, before any condition on their current value.
     189         *
     190         * @param int $term_id Product category to limit to, or 0 for the whole catalogue.
     191         * @return array<string, mixed>
     192         */
     193        private static function scope_args( int $term_id ): array {
     194                $args = [
     195                        'post_type'              => 'product',
     196                        'post_status'            => 'any',
     197                        'posts_per_page'         => self::BATCH,
     198                        'orderby'                => 'ID',
     199                        'order'                  => 'ASC',
     200                        'fields'                 => 'ids',
     201                        'no_found_rows'          => true,
     202                        'update_post_meta_cache' => false,
     203                        'update_post_term_cache' => false,
     204                ];
    120205
    121206                if ( $term_id > 0 ) {
    … …  
    129214                }
    130215
    131                 $product_ids = get_posts( $args );
    132                 $assigned    = 0;
    133 
    134                 foreach ( $product_ids as $product_id ) {
    135                         $product_id = (int) $product_id;
    136 
    137                         update_post_meta( $product_id, $meta_key, (string) $operator_id );
    138                         Evaluator::evaluate( $product_id );
    139 
    140                         ++$assigned;
    141                 }
    142 
    143                 return $assigned;
     216                return $args;
    144217        }
    145218}
  • lodestone-product-compliance/trunk/includes/class-evaluator.php

    r3705566 r3713160  
    2626        public const STATE_EXEMPT     = 'exempt';
    2727
     28        /** Products re-evaluated synchronously when an operator record changes. */
     29        public const REEVALUATE_LIMIT = 500;
     30
    2831        /**
    2932         * Evaluates one product and writes the result to the index.
    … …  
    3437                $payload = Fields::payload( $product_id );
    3538                $result  = self::evaluate_payload( $payload, has_post_thumbnail( $product_id ) );
     39
     40                /*
     41                 * Only a product the catalogue audit covers is written to the index. Every caller hands
     42                 * this an id it believes is a live product, but not every caller is right: the products
     43                 * list's Bin view asks status() about binned products, which used to evaluate them straight
     44                 * back into the counts they had just left. Anything else gets its answer computed and
     45                 * returned, and any row it had is removed. See Status_Store::prune() for what counts.
     46                 */
     47                if ( ! self::is_indexable( $product_id ) ) {
     48                        Status_Store::delete_status( $product_id );
     49
     50                        return $result;
     51                }
    3652
    3753                Status_Store::write_status( $product_id, $result['state'], $result['missing'] );
    … …  
    133149
    134150        /**
     151         * Whether a product belongs in the index: a product, not in the bin, not an auto-draft.
     152         *
     153         * The same set the dashboard warmer walks (WP_Query's 'any') and the dashboard's catalogue
     154         * count adds up. A binned product is on its way out of the shop, not a listing anyone is
     155         * offering; if it is restored, Index re-evaluates it.
     156         */
     157        public static function is_indexable( int $product_id ): bool {
     158                $post = $product_id > 0 ? get_post( $product_id ) : null;
     159
     160                if ( ! $post instanceof \WP_Post || 'product' !== $post->post_type ) {
     161                        return false;
     162                }
     163
     164                return ! in_array( $post->post_status, [ 'trash', 'auto-draft' ], true );
     165        }
     166
     167        /**
    135168         * Re-evaluates every product currently pointing at an operator record.
    136169         *
    137          * Called when an operator is edited, because one edit there can change the answer for
    138          * hundreds of products. Bounded and batched: a catalogue where a single manufacturer covers
    139          * ten thousand products must not turn one save into a timeout, so anything past the limit is
    140          * simply dropped from the index and recomputed lazily the next time it is read.
    141          */
    142         public static function invalidate_for_operator( int $operator_id ): void {
     170         * Called when an operator is edited, binned, restored or deleted, because one change there can
     171         * change the answer for hundreds of products. Bounded: a catalogue where a single manufacturer
     172         * covers ten thousand products must not turn one save into a timeout. So the first $limit
     173         * products are re-evaluated now, and every product past the limit is DROPPED from the index --
     174         * which is what this docblock always claimed and, until 24 Sep 2026, the code did not do: it
     175         * re-evaluated 500 and left the rest reporting the answer from before the edit.
     176         *
     177         * A dropped product is recomputed the next time anything reads it (Evaluator::status(), which
     178         * the products list calls) and by the dashboard warmer's next pass. Until then it is absent
     179         * from the counts rather than present with an answer that is no longer true, and the dashboard
     180         * already says how many products have been checked so far.
     181         *
     182         * @param int $operator_id The record that changed.
     183         * @param int $limit       How many to re-evaluate now; the rest are dropped.
     184         */
     185        public static function invalidate_for_operator( int $operator_id, int $limit = self::REEVALUATE_LIMIT ): void {
    143186                if ( $operator_id <= 0 ) {
    144187                        return;
    145188                }
    146189
     190                // Ids only, so even a very large set is a small array; the cost is in evaluating them.
    147191                $product_ids = get_posts(
    148192                        [
    149193                                'post_type'              => 'product',
    150194                                'post_status'            => 'any',
    151                                 'posts_per_page'         => 500,
     195                                'posts_per_page'         => -1,
     196                                'orderby'                => 'ID',
     197                                'order'                  => 'ASC',
    152198                                'fields'                 => 'ids',
    153199                                'no_found_rows'          => true,
    … …  
    168214                );
    169215
    170                 foreach ( $product_ids as $product_id ) {
     216                $limit = max( 0, $limit );
     217
     218                foreach ( array_slice( $product_ids, 0, $limit ) as $product_id ) {
    171219                        self::evaluate( (int) $product_id );
    172220                }
     221
     222                Status_Store::delete_statuses( array_slice( $product_ids, $limit ) );
    173223        }
    174224
  • lodestone-product-compliance/trunk/includes/class-operators.php

    r3705566 r3713160  
    249249
    250250        /**
    251          * The stored values of one operator record.
    252          *
    253          * Returns an empty array for 0 or for a record that no longer exists, which is what lets
    254          * Evaluator treat "points at a deleted operator" the same as "not set" rather than fataling.
     251         * The values of one operator record, as the shop may use them.
     252         *
     253         * Returns an empty array for 0, for a record that no longer exists, and for one that is not
     254         * published -- in the bin, a draft, anything else. That is what lets Evaluator treat "points
     255         * at a deleted or binned operator" the same as "not set" rather than fataling, and what stops
     256         * the storefront printing a record the merchant has thrown away. Until 24 Sep 2026 this
     257         * checked existence only, so a binned record's name and address kept appearing on every
     258         * product page that pointed at it. Published is the same line options() draws: it is the only
     259         * status the product screen and bulk assign offer.
     260         *
     261         * The record's own edit screen reads stored_values() instead, because a draft or binned
     262         * record's form must still show what it holds -- an empty form would save as empty.
    255263         *
    256264         * @return array<string, string>
    257265         */
    258266        public static function values( int $operator_id ): array {
     267                $post = $operator_id > 0 ? get_post( $operator_id ) : null;
     268
     269                if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) {
     270                        return [];
     271                }
     272
     273                return self::stored_values( $operator_id );
     274        }
     275
     276        /**
     277         * What one operator record holds, whatever its status. For its own edit form only.
     278         *
     279         * @return array<string, string>
     280         */
     281        public static function stored_values( int $operator_id ): array {
    259282                if ( $operator_id <= 0 ) {
    260283                        return [];
    … …  
    343366                wp_nonce_field( 'lodecomply_operator_save', 'lodecomply_operator_nonce' );
    344367
    345                 $values = self::values( (int) $post->ID );
     368                $values = self::stored_values( (int) $post->ID );
    346369
    347370                require lodecomply()->dir( 'admin/views/operator-fields.php' );
  • lodestone-product-compliance/trunk/includes/class-plugin.php

    r3705566 r3713160  
    9696
    9797                ( new Operators() )->hooks();
     98                ( new Index() )->hooks();
    9899                ( new Display() )->hooks();
    99100                ( new Guarantee() )->hooks();
  • lodestone-product-compliance/trunk/includes/class-status-store.php

    r3705566 r3713160  
    101101
    102102        /**
     103         * Drops many products from the index at once, so they are re-evaluated when next read.
     104         *
     105         * @param int[] $product_ids Products to drop.
     106         */
     107        public static function delete_statuses( array $product_ids ): void {
     108                global $wpdb;
     109
     110                $table = Activator::status_table();
     111
     112                foreach ( array_chunk( array_values( array_filter( array_map( 'intval', $product_ids ) ) ), 500 ) as $chunk ) {
     113                        $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
     114
     115                        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name and a generated list of %d placeholders, one per id; every value is a placeholder, which the sniff cannot see through implode().
     116                        $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE product_id IN ({$placeholders})", $chunk ) );
     117                }
     118        }
     119
     120        /**
     121         * Removes rows for anything that is not a product the catalogue audit covers.
     122         *
     123         * The index is "what is missing right now" for products that exist for sale or are being
     124         * prepared for it: every status the warmer walks, which is WP_Query's 'any' -- so not the bin
     125         * and not an auto-draft. A deleted product, a binned one, or an id that was never a product
     126         * has no business in a count. Index keeps new rows of that kind from appearing; this clears
     127         * the ones written before it existed. Snapshots are untouched: they are evidence, and a
     128         * deleted product's evidence is still evidence.
     129         */
     130        public static function prune(): void {
     131                global $wpdb;
     132
     133                $table = Activator::status_table();
     134
     135                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names, not values; every value is a placeholder.
     136                $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE product_id NOT IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status NOT IN ( %s, %s ) )", 'product', 'trash', 'auto-draft' ) );
     137        }
     138
     139        /**
    103140         * Counts by state, for the dashboard headline.
    104141         *
  • lodestone-product-compliance/trunk/includes/freemius.php

    r3705566 r3713160  
    6464                                        'public_key'   => LODECOMPLY_FS_PUBLIC_KEY,
    6565                                        'menu_slug'    => \LodeComply\Plugin::MENU_SLUG,
    66                                         'is_premium'   => defined( 'LODECOMPLY_IS_PREMIUM' ) && LODECOMPLY_IS_PREMIUM,
     66                                        'is_premium'   => \LodeComply\Autoloader::is_premium_build(),
    6767                                ]
    6868                        );
  • lodestone-product-compliance/trunk/languages/lodestone-product-compliance.pot

    r3705566 r3713160  
    55"Project-Id-Version: Lodestone Product Compliance – GPSR for WooCommerce\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/lodestone-product-compliance/\n"
    7 "POT-Creation-Date: 2026-09-20 01:30:06+0000\n"
     7"POT-Creation-Date: 2026-09-25 13:43:40+0000\n"
    88"MIME-Version: 1.0\n"
    99"Content-Type: text/plain; charset=UTF-8\n"
    … …  
    1313
    1414#. translators: %s: link to the plugin's dashboard, with anchor text.
    15 #: admin/class-admin.php:119
     15#: admin/class-admin.php:122
    1616msgid "Product Compliance is ready. See which products are still missing GPSR information in %s."
    1717msgstr ""
    1818
    1919#. translators: %s: link to the plugin's dashboard, with anchor text.
    20 #: admin/class-admin.php:120
     20#: admin/class-admin.php:123
    2121msgid "the Compliance dashboard"
    2222msgstr ""
    2323
    24 #: admin/class-admin.php:194
     24#: admin/class-admin.php:197
    2525msgid "You are not allowed to view product compliance data."
    2626msgstr ""
    2727
    28 #: admin/class-admin.php:347
     28#: admin/class-admin.php:229
     29msgid "Questions?"
     30msgstr ""
     31
     32#: admin/class-admin.php:230
     33msgid "Ask in the WordPress.org support forum"
     34msgstr ""
     35
     36#: admin/class-admin.php:232
     37msgid "Pro customers can also write to"
     38msgstr ""
     39
     40#. translators: %d: product ID.
     41#: admin/class-admin.php:323
     42msgid "Product #%d (no title)"
     43msgstr ""
     44
     45#: admin/class-admin.php:403
    2946msgid "Catalogue"
    3047msgstr ""
    3148
    32 #: admin/class-admin.php:348
    33 #: admin/views/operators.php:28
     49#: admin/class-admin.php:404
     50#: admin/views/operators.php:34
    3451#: includes/class-operators.php:160
    3552msgid "Operator records"
    3653msgstr ""
    3754
    38 #: admin/class-admin.php:349
     55#: admin/class-admin.php:405
    3956#: admin/views/settings.php:17
    4057msgid "Settings"
    4158msgstr ""
    4259
    43 #: admin/class-admin.php:360
     60#: admin/class-admin.php:416
    4461msgid "Languages"
    4562msgstr ""
    4663
    47 #: admin/class-admin.php:361
     64#: admin/class-admin.php:417
    4865msgid "Coverage"
    4966msgstr ""
    5067
    51 #: admin/class-admin.php:372
     68#: admin/class-admin.php:428
    5269msgid "Pro"
    5370msgstr ""
    5471
    55 #: admin/class-admin.php:375
     72#: admin/class-admin.php:431
    5673msgid "Product Compliance sections"
    5774msgstr ""
    5875
    59 #: admin/class-admin.php:399
     76#: admin/class-admin.php:455
    6077msgid "You are not allowed to change these settings."
    6178msgstr ""
    6279
    63 #: admin/class-admin.php:59
    64 #: admin/class-admin.php:72
    65 #: admin/class-admin.php:80
     80#: admin/class-admin.php:62
     81#: admin/class-admin.php:75
     82#: admin/class-admin.php:83
    6683msgid "Product Compliance"
    6784msgstr ""
    6885
    69 #: admin/class-admin.php:60
    70 #: admin/class-admin.php:81
     86#: admin/class-admin.php:63
     87#: admin/class-admin.php:84
    7188#: includes/class-product-data.php:144
    7289msgid "Compliance"
    7390msgstr ""
    7491
    75 #: admin/class-admin.php:73
     92#: admin/class-admin.php:76
    7693msgid "This plugin adds product safety information to WooCommerce products, so it needs WooCommerce to be active. Nothing has been changed on your site and no data has been removed."
     94msgstr ""
     95
     96#: admin/class-review-prompt.php:57
     97msgid "Did that save you some time?"
     98msgstr ""
     99
     100#: admin/class-review-prompt.php:58
     101msgid "Ratings are how a plugin gets found in the WordPress.org directory. If filling a whole catalogue in one step helped, a rating helps the next shop with the same job find it."
     102msgstr ""
     103
     104#: admin/class-review-prompt.php:59
     105msgid "Rate it on WordPress.org"
     106msgstr ""
     107
     108#: admin/class-review-prompt.php:60
     109msgid "No thanks"
     110msgstr ""
     111
     112#: admin/class-review-prompt.php:61
     113msgid "Rating opens wordpress.org in a new tab. Asked once — whichever you choose, this will not appear again."
     114msgstr ""
     115
     116#: admin/class-review-prompt.php:62
     117msgid "You are not allowed to do that."
    77118msgstr ""
    78119
    … …  
    203244
    204245#: admin/views/operator-fields.php:26
    205 #: admin/views/operators.php:80
     246#: admin/views/operators.php:117
    206247msgid "Role"
    207248msgstr ""
    … …  
    212253
    213254#: admin/views/operator-fields.php:30
    214 #: admin/views/operators.php:83
     255#: admin/views/operators.php:120
    215256#: includes/class-fields.php:117
    216257msgid "Manufacturer"
    … …  
    218259
    219260#: admin/views/operator-fields.php:31
    220 #: admin/views/operators.php:84
     261#: admin/views/operators.php:121
    221262#: includes/class-fields.php:121
    222263msgid "EU responsible person"
    … …  
    248289
    249290#: admin/views/operator-fields.php:57
    250 #: admin/views/operators.php:145
     291#: admin/views/operators.php:182
    251292msgid "Country"
    252293msgstr ""
    … …  
    264305msgstr ""
    265306
    266 #: admin/views/operators.php:102
     307#: admin/views/operators.php:104
     308msgid "No operator records yet. Add the manufacturer of your products first — most catalogues need only a handful of records in total."
     309msgstr ""
     310
     311#: admin/views/operators.php:109
     312msgid "Assign to many products at once"
     313msgstr ""
     314
     315#: admin/views/operators.php:126
     316#: includes/class-operators.php:161
     317msgid "Operator record"
     318msgstr ""
     319
     320#: admin/views/operators.php:136
     321msgid "Limit to category"
     322msgstr ""
     323
     324#: admin/views/operators.php:139
    267325msgid "All products"
    268326msgstr ""
    269327
    270 #: admin/views/operators.php:116
     328#: admin/views/operators.php:153
    271329msgid "Existing values"
    272330msgstr ""
    273331
    274 #: admin/views/operators.php:120
     332#: admin/views/operators.php:157
    275333msgid "Only fill in products that have no value yet"
    276334msgstr ""
    277335
    278336#. translators: %s: the number of products handled per run.
    279 #: admin/views/operators.php:126
     337#: admin/views/operators.php:163
    280338msgid "Up to %s products per run. If there are more, run it again."
    281339msgstr ""
    282340
    283 #: admin/views/operators.php:135
     341#: admin/views/operators.php:172
    284342msgid "Assign"
    285343msgstr ""
    286344
    287 #: admin/views/operators.php:138
     345#: admin/views/operators.php:175
    288346msgid "Your records"
    289347msgstr ""
    290348
    291 #: admin/views/operators.php:141
     349#: admin/views/operators.php:178
    292350msgid "Operator records and whether each is complete"
    293351msgstr ""
    294352
    295 #: admin/views/operators.php:144
     353#: admin/views/operators.php:181
    296354msgid "Name"
    297355msgstr ""
    298356
    299 #: admin/views/operators.php:146
     357#: admin/views/operators.php:183
    300358msgid "Details"
    301359msgstr ""
    302360
    303 #: admin/views/operators.php:160
     361#: admin/views/operators.php:197
    304362msgid "not set"
    305363msgstr ""
    306364
    307 #: admin/views/operators.php:163
     365#: admin/views/operators.php:200
    308366msgid "outside the EU"
    309367msgstr ""
    310368
    311 #: admin/views/operators.php:170
     369#: admin/views/operators.php:207
    312370msgid "Complete"
    313371msgstr ""
    314372
    315 #: admin/views/operators.php:171
     373#: admin/views/operators.php:208
    316374msgid "Needs a postal address and an email or web address"
    317375msgstr ""
    318376
    319 #: admin/views/operators.php:31
     377#: admin/views/operators.php:37
    320378msgid "A manufacturer or EU responsible person is stored once here and attached to as many products as you like. Correcting an address is then one edit rather than hundreds."
    321379msgstr ""
    322380
     381#: admin/views/operators.php:53
     382msgid "Nothing needed changing: there are no products in the category you chose."
     383msgstr ""
     384
     385#: admin/views/operators.php:55
     386msgid "Nothing needed changing: every product in scope already had a value for this role, and existing values were left alone. Untick \"Only fill in products that have no value yet\" to replace them."
     387msgstr ""
     388
     389#: admin/views/operators.php:57
     390msgid "Nothing needed changing: every product in scope already uses this operator record for this role."
     391msgstr ""
     392
    323393#. translators: %s: number of products updated.
    324 #: admin/views/operators.php:40
     394#: admin/views/operators.php:68
    325395msgid "%s product updated."
    326396msgid_plural "%s products updated."
    … …  
    328398msgstr[1] ""
    329399
    330 #: admin/views/operators.php:45
    331 msgid "That was a full batch, so there may be more to do — run it again."
    332 msgstr ""
    333 
    334 #: admin/views/operators.php:51
     400#: admin/views/operators.php:73
     401msgid "There are more products in scope still to do — run it again with the same choices to continue."
     402msgstr ""
     403
     404#: admin/views/operators.php:75
     405msgid "That was every product in scope."
     406msgstr ""
     407
     408#: admin/views/operators.php:81
    335409msgid "Nothing was assigned. Choose an operator record and try again."
    336410msgstr ""
    337411
    338 #: admin/views/operators.php:57
     412#: admin/views/operators.php:94
    339413#: includes/class-operators.php:162
    340414msgid "Add operator record"
    341415msgstr ""
    342416
    343 #: admin/views/operators.php:60
     417#: admin/views/operators.php:97
    344418msgid "Manage all records"
    345 msgstr ""
    346 
    347 #: admin/views/operators.php:67
    348 msgid "No operator records yet. Add the manufacturer of your products first — most catalogues need only a handful of records in total."
    349 msgstr ""
    350 
    351 #: admin/views/operators.php:72
    352 msgid "Assign to many products at once"
    353 msgstr ""
    354 
    355 #: admin/views/operators.php:89
    356 #: includes/class-operators.php:161
    357 msgid "Operator record"
    358 msgstr ""
    359 
    360 #: admin/views/operators.php:99
    361 msgid "Limit to category"
    362419msgstr ""
    363420
    … …  
    452509
    453510#: admin/views/pro.php:63
    454 msgid "Audit a catalogue and export a dossier from a script, across as many stores as you run."
     511msgid "Audit a whole catalogue and list what is still missing from a script, across as many stores as you run — with a non-zero exit when anything is incomplete, so it can sit in a deployment check or a nightly job."
    455512msgstr ""
    456513
    … …  
    536593msgstr ""
    537594
    538 #: admin/views/settings.php:107
     595#: admin/views/settings.php:105
     596msgid "Notice wording"
     597msgstr ""
     598
     599#: admin/views/settings.php:108
     600msgid "Leave empty to use the built-in plain-language summary."
     601msgstr ""
     602
     603#: admin/views/settings.php:116
    539604msgid "What this plugin does not do"
    540605msgstr ""
    541606
    542 #: admin/views/settings.php:110
    543 msgid "It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it does not contact any external service, and it never states that your store is compliant. Nothing here is legal advice."
     607#: admin/views/settings.php:119
     608msgid "It records and displays the information you enter, and it tells you what is still missing. It does not verify that what you entered is true, it does not assess whether a product is safe, it sends none of your product information to any external service, and it never states that your store is compliant. Nothing here is legal advice."
    544609msgstr ""
    545610
    … …  
    573638
    574639#. translators: 1: number of days, 2: the date the obligation starts.
    575 #: admin/views/settings.php:57
    576 msgid "Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early."
    577 msgstr ""
     640#: admin/views/settings.php:60
     641msgid "Mandatory in %1$s day, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early."
     642msgid_plural "Mandatory in %1$s days, from %2$s, under Commission Implementing Regulation (EU) 2025/1960. Until then it is shown only if you turn it on early."
     643msgstr[0] ""
     644msgstr[1] ""
    578645
    579646#. translators: %s: the date the obligation started.
    580 #: admin/views/settings.php:64
     647#: admin/views/settings.php:73
    581648msgid "Mandatory since %s under Commission Implementing Regulation (EU) 2025/1960."
    582649msgstr ""
    583650
    584 #: admin/views/settings.php:73
     651#: admin/views/settings.php:82
    585652msgid "Read this before relying on the default wording."
    586653msgstr ""
    587654
    588 #: admin/views/settings.php:74
     655#: admin/views/settings.php:83
    589656msgid "The European Commission publishes an official harmonised notice, with prescribed wording and artwork, in every EU language. This plugin does not reproduce it, because an approximation that looks official would be worse for you than a plain reminder. The text below is an accurate plain-language summary of the legal guarantee. Paste the official wording for your market into the box when you have it."
    590657msgstr ""
    591658
    592 #: admin/views/settings.php:80
     659#: admin/views/settings.php:89
    593660msgid "Display"
    594661msgstr ""
    595662
    596 #: admin/views/settings.php:84
     663#: admin/views/settings.php:93
    597664msgid "Show the legal guarantee notice on product pages"
    598665msgstr ""
    599666
    600 #: admin/views/settings.php:90
     667#: admin/views/settings.php:99
    601668msgid "Start showing it now, before the obligation begins"
    602669msgstr ""
    603670
    604 #: admin/views/settings.php:96
    605 msgid "Notice wording"
    606 msgstr ""
    607 
    608 #: admin/views/settings.php:99
    609 msgid "Leave empty to use the built-in plain-language summary."
    610 msgstr ""
    611 
    612 #: includes/class-bulk.php:34
     671#: includes/class-bulk.php:42
    613672msgid "You are not allowed to edit product compliance data."
    614673msgstr ""
    … …  
    622681msgstr ""
    623682
    624 #: includes/class-evaluator.php:129
     683#: includes/class-evaluator.php:145
    625684msgid "Product image"
    626685msgstr ""
    … …  
    726785msgstr ""
    727786
    728 #: includes/class-operators.php:331
     787#: includes/class-operators.php:354
    729788msgid "Operator details"
    730789msgstr ""
  • lodestone-product-compliance/trunk/lodestone-product-compliance.php

    r3705566 r3713160  
    22/**
    33 * Plugin Name:       Lodestone Product Compliance – GPSR for WooCommerce
     4 * Plugin URI:        https://madebylodestone.com/product-compliance
    45 * Description:       GPSR product safety information for WooCommerce. Records manufacturer and EU responsible person details, shows them in the product listing, adds the EU legal guarantee notice, and audits your whole catalogue for what is still missing.
    5  * Version:           1.0.1
     6 * Version:           1.0.2
    67 * Requires at least: 6.4
    78 * Requires PHP:      7.4
    … …  
    1920defined( 'ABSPATH' ) || exit;
    2021
    21 define( 'LODECOMPLY_VERSION', '1.0.0' );
     22define( 'LODECOMPLY_VERSION', '1.0.2' );
    2223define( 'LODECOMPLY_FILE', __FILE__ );
    2324
  • lodestone-product-compliance/trunk/readme.txt

    r3705566 r3713160  
    66Requires PHP: 7.4
    77Requires Plugins: woocommerce
    8 Stable tag: 1.0.1
     8Stable tag: 1.0.2
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3333* Stores manufacturers and EU responsible persons as **reusable records**. One importer behind 300
    3434  products is one record, so correcting an address is one edit and not three hundred.
    35 * Assigns an operator record to a whole product category in one action.
     35* Assigns an operator record to a whole product category, or the whole catalogue, in bulk — 200
     36  products per run, and running it again carries on where the last run stopped.
    3637* Shows the information **in the offer** — a block under the add-to-cart area and an optional
    3738  product tab — and lets you switch either off if your theme already prints it.
    … …  
    6465a printable per-product compliance record, a CSV export, coverage over time, per-language warning
    6566coverage for multilingual stores, the GARAN durability guarantee, and WP-CLI.
     67
     68= One question, asked once =
     69
     70We ask you for exactly one thing, once. After a bulk assignment fills five or more products in one
     71step, the screen you land on asks whether you would rate this plugin on WordPress.org. It is not an
     72admin notice, it is only shown to people who can manage plugins, and whichever button you press —
     73including "No thanks" — it never appears again.
    6674
    6775== Frequently Asked Questions ==
    … …  
    108116= Does it send my data anywhere? =
    109117
    110 No. There are no outbound requests at all. If you later opt in to the Pro version, Freemius asks
    111 for its own consent separately, on its own screen, and you can decline.
     118No. The plugin itself makes no outbound requests, and nothing about your products or customers
     119leaves your site. Licensing is handled by the Freemius SDK, which sends nothing on its own unless
     120you opt in. Two things you do yourself do reach Freemius: opening the upgrade page loads its
     121prices and checkout, and the optional feedback form shown when you deactivate is sent if you fill
     122it in.
    112123
    113124= Will it slow down my product pages? =
    … …  
    118129= I have 5,000 products. Will the audit handle that? =
    119130
    120 Yes. Products are checked in batches as you visit the dashboard rather than all at once, so a large
    121 catalogue fills in over a few visits instead of timing out. The screen tells you how many have been
     131Yes. There is no limit on the number of products. Every product is checked the moment it is saved in
     132wp-admin, and the ones nobody has saved there since you installed the plugin are checked 100 at a time each time you
     133open the dashboard, rather than all at once, so a large catalogue fills in over repeated visits
     134instead of timing out — 5,000 products take about fifty. The screen tells you how many have been
    122135checked so far.
    123136
    … …  
    131144== Changelog ==
    132145
     146= 1.0.2 =
     147* Fixed: the catalogue audit never got past the first 100 products on a larger shop. Every
     148  product is now reached, a batch per visit to the screen.
     149* Fixed: assigning an operator record without "only fill in products with no value yet" rewrote
     150  the same 200 products on every run. Each run now carries on where the last one stopped, and the
     151  screen says whether there is more to do.
     152* Fixed: deleted and binned products stayed in the counts and the list of products to fix, and a
     153  binned operator record's name and address kept appearing on product pages.
     154* Fixed: the Settings screen would have read "Mandatory in 1 days" the day before the guarantee
     155  notice becomes mandatory.
     156* Fixed: a bulk assignment that changed nothing was shown as a success reading "0 products updated".
     157* Fixed: a shop manager who dismissed the one admin notice saw it come back on the next page.
     158* Pro: CSV exports no longer write a cell a spreadsheet would run as a formula, and write product
     159  titles as text rather than HTML entities. The dossier refuses an id that is not a product.
     160* A "Questions?" line at the foot of each screen links the support forum.
     161* After a bulk assignment fills a catalogue, the screen asks once whether you would rate the plugin.
     162* The FAQ and Settings now say exactly when the Freemius SDK contacts Freemius, instead of "no
     163  outbound requests at all".
     164
    133165= 1.0.1 =
    134166* Listed as "Lodestone Product Compliance - GPSR for WooCommerce", so it can be
  • lodestone-product-compliance/trunk/vendor/composer/autoload_classmap.php

    r3705566 r3713160  
    88return array(
    99    'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
     10    'Portfolio\\Core\\Csv' => $vendorDir . '/portfolio/core/src/Csv.php',
    1011    'Portfolio\\Core\\Freemius' => $vendorDir . '/portfolio/core/src/Freemius.php',
    1112    'Portfolio\\Core\\Notices' => $vendorDir . '/portfolio/core/src/Notices.php',
    1213    'Portfolio\\Core\\Plugin' => $vendorDir . '/portfolio/core/src/Plugin.php',
    1314    'Portfolio\\Core\\Pricing_A11y' => $vendorDir . '/portfolio/core/src/Pricing_A11y.php',
     15    'Portfolio\\Core\\Review_Prompt' => $vendorDir . '/portfolio/core/src/Review_Prompt.php',
     16    'Portfolio\\Core\\Support_Link' => $vendorDir . '/portfolio/core/src/Support_Link.php',
    1417);
  • lodestone-product-compliance/trunk/vendor/composer/autoload_static.php

    r3705566 r3713160  
    2727    public static $classMap = array (
    2828        'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
     29        'Portfolio\\Core\\Csv' => __DIR__ . '/..' . '/portfolio/core/src/Csv.php',
    2930        'Portfolio\\Core\\Freemius' => __DIR__ . '/..' . '/portfolio/core/src/Freemius.php',
    3031        'Portfolio\\Core\\Notices' => __DIR__ . '/..' . '/portfolio/core/src/Notices.php',
    3132        'Portfolio\\Core\\Plugin' => __DIR__ . '/..' . '/portfolio/core/src/Plugin.php',
    3233        'Portfolio\\Core\\Pricing_A11y' => __DIR__ . '/..' . '/portfolio/core/src/Pricing_A11y.php',
     34        'Portfolio\\Core\\Review_Prompt' => __DIR__ . '/..' . '/portfolio/core/src/Review_Prompt.php',
     35        'Portfolio\\Core\\Support_Link' => __DIR__ . '/..' . '/portfolio/core/src/Support_Link.php',
    3336    );
    3437
  • lodestone-product-compliance/trunk/vendor/composer/installed.php

    r3705566 r3713160  
    22    'root' => array(
    33        'name' => 'portfolio/lodestone-product-compliance',
    4         'pretty_version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    5         'version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    6         'reference' => 'fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
     4        'pretty_version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     5        'version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     6        'reference' => 'be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
    77        'type' => 'project',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    3030        ),
    3131        'portfolio/lodestone-product-compliance' => array(
    32             'pretty_version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    33             'version' => 'dev-fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
    34             'reference' => 'fd06c16ea57dbc9cfcf1849fdb8f8572ce3332cb',
     32            'pretty_version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     33            'version' => 'dev-be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
     34            'reference' => 'be5baeb6ff9f1ded9cd947a64f181586ba0b1b2f',
    3535            'type' => 'project',
    3636            'install_path' => __DIR__ . '/../../',
  • lodestone-product-compliance/trunk/vendor/portfolio/core/src/Freemius.php

    r3705566 r3713160  
    4242                }
    4343
    44                 return fs_dynamic_init( [
     44                $fs = fs_dynamic_init( [
    4545                        'id'              => $args['id'],
    4646                        'slug'            => $args['slug'],
    … …  
    101101                        'is_org_compliant' => $args['is_org_compliant'] ?? true,
    102102                ] );
     103
     104                /*
     105                 * The SDK carries two admin notices that no line of this repository adds, and the
     106                 * portfolio's budget is one notice, ever, across all four plugins:
     107                 *
     108                 *   _add_trial_notice()             gated on 'show_trial', and it recurs;
     109                 *   _add_affiliate_program_notice() gated on 'show_affiliate_program_notice'.
     110                 *
     111                 * Both default to true and both are dormant only because the matching setting is off in
     112                 * the Freemius *dashboard*. Switching on a trial or the affiliate programme there -- one
     113                 * click, no commit, nothing to review -- would put a third party's notice into every
     114                 * plugin at once. So both are answered here, for every product, before either is ever
     115                 * switched on. A trial can still be offered; it is offered from the Pro tab, which is one
     116                 * of the two places upsell is allowed. tests/test-core-freemius-notices.php holds this,
     117                 * and holds the SDK to these two filter names.
     118                 *
     119                 * $fs->add_filter() rather than add_filter(): the SDK prefixes every hook with its
     120                 * product's slug, so only the instance's own method reaches the right one.
     121                 */
     122                if ( is_object( $fs ) && method_exists( $fs, 'add_filter' ) ) {
     123                        $fs->add_filter( 'show_trial', '__return_false' );
     124                        $fs->add_filter( 'show_affiliate_program_notice', '__return_false' );
     125
     126                        /*
     127                         * And one the SDK does add, to every paying customer: installing Pro over the free
     128                         * plugin raises a sticky "W00t! Premium plugin version was successfully activated."
     129                         * -- one per plugin, so a customer with three stacked three high on our own screen, in
     130                         * markup axe fails for colour contrast. Seen in a browser, 24 Sep 2026. The Pro tabs
     131                         * appearing is the confirmation. Only that notice: licence and activation problems are
     132                         * things a customer needs to read, and still pass through.
     133                         */
     134                        $fs->add_filter( 'show_admin_notice', [ self::class, 'filter_sdk_notice' ], 10, 2 );
     135                }
     136
     137                return $fs;
     138        }
     139
     140        /**
     141         * The SDK's show_admin_notice filter: hides the premium-activated celebration, nothing else.
     142         *
     143         * @param mixed $show Whether the SDK means to show it.
     144         * @param mixed $msg  The notice: id, message, title, type.
     145         */
     146        public static function filter_sdk_notice( $show, $msg ) {
     147                if ( is_array( $msg ) && 'premium_activated' === ( $msg['id'] ?? '' ) ) {
     148                        return false;
     149                }
     150
     151                return $show;
    103152        }
    104153}
  • lodestone-product-compliance/trunk/vendor/portfolio/core/src/Notices.php

    r3705566 r3713160  
    3939
    4040        private string $slug;
     41        private string $cap;
    4142        private array  $queued = [];
    4243
    … …  
    4748         *                       own screens. Optional only so that a caller written before this
    4849         *                       existed still works.
     50         * @param string $cap    The capability the plugin shows its notice at, which is therefore
     51         *                       the one that may dismiss it. Until 24 Sep 2026 dismissal was fixed
     52         *                       at manage_options while the notices were shown to editors, authors
     53         *                       and shop managers, so for them the X did nothing but return a 403
     54         *                       and the notice came back on the next page.
    4955         */
    50         public function __construct( string $slug, string $screen = '' ) {
     56        public function __construct( string $slug, string $screen = '', string $cap = 'manage_options' ) {
    5157                $this->slug = $slug;
     58                $this->cap  = '' !== $cap ? $cap : 'manage_options';
    5259
    5360                // Registered, not stored: the only consumer is the static check below, which has to
    … …  
    160167        public function ajax_dismiss(): void {
    161168                check_ajax_referer( $this->slug . '_dismiss', 'nonce' );
    162                 if ( ! current_user_can( 'manage_options' ) ) {
     169                if ( ! current_user_can( $this->cap ) ) {
    163170                        wp_send_json_error( null, 403 );
    164171                }
Note: See TracChangeset for help on using the changeset viewer.