Changeset 3713160
- Timestamp:
- 09/25/2026 02:04:22 PM (4 weeks ago)
- Location:
- lodestone-product-compliance
- Files:
-
- 10 added
- 42 edited
- 1 copied
-
tags/1.0.2 (copied) (copied from lodestone-product-compliance/trunk )
-
tags/1.0.2/admin/class-admin.php (modified) (8 diffs)
-
tags/1.0.2/admin/class-review-prompt.php (added)
-
tags/1.0.2/admin/views/dashboard.php (modified) (1 diff)
-
tags/1.0.2/admin/views/operators.php (modified) (4 diffs)
-
tags/1.0.2/admin/views/pro.php (modified) (1 diff)
-
tags/1.0.2/admin/views/settings.php (modified) (2 diffs)
-
tags/1.0.2/assets/css/admin.css (modified) (1 diff)
-
tags/1.0.2/includes/class-autoloader.php (modified) (1 diff)
-
tags/1.0.2/includes/class-bulk.php (modified) (7 diffs)
-
tags/1.0.2/includes/class-evaluator.php (modified) (4 diffs)
-
tags/1.0.2/includes/class-index.php (added)
-
tags/1.0.2/includes/class-operators.php (modified) (2 diffs)
-
tags/1.0.2/includes/class-plugin.php (modified) (1 diff)
-
tags/1.0.2/includes/class-status-store.php (modified) (1 diff)
-
tags/1.0.2/includes/freemius.php (modified) (1 diff)
-
tags/1.0.2/languages/lodestone-product-compliance.pot (modified) (13 diffs)
-
tags/1.0.2/lodestone-product-compliance.php (modified) (2 diffs)
-
tags/1.0.2/readme.txt (modified) (6 diffs)
-
tags/1.0.2/vendor/composer/autoload_classmap.php (modified) (1 diff)
-
tags/1.0.2/vendor/composer/autoload_static.php (modified) (1 diff)
-
tags/1.0.2/vendor/composer/installed.php (modified) (2 diffs)
-
tags/1.0.2/vendor/portfolio/core/src/Csv.php (added)
-
tags/1.0.2/vendor/portfolio/core/src/Freemius.php (modified) (2 diffs)
-
tags/1.0.2/vendor/portfolio/core/src/Notices.php (modified) (3 diffs)
-
tags/1.0.2/vendor/portfolio/core/src/Review_Prompt.php (added)
-
tags/1.0.2/vendor/portfolio/core/src/Support_Link.php (added)
-
trunk/admin/class-admin.php (modified) (8 diffs)
-
trunk/admin/class-review-prompt.php (added)
-
trunk/admin/views/dashboard.php (modified) (1 diff)
-
trunk/admin/views/operators.php (modified) (4 diffs)
-
trunk/admin/views/pro.php (modified) (1 diff)
-
trunk/admin/views/settings.php (modified) (2 diffs)
-
trunk/assets/css/admin.css (modified) (1 diff)
-
trunk/includes/class-autoloader.php (modified) (1 diff)
-
trunk/includes/class-bulk.php (modified) (7 diffs)
-
trunk/includes/class-evaluator.php (modified) (4 diffs)
-
trunk/includes/class-index.php (added)
-
trunk/includes/class-operators.php (modified) (2 diffs)
-
trunk/includes/class-plugin.php (modified) (1 diff)
-
trunk/includes/class-status-store.php (modified) (1 diff)
-
trunk/includes/freemius.php (modified) (1 diff)
-
trunk/languages/lodestone-product-compliance.pot (modified) (13 diffs)
-
trunk/lodestone-product-compliance.php (modified) (2 diffs)
-
trunk/readme.txt (modified) (6 diffs)
-
trunk/vendor/composer/autoload_classmap.php (modified) (1 diff)
-
trunk/vendor/composer/autoload_static.php (modified) (1 diff)
-
trunk/vendor/composer/installed.php (modified) (2 diffs)
-
trunk/vendor/portfolio/core/src/Csv.php (added)
-
trunk/vendor/portfolio/core/src/Freemius.php (modified) (2 diffs)
-
trunk/vendor/portfolio/core/src/Notices.php (modified) (3 diffs)
-
trunk/vendor/portfolio/core/src/Review_Prompt.php (added)
-
trunk/vendor/portfolio/core/src/Support_Link.php (added)
Legend:
- Unmodified
- Added
- Removed
-
lodestone-product-compliance/tags/1.0.2/admin/class-admin.php
r3705566 r3713160 42 42 add_filter( 'posts_where', [ $this, 'warm_where' ], 10, 2 ); 43 43 44 // The rating prompt's handler. Its panel is printed by the operators view. 45 ( new Review_Prompt() )->hooks(); 46 44 47 $this->maybe_add_notice(); 45 48 } … … 128 131 // PREFIX, not SLUG: Notices derives its option names from this, and Uninstaller 129 132 // 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' ); 131 134 } 132 135 … … 205 208 if ( 'dashboard' === $view ) { 206 209 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(); 207 213 } 208 214 … … 212 218 213 219 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 ); 214 235 215 236 echo '</div>'; … … 226 247 */ 227 248 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 228 257 $cursor = (int) get_option( self::OPTION_WARM_CURSOR, 0 ); 229 258 … … 237 266 * revisiting products that were indexed and later changed -- which does not matter, 238 267 * 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. 239 275 */ 240 276 $product_ids = get_posts( … … 249 285 'update_post_meta_cache' => false, 250 286 'update_post_term_cache' => false, 251 'lodecomply_min_id' => $cursor, 287 'suppress_filters' => false, 288 'lodecomply_min_id' => $cursor, 252 289 ] 253 290 ); … … 266 303 267 304 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 ); 268 324 } 269 325 -
lodestone-product-compliance/tags/1.0.2/admin/views/dashboard.php
r3705566 r3713160 169 169 <th scope="row"> 170 170 <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 ) ); ?> 172 172 </a> 173 173 </th> -
lodestone-product-compliance/tags/1.0.2/admin/views/operators.php
r3705566 r3713160 16 16 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result. 17 17 $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'] ) ); 18 24 19 25 $lodecomply_categories = get_terms( … … 32 38 </p> 33 39 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 ) : ?> 35 63 <div class="notice notice-success inline"> 36 64 <p> … … 42 70 ); 43 71 ?> 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' ); ?> 46 76 <?php endif; ?> 47 77 </p> … … 52 82 </div> 53 83 <?php endif; ?> 84 85 <?php 86 // The rating prompt, right after a bulk assignment that filled a catalogue. Review_Prompt holds every rule. 87 if ( Review_Prompt::should_show() ) { 88 Review_Prompt::render(); 89 } 90 ?> 54 91 55 92 <p> -
lodestone-product-compliance/tags/1.0.2/admin/views/pro.php
r3705566 r3713160 61 61 <li> 62 62 <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' ); ?> 64 64 </li> 65 65 </ul> -
lodestone-product-compliance/tags/1.0.2/admin/views/settings.php
r3705566 r3713160 53 53 <?php 54 54 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. 55 57 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 ), 58 67 esc_html( number_format_i18n( $lodecomply_days ) ), 59 68 esc_html( date_i18n( (string) get_option( 'date_format' ), (int) strtotime( LODECOMPLY_GUARANTEE_NOTICE_DATE ) ) ) … … 108 117 109 118 <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 contactany 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' ); ?> 111 120 </p> -
lodestone-product-compliance/tags/1.0.2/assets/css/admin.css
r3705566 r3713160 117 117 color: #8a2424; 118 118 } 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 56 56 } 57 57 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 58 100 public static function load( string $class_name ): void { 59 101 if ( 0 !== strpos( $class_name, self::PREFIX ) ) { -
lodestone-product-compliance/tags/1.0.2/includes/class-bulk.php
r3705566 r3713160 11 11 * be run again; that is a slower interaction than a progress bar but it cannot half-finish a 12 12 * 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. 13 21 * 14 22 * @package LodeComply … … 42 50 // phpcs:enable WordPress.Security.NonceVerification.Missing 43 51 44 $assigned = 0; 45 $notice = 'error'; 52 $args = [ 53 'view' => 'operators', 54 'lodecomply_notice' => 'error', 55 ]; 46 56 47 57 /* … … 57 67 && Operators::POST_TYPE === get_post_type( $operator_id ) 58 68 ) { 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 ) ); 72 89 73 90 exit; … … 76 93 /** 77 94 * 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. 78 99 * 79 100 * @param string $field 'manufacturer' or 'responsible_person'. … … 81 102 * @param int $term_id Product category to limit to, or 0 for the whole catalogue. 82 103 * @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. 84 106 */ 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 { 86 108 $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; 97 114 98 115 if ( $only_empty ) { … … 117 134 ], 118 135 ]; 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 ]; 120 205 121 206 if ( $term_id > 0 ) { … … 129 214 } 130 215 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; 144 217 } 145 218 } -
lodestone-product-compliance/tags/1.0.2/includes/class-evaluator.php
r3705566 r3713160 26 26 public const STATE_EXEMPT = 'exempt'; 27 27 28 /** Products re-evaluated synchronously when an operator record changes. */ 29 public const REEVALUATE_LIMIT = 500; 30 28 31 /** 29 32 * Evaluates one product and writes the result to the index. … … 34 37 $payload = Fields::payload( $product_id ); 35 38 $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 } 36 52 37 53 Status_Store::write_status( $product_id, $result['state'], $result['missing'] ); … … 133 149 134 150 /** 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 /** 135 168 * Re-evaluates every product currently pointing at an operator record. 136 169 * 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 { 143 186 if ( $operator_id <= 0 ) { 144 187 return; 145 188 } 146 189 190 // Ids only, so even a very large set is a small array; the cost is in evaluating them. 147 191 $product_ids = get_posts( 148 192 [ 149 193 'post_type' => 'product', 150 194 'post_status' => 'any', 151 'posts_per_page' => 500, 195 'posts_per_page' => -1, 196 'orderby' => 'ID', 197 'order' => 'ASC', 152 198 'fields' => 'ids', 153 199 'no_found_rows' => true, … … 168 214 ); 169 215 170 foreach ( $product_ids as $product_id ) { 216 $limit = max( 0, $limit ); 217 218 foreach ( array_slice( $product_ids, 0, $limit ) as $product_id ) { 171 219 self::evaluate( (int) $product_id ); 172 220 } 221 222 Status_Store::delete_statuses( array_slice( $product_ids, $limit ) ); 173 223 } 174 224 -
lodestone-product-compliance/tags/1.0.2/includes/class-operators.php
r3705566 r3713160 249 249 250 250 /** 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. 255 263 * 256 264 * @return array<string, string> 257 265 */ 258 266 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 { 259 282 if ( $operator_id <= 0 ) { 260 283 return []; … … 343 366 wp_nonce_field( 'lodecomply_operator_save', 'lodecomply_operator_nonce' ); 344 367 345 $values = self:: values( (int) $post->ID );368 $values = self::stored_values( (int) $post->ID ); 346 369 347 370 require lodecomply()->dir( 'admin/views/operator-fields.php' ); -
lodestone-product-compliance/tags/1.0.2/includes/class-plugin.php
r3705566 r3713160 96 96 97 97 ( new Operators() )->hooks(); 98 ( new Index() )->hooks(); 98 99 ( new Display() )->hooks(); 99 100 ( new Guarantee() )->hooks(); -
lodestone-product-compliance/tags/1.0.2/includes/class-status-store.php
r3705566 r3713160 101 101 102 102 /** 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 /** 103 140 * Counts by state, for the dashboard headline. 104 141 * -
lodestone-product-compliance/tags/1.0.2/includes/freemius.php
r3705566 r3713160 64 64 'public_key' => LODECOMPLY_FS_PUBLIC_KEY, 65 65 'menu_slug' => \LodeComply\Plugin::MENU_SLUG, 66 'is_premium' => defined( 'LODECOMPLY_IS_PREMIUM' ) && LODECOMPLY_IS_PREMIUM,66 'is_premium' => \LodeComply\Autoloader::is_premium_build(), 67 67 ] 68 68 ); -
lodestone-product-compliance/tags/1.0.2/languages/lodestone-product-compliance.pot
r3705566 r3713160 5 5 "Project-Id-Version: Lodestone Product Compliance – GPSR for WooCommerce\n" 6 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/lodestone-product-compliance/\n" 7 "POT-Creation-Date: 2026-09-2 0 01:30:06+0000\n"7 "POT-Creation-Date: 2026-09-25 13:43:40+0000\n" 8 8 "MIME-Version: 1.0\n" 9 9 "Content-Type: text/plain; charset=UTF-8\n" … … 13 13 14 14 #. translators: %s: link to the plugin's dashboard, with anchor text. 15 #: admin/class-admin.php:1 1915 #: admin/class-admin.php:122 16 16 msgid "Product Compliance is ready. See which products are still missing GPSR information in %s." 17 17 msgstr "" 18 18 19 19 #. translators: %s: link to the plugin's dashboard, with anchor text. 20 #: admin/class-admin.php:12 020 #: admin/class-admin.php:123 21 21 msgid "the Compliance dashboard" 22 22 msgstr "" 23 23 24 #: admin/class-admin.php:19 424 #: admin/class-admin.php:197 25 25 msgid "You are not allowed to view product compliance data." 26 26 msgstr "" 27 27 28 #: admin/class-admin.php:347 28 #: admin/class-admin.php:229 29 msgid "Questions?" 30 msgstr "" 31 32 #: admin/class-admin.php:230 33 msgid "Ask in the WordPress.org support forum" 34 msgstr "" 35 36 #: admin/class-admin.php:232 37 msgid "Pro customers can also write to" 38 msgstr "" 39 40 #. translators: %d: product ID. 41 #: admin/class-admin.php:323 42 msgid "Product #%d (no title)" 43 msgstr "" 44 45 #: admin/class-admin.php:403 29 46 msgid "Catalogue" 30 47 msgstr "" 31 48 32 #: admin/class-admin.php: 34833 #: admin/views/operators.php: 2849 #: admin/class-admin.php:404 50 #: admin/views/operators.php:34 34 51 #: includes/class-operators.php:160 35 52 msgid "Operator records" 36 53 msgstr "" 37 54 38 #: admin/class-admin.php: 34955 #: admin/class-admin.php:405 39 56 #: admin/views/settings.php:17 40 57 msgid "Settings" 41 58 msgstr "" 42 59 43 #: admin/class-admin.php: 36060 #: admin/class-admin.php:416 44 61 msgid "Languages" 45 62 msgstr "" 46 63 47 #: admin/class-admin.php: 36164 #: admin/class-admin.php:417 48 65 msgid "Coverage" 49 66 msgstr "" 50 67 51 #: admin/class-admin.php: 37268 #: admin/class-admin.php:428 52 69 msgid "Pro" 53 70 msgstr "" 54 71 55 #: admin/class-admin.php: 37572 #: admin/class-admin.php:431 56 73 msgid "Product Compliance sections" 57 74 msgstr "" 58 75 59 #: admin/class-admin.php: 39976 #: admin/class-admin.php:455 60 77 msgid "You are not allowed to change these settings." 61 78 msgstr "" 62 79 63 #: admin/class-admin.php: 5964 #: admin/class-admin.php:7 265 #: admin/class-admin.php:8 080 #: admin/class-admin.php:62 81 #: admin/class-admin.php:75 82 #: admin/class-admin.php:83 66 83 msgid "Product Compliance" 67 84 msgstr "" 68 85 69 #: admin/class-admin.php:6 070 #: admin/class-admin.php:8 186 #: admin/class-admin.php:63 87 #: admin/class-admin.php:84 71 88 #: includes/class-product-data.php:144 72 89 msgid "Compliance" 73 90 msgstr "" 74 91 75 #: admin/class-admin.php:7 392 #: admin/class-admin.php:76 76 93 msgid "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." 94 msgstr "" 95 96 #: admin/class-review-prompt.php:57 97 msgid "Did that save you some time?" 98 msgstr "" 99 100 #: admin/class-review-prompt.php:58 101 msgid "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." 102 msgstr "" 103 104 #: admin/class-review-prompt.php:59 105 msgid "Rate it on WordPress.org" 106 msgstr "" 107 108 #: admin/class-review-prompt.php:60 109 msgid "No thanks" 110 msgstr "" 111 112 #: admin/class-review-prompt.php:61 113 msgid "Rating opens wordpress.org in a new tab. Asked once — whichever you choose, this will not appear again." 114 msgstr "" 115 116 #: admin/class-review-prompt.php:62 117 msgid "You are not allowed to do that." 77 118 msgstr "" 78 119 … … 203 244 204 245 #: admin/views/operator-fields.php:26 205 #: admin/views/operators.php: 80246 #: admin/views/operators.php:117 206 247 msgid "Role" 207 248 msgstr "" … … 212 253 213 254 #: admin/views/operator-fields.php:30 214 #: admin/views/operators.php: 83255 #: admin/views/operators.php:120 215 256 #: includes/class-fields.php:117 216 257 msgid "Manufacturer" … … 218 259 219 260 #: admin/views/operator-fields.php:31 220 #: admin/views/operators.php: 84261 #: admin/views/operators.php:121 221 262 #: includes/class-fields.php:121 222 263 msgid "EU responsible person" … … 248 289 249 290 #: admin/views/operator-fields.php:57 250 #: admin/views/operators.php:1 45291 #: admin/views/operators.php:182 251 292 msgid "Country" 252 293 msgstr "" … … 264 305 msgstr "" 265 306 266 #: admin/views/operators.php:102 307 #: admin/views/operators.php:104 308 msgid "No operator records yet. Add the manufacturer of your products first — most catalogues need only a handful of records in total." 309 msgstr "" 310 311 #: admin/views/operators.php:109 312 msgid "Assign to many products at once" 313 msgstr "" 314 315 #: admin/views/operators.php:126 316 #: includes/class-operators.php:161 317 msgid "Operator record" 318 msgstr "" 319 320 #: admin/views/operators.php:136 321 msgid "Limit to category" 322 msgstr "" 323 324 #: admin/views/operators.php:139 267 325 msgid "All products" 268 326 msgstr "" 269 327 270 #: admin/views/operators.php:1 16328 #: admin/views/operators.php:153 271 329 msgid "Existing values" 272 330 msgstr "" 273 331 274 #: admin/views/operators.php:1 20332 #: admin/views/operators.php:157 275 333 msgid "Only fill in products that have no value yet" 276 334 msgstr "" 277 335 278 336 #. translators: %s: the number of products handled per run. 279 #: admin/views/operators.php:1 26337 #: admin/views/operators.php:163 280 338 msgid "Up to %s products per run. If there are more, run it again." 281 339 msgstr "" 282 340 283 #: admin/views/operators.php:1 35341 #: admin/views/operators.php:172 284 342 msgid "Assign" 285 343 msgstr "" 286 344 287 #: admin/views/operators.php:1 38345 #: admin/views/operators.php:175 288 346 msgid "Your records" 289 347 msgstr "" 290 348 291 #: admin/views/operators.php:1 41349 #: admin/views/operators.php:178 292 350 msgid "Operator records and whether each is complete" 293 351 msgstr "" 294 352 295 #: admin/views/operators.php:1 44353 #: admin/views/operators.php:181 296 354 msgid "Name" 297 355 msgstr "" 298 356 299 #: admin/views/operators.php:1 46357 #: admin/views/operators.php:183 300 358 msgid "Details" 301 359 msgstr "" 302 360 303 #: admin/views/operators.php:1 60361 #: admin/views/operators.php:197 304 362 msgid "not set" 305 363 msgstr "" 306 364 307 #: admin/views/operators.php: 163365 #: admin/views/operators.php:200 308 366 msgid "outside the EU" 309 367 msgstr "" 310 368 311 #: admin/views/operators.php: 170369 #: admin/views/operators.php:207 312 370 msgid "Complete" 313 371 msgstr "" 314 372 315 #: admin/views/operators.php: 171373 #: admin/views/operators.php:208 316 374 msgid "Needs a postal address and an email or web address" 317 375 msgstr "" 318 376 319 #: admin/views/operators.php:3 1377 #: admin/views/operators.php:37 320 378 msgid "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." 321 379 msgstr "" 322 380 381 #: admin/views/operators.php:53 382 msgid "Nothing needed changing: there are no products in the category you chose." 383 msgstr "" 384 385 #: admin/views/operators.php:55 386 msgid "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." 387 msgstr "" 388 389 #: admin/views/operators.php:57 390 msgid "Nothing needed changing: every product in scope already uses this operator record for this role." 391 msgstr "" 392 323 393 #. translators: %s: number of products updated. 324 #: admin/views/operators.php: 40394 #: admin/views/operators.php:68 325 395 msgid "%s product updated." 326 396 msgid_plural "%s products updated." … … 328 398 msgstr[1] "" 329 399 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 401 msgid "There are more products in scope still to do — run it again with the same choices to continue." 402 msgstr "" 403 404 #: admin/views/operators.php:75 405 msgid "That was every product in scope." 406 msgstr "" 407 408 #: admin/views/operators.php:81 335 409 msgid "Nothing was assigned. Choose an operator record and try again." 336 410 msgstr "" 337 411 338 #: admin/views/operators.php: 57412 #: admin/views/operators.php:94 339 413 #: includes/class-operators.php:162 340 414 msgid "Add operator record" 341 415 msgstr "" 342 416 343 #: admin/views/operators.php: 60417 #: admin/views/operators.php:97 344 418 msgid "Manage all records" 345 msgstr ""346 347 #: admin/views/operators.php:67348 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:72352 msgid "Assign to many products at once"353 msgstr ""354 355 #: admin/views/operators.php:89356 #: includes/class-operators.php:161357 msgid "Operator record"358 msgstr ""359 360 #: admin/views/operators.php:99361 msgid "Limit to category"362 419 msgstr "" 363 420 … … 452 509 453 510 #: admin/views/pro.php:63 454 msgid "Audit a catalogue and export a dossier from a script, across as many stores as you run."511 msgid "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." 455 512 msgstr "" 456 513 … … 536 593 msgstr "" 537 594 538 #: admin/views/settings.php:107 595 #: admin/views/settings.php:105 596 msgid "Notice wording" 597 msgstr "" 598 599 #: admin/views/settings.php:108 600 msgid "Leave empty to use the built-in plain-language summary." 601 msgstr "" 602 603 #: admin/views/settings.php:116 539 604 msgid "What this plugin does not do" 540 605 msgstr "" 541 606 542 #: admin/views/settings.php:11 0543 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 contactany external service, and it never states that your store is compliant. Nothing here is legal advice."607 #: admin/views/settings.php:119 608 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 sends none of your product information to any external service, and it never states that your store is compliant. Nothing here is legal advice." 544 609 msgstr "" 545 610 … … 573 638 574 639 #. 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 641 msgid "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." 642 msgid_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." 643 msgstr[0] "" 644 msgstr[1] "" 578 645 579 646 #. translators: %s: the date the obligation started. 580 #: admin/views/settings.php: 64647 #: admin/views/settings.php:73 581 648 msgid "Mandatory since %s under Commission Implementing Regulation (EU) 2025/1960." 582 649 msgstr "" 583 650 584 #: admin/views/settings.php: 73651 #: admin/views/settings.php:82 585 652 msgid "Read this before relying on the default wording." 586 653 msgstr "" 587 654 588 #: admin/views/settings.php: 74655 #: admin/views/settings.php:83 589 656 msgid "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." 590 657 msgstr "" 591 658 592 #: admin/views/settings.php:8 0659 #: admin/views/settings.php:89 593 660 msgid "Display" 594 661 msgstr "" 595 662 596 #: admin/views/settings.php: 84663 #: admin/views/settings.php:93 597 664 msgid "Show the legal guarantee notice on product pages" 598 665 msgstr "" 599 666 600 #: admin/views/settings.php:9 0667 #: admin/views/settings.php:99 601 668 msgid "Start showing it now, before the obligation begins" 602 669 msgstr "" 603 670 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 613 672 msgid "You are not allowed to edit product compliance data." 614 673 msgstr "" … … 622 681 msgstr "" 623 682 624 #: includes/class-evaluator.php:1 29683 #: includes/class-evaluator.php:145 625 684 msgid "Product image" 626 685 msgstr "" … … 726 785 msgstr "" 727 786 728 #: includes/class-operators.php:3 31787 #: includes/class-operators.php:354 729 788 msgid "Operator details" 730 789 msgstr "" -
lodestone-product-compliance/tags/1.0.2/lodestone-product-compliance.php
r3705566 r3713160 2 2 /** 3 3 * Plugin Name: Lodestone Product Compliance – GPSR for WooCommerce 4 * Plugin URI: https://madebylodestone.com/product-compliance 4 5 * 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. 16 * Version: 1.0.2 6 7 * Requires at least: 6.4 7 8 * Requires PHP: 7.4 … … 19 20 defined( 'ABSPATH' ) || exit; 20 21 21 define( 'LODECOMPLY_VERSION', '1.0. 0' );22 define( 'LODECOMPLY_VERSION', '1.0.2' ); 22 23 define( 'LODECOMPLY_FILE', __FILE__ ); 23 24 -
lodestone-product-compliance/tags/1.0.2/readme.txt
r3705566 r3713160 6 6 Requires PHP: 7.4 7 7 Requires Plugins: woocommerce 8 Stable tag: 1.0. 18 Stable tag: 1.0.2 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 33 33 * Stores manufacturers and EU responsible persons as **reusable records**. One importer behind 300 34 34 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. 36 37 * Shows the information **in the offer** — a block under the add-to-cart area and an optional 37 38 product tab — and lets you switch either off if your theme already prints it. … … 64 65 a printable per-product compliance record, a CSV export, coverage over time, per-language warning 65 66 coverage for multilingual stores, the GARAN durability guarantee, and WP-CLI. 67 68 = One question, asked once = 69 70 We ask you for exactly one thing, once. After a bulk assignment fills five or more products in one 71 step, the screen you land on asks whether you would rate this plugin on WordPress.org. It is not an 72 admin notice, it is only shown to people who can manage plugins, and whichever button you press — 73 including "No thanks" — it never appears again. 66 74 67 75 == Frequently Asked Questions == … … 108 116 = Does it send my data anywhere? = 109 117 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. 118 No. The plugin itself makes no outbound requests, and nothing about your products or customers 119 leaves your site. Licensing is handled by the Freemius SDK, which sends nothing on its own unless 120 you opt in. Two things you do yourself do reach Freemius: opening the upgrade page loads its 121 prices and checkout, and the optional feedback form shown when you deactivate is sent if you fill 122 it in. 112 123 113 124 = Will it slow down my product pages? = … … 118 129 = I have 5,000 products. Will the audit handle that? = 119 130 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 131 Yes. There is no limit on the number of products. Every product is checked the moment it is saved in 132 wp-admin, and the ones nobody has saved there since you installed the plugin are checked 100 at a time each time you 133 open the dashboard, rather than all at once, so a large catalogue fills in over repeated visits 134 instead of timing out — 5,000 products take about fifty. The screen tells you how many have been 122 135 checked so far. 123 136 … … 131 144 == Changelog == 132 145 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 133 165 = 1.0.1 = 134 166 * 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 8 8 return array( 9 9 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 10 'Portfolio\\Core\\Csv' => $vendorDir . '/portfolio/core/src/Csv.php', 10 11 'Portfolio\\Core\\Freemius' => $vendorDir . '/portfolio/core/src/Freemius.php', 11 12 'Portfolio\\Core\\Notices' => $vendorDir . '/portfolio/core/src/Notices.php', 12 13 'Portfolio\\Core\\Plugin' => $vendorDir . '/portfolio/core/src/Plugin.php', 13 14 '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', 14 17 ); -
lodestone-product-compliance/tags/1.0.2/vendor/composer/autoload_static.php
r3705566 r3713160 27 27 public static $classMap = array ( 28 28 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 29 'Portfolio\\Core\\Csv' => __DIR__ . '/..' . '/portfolio/core/src/Csv.php', 29 30 'Portfolio\\Core\\Freemius' => __DIR__ . '/..' . '/portfolio/core/src/Freemius.php', 30 31 'Portfolio\\Core\\Notices' => __DIR__ . '/..' . '/portfolio/core/src/Notices.php', 31 32 'Portfolio\\Core\\Plugin' => __DIR__ . '/..' . '/portfolio/core/src/Plugin.php', 32 33 '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', 33 36 ); 34 37 -
lodestone-product-compliance/tags/1.0.2/vendor/composer/installed.php
r3705566 r3713160 2 2 'root' => array( 3 3 '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', 7 7 'type' => 'project', 8 8 'install_path' => __DIR__ . '/../../', … … 30 30 ), 31 31 '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', 35 35 'type' => 'project', 36 36 'install_path' => __DIR__ . '/../../', -
lodestone-product-compliance/tags/1.0.2/vendor/portfolio/core/src/Freemius.php
r3705566 r3713160 42 42 } 43 43 44 returnfs_dynamic_init( [44 $fs = fs_dynamic_init( [ 45 45 'id' => $args['id'], 46 46 'slug' => $args['slug'], … … 101 101 'is_org_compliant' => $args['is_org_compliant'] ?? true, 102 102 ] ); 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; 103 152 } 104 153 } -
lodestone-product-compliance/tags/1.0.2/vendor/portfolio/core/src/Notices.php
r3705566 r3713160 39 39 40 40 private string $slug; 41 private string $cap; 41 42 private array $queued = []; 42 43 … … 47 48 * own screens. Optional only so that a caller written before this 48 49 * 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. 49 55 */ 50 public function __construct( string $slug, string $screen = '' ) {56 public function __construct( string $slug, string $screen = '', string $cap = 'manage_options' ) { 51 57 $this->slug = $slug; 58 $this->cap = '' !== $cap ? $cap : 'manage_options'; 52 59 53 60 // Registered, not stored: the only consumer is the static check below, which has to … … 160 167 public function ajax_dismiss(): void { 161 168 check_ajax_referer( $this->slug . '_dismiss', 'nonce' ); 162 if ( ! current_user_can( 'manage_options') ) {169 if ( ! current_user_can( $this->cap ) ) { 163 170 wp_send_json_error( null, 403 ); 164 171 } -
lodestone-product-compliance/trunk/admin/class-admin.php
r3705566 r3713160 42 42 add_filter( 'posts_where', [ $this, 'warm_where' ], 10, 2 ); 43 43 44 // The rating prompt's handler. Its panel is printed by the operators view. 45 ( new Review_Prompt() )->hooks(); 46 44 47 $this->maybe_add_notice(); 45 48 } … … 128 131 // PREFIX, not SLUG: Notices derives its option names from this, and Uninstaller 129 132 // 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' ); 131 134 } 132 135 … … 205 208 if ( 'dashboard' === $view ) { 206 209 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(); 207 213 } 208 214 … … 212 218 213 219 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 ); 214 235 215 236 echo '</div>'; … … 226 247 */ 227 248 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 228 257 $cursor = (int) get_option( self::OPTION_WARM_CURSOR, 0 ); 229 258 … … 237 266 * revisiting products that were indexed and later changed -- which does not matter, 238 267 * 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. 239 275 */ 240 276 $product_ids = get_posts( … … 249 285 'update_post_meta_cache' => false, 250 286 'update_post_term_cache' => false, 251 'lodecomply_min_id' => $cursor, 287 'suppress_filters' => false, 288 'lodecomply_min_id' => $cursor, 252 289 ] 253 290 ); … … 266 303 267 304 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 ); 268 324 } 269 325 -
lodestone-product-compliance/trunk/admin/views/dashboard.php
r3705566 r3713160 169 169 <th scope="row"> 170 170 <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 ) ); ?> 172 172 </a> 173 173 </th> -
lodestone-product-compliance/trunk/admin/views/operators.php
r3705566 r3713160 16 16 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result. 17 17 $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'] ) ); 18 24 19 25 $lodecomply_categories = get_terms( … … 32 38 </p> 33 39 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 ) : ?> 35 63 <div class="notice notice-success inline"> 36 64 <p> … … 42 70 ); 43 71 ?> 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' ); ?> 46 76 <?php endif; ?> 47 77 </p> … … 52 82 </div> 53 83 <?php endif; ?> 84 85 <?php 86 // The rating prompt, right after a bulk assignment that filled a catalogue. Review_Prompt holds every rule. 87 if ( Review_Prompt::should_show() ) { 88 Review_Prompt::render(); 89 } 90 ?> 54 91 55 92 <p> -
lodestone-product-compliance/trunk/admin/views/pro.php
r3705566 r3713160 61 61 <li> 62 62 <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' ); ?> 64 64 </li> 65 65 </ul> -
lodestone-product-compliance/trunk/admin/views/settings.php
r3705566 r3713160 53 53 <?php 54 54 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. 55 57 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 ), 58 67 esc_html( number_format_i18n( $lodecomply_days ) ), 59 68 esc_html( date_i18n( (string) get_option( 'date_format' ), (int) strtotime( LODECOMPLY_GUARANTEE_NOTICE_DATE ) ) ) … … 108 117 109 118 <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 contactany 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' ); ?> 111 120 </p> -
lodestone-product-compliance/trunk/assets/css/admin.css
r3705566 r3713160 117 117 color: #8a2424; 118 118 } 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 56 56 } 57 57 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 58 100 public static function load( string $class_name ): void { 59 101 if ( 0 !== strpos( $class_name, self::PREFIX ) ) { -
lodestone-product-compliance/trunk/includes/class-bulk.php
r3705566 r3713160 11 11 * be run again; that is a slower interaction than a progress bar but it cannot half-finish a 12 12 * 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. 13 21 * 14 22 * @package LodeComply … … 42 50 // phpcs:enable WordPress.Security.NonceVerification.Missing 43 51 44 $assigned = 0; 45 $notice = 'error'; 52 $args = [ 53 'view' => 'operators', 54 'lodecomply_notice' => 'error', 55 ]; 46 56 47 57 /* … … 57 67 && Operators::POST_TYPE === get_post_type( $operator_id ) 58 68 ) { 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 ) ); 72 89 73 90 exit; … … 76 93 /** 77 94 * 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. 78 99 * 79 100 * @param string $field 'manufacturer' or 'responsible_person'. … … 81 102 * @param int $term_id Product category to limit to, or 0 for the whole catalogue. 82 103 * @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. 84 106 */ 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 { 86 108 $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; 97 114 98 115 if ( $only_empty ) { … … 117 134 ], 118 135 ]; 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 ]; 120 205 121 206 if ( $term_id > 0 ) { … … 129 214 } 130 215 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; 144 217 } 145 218 } -
lodestone-product-compliance/trunk/includes/class-evaluator.php
r3705566 r3713160 26 26 public const STATE_EXEMPT = 'exempt'; 27 27 28 /** Products re-evaluated synchronously when an operator record changes. */ 29 public const REEVALUATE_LIMIT = 500; 30 28 31 /** 29 32 * Evaluates one product and writes the result to the index. … … 34 37 $payload = Fields::payload( $product_id ); 35 38 $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 } 36 52 37 53 Status_Store::write_status( $product_id, $result['state'], $result['missing'] ); … … 133 149 134 150 /** 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 /** 135 168 * Re-evaluates every product currently pointing at an operator record. 136 169 * 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 { 143 186 if ( $operator_id <= 0 ) { 144 187 return; 145 188 } 146 189 190 // Ids only, so even a very large set is a small array; the cost is in evaluating them. 147 191 $product_ids = get_posts( 148 192 [ 149 193 'post_type' => 'product', 150 194 'post_status' => 'any', 151 'posts_per_page' => 500, 195 'posts_per_page' => -1, 196 'orderby' => 'ID', 197 'order' => 'ASC', 152 198 'fields' => 'ids', 153 199 'no_found_rows' => true, … … 168 214 ); 169 215 170 foreach ( $product_ids as $product_id ) { 216 $limit = max( 0, $limit ); 217 218 foreach ( array_slice( $product_ids, 0, $limit ) as $product_id ) { 171 219 self::evaluate( (int) $product_id ); 172 220 } 221 222 Status_Store::delete_statuses( array_slice( $product_ids, $limit ) ); 173 223 } 174 224 -
lodestone-product-compliance/trunk/includes/class-operators.php
r3705566 r3713160 249 249 250 250 /** 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. 255 263 * 256 264 * @return array<string, string> 257 265 */ 258 266 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 { 259 282 if ( $operator_id <= 0 ) { 260 283 return []; … … 343 366 wp_nonce_field( 'lodecomply_operator_save', 'lodecomply_operator_nonce' ); 344 367 345 $values = self:: values( (int) $post->ID );368 $values = self::stored_values( (int) $post->ID ); 346 369 347 370 require lodecomply()->dir( 'admin/views/operator-fields.php' ); -
lodestone-product-compliance/trunk/includes/class-plugin.php
r3705566 r3713160 96 96 97 97 ( new Operators() )->hooks(); 98 ( new Index() )->hooks(); 98 99 ( new Display() )->hooks(); 99 100 ( new Guarantee() )->hooks(); -
lodestone-product-compliance/trunk/includes/class-status-store.php
r3705566 r3713160 101 101 102 102 /** 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 /** 103 140 * Counts by state, for the dashboard headline. 104 141 * -
lodestone-product-compliance/trunk/includes/freemius.php
r3705566 r3713160 64 64 'public_key' => LODECOMPLY_FS_PUBLIC_KEY, 65 65 'menu_slug' => \LodeComply\Plugin::MENU_SLUG, 66 'is_premium' => defined( 'LODECOMPLY_IS_PREMIUM' ) && LODECOMPLY_IS_PREMIUM,66 'is_premium' => \LodeComply\Autoloader::is_premium_build(), 67 67 ] 68 68 ); -
lodestone-product-compliance/trunk/languages/lodestone-product-compliance.pot
r3705566 r3713160 5 5 "Project-Id-Version: Lodestone Product Compliance – GPSR for WooCommerce\n" 6 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/lodestone-product-compliance/\n" 7 "POT-Creation-Date: 2026-09-2 0 01:30:06+0000\n"7 "POT-Creation-Date: 2026-09-25 13:43:40+0000\n" 8 8 "MIME-Version: 1.0\n" 9 9 "Content-Type: text/plain; charset=UTF-8\n" … … 13 13 14 14 #. translators: %s: link to the plugin's dashboard, with anchor text. 15 #: admin/class-admin.php:1 1915 #: admin/class-admin.php:122 16 16 msgid "Product Compliance is ready. See which products are still missing GPSR information in %s." 17 17 msgstr "" 18 18 19 19 #. translators: %s: link to the plugin's dashboard, with anchor text. 20 #: admin/class-admin.php:12 020 #: admin/class-admin.php:123 21 21 msgid "the Compliance dashboard" 22 22 msgstr "" 23 23 24 #: admin/class-admin.php:19 424 #: admin/class-admin.php:197 25 25 msgid "You are not allowed to view product compliance data." 26 26 msgstr "" 27 27 28 #: admin/class-admin.php:347 28 #: admin/class-admin.php:229 29 msgid "Questions?" 30 msgstr "" 31 32 #: admin/class-admin.php:230 33 msgid "Ask in the WordPress.org support forum" 34 msgstr "" 35 36 #: admin/class-admin.php:232 37 msgid "Pro customers can also write to" 38 msgstr "" 39 40 #. translators: %d: product ID. 41 #: admin/class-admin.php:323 42 msgid "Product #%d (no title)" 43 msgstr "" 44 45 #: admin/class-admin.php:403 29 46 msgid "Catalogue" 30 47 msgstr "" 31 48 32 #: admin/class-admin.php: 34833 #: admin/views/operators.php: 2849 #: admin/class-admin.php:404 50 #: admin/views/operators.php:34 34 51 #: includes/class-operators.php:160 35 52 msgid "Operator records" 36 53 msgstr "" 37 54 38 #: admin/class-admin.php: 34955 #: admin/class-admin.php:405 39 56 #: admin/views/settings.php:17 40 57 msgid "Settings" 41 58 msgstr "" 42 59 43 #: admin/class-admin.php: 36060 #: admin/class-admin.php:416 44 61 msgid "Languages" 45 62 msgstr "" 46 63 47 #: admin/class-admin.php: 36164 #: admin/class-admin.php:417 48 65 msgid "Coverage" 49 66 msgstr "" 50 67 51 #: admin/class-admin.php: 37268 #: admin/class-admin.php:428 52 69 msgid "Pro" 53 70 msgstr "" 54 71 55 #: admin/class-admin.php: 37572 #: admin/class-admin.php:431 56 73 msgid "Product Compliance sections" 57 74 msgstr "" 58 75 59 #: admin/class-admin.php: 39976 #: admin/class-admin.php:455 60 77 msgid "You are not allowed to change these settings." 61 78 msgstr "" 62 79 63 #: admin/class-admin.php: 5964 #: admin/class-admin.php:7 265 #: admin/class-admin.php:8 080 #: admin/class-admin.php:62 81 #: admin/class-admin.php:75 82 #: admin/class-admin.php:83 66 83 msgid "Product Compliance" 67 84 msgstr "" 68 85 69 #: admin/class-admin.php:6 070 #: admin/class-admin.php:8 186 #: admin/class-admin.php:63 87 #: admin/class-admin.php:84 71 88 #: includes/class-product-data.php:144 72 89 msgid "Compliance" 73 90 msgstr "" 74 91 75 #: admin/class-admin.php:7 392 #: admin/class-admin.php:76 76 93 msgid "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." 94 msgstr "" 95 96 #: admin/class-review-prompt.php:57 97 msgid "Did that save you some time?" 98 msgstr "" 99 100 #: admin/class-review-prompt.php:58 101 msgid "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." 102 msgstr "" 103 104 #: admin/class-review-prompt.php:59 105 msgid "Rate it on WordPress.org" 106 msgstr "" 107 108 #: admin/class-review-prompt.php:60 109 msgid "No thanks" 110 msgstr "" 111 112 #: admin/class-review-prompt.php:61 113 msgid "Rating opens wordpress.org in a new tab. Asked once — whichever you choose, this will not appear again." 114 msgstr "" 115 116 #: admin/class-review-prompt.php:62 117 msgid "You are not allowed to do that." 77 118 msgstr "" 78 119 … … 203 244 204 245 #: admin/views/operator-fields.php:26 205 #: admin/views/operators.php: 80246 #: admin/views/operators.php:117 206 247 msgid "Role" 207 248 msgstr "" … … 212 253 213 254 #: admin/views/operator-fields.php:30 214 #: admin/views/operators.php: 83255 #: admin/views/operators.php:120 215 256 #: includes/class-fields.php:117 216 257 msgid "Manufacturer" … … 218 259 219 260 #: admin/views/operator-fields.php:31 220 #: admin/views/operators.php: 84261 #: admin/views/operators.php:121 221 262 #: includes/class-fields.php:121 222 263 msgid "EU responsible person" … … 248 289 249 290 #: admin/views/operator-fields.php:57 250 #: admin/views/operators.php:1 45291 #: admin/views/operators.php:182 251 292 msgid "Country" 252 293 msgstr "" … … 264 305 msgstr "" 265 306 266 #: admin/views/operators.php:102 307 #: admin/views/operators.php:104 308 msgid "No operator records yet. Add the manufacturer of your products first — most catalogues need only a handful of records in total." 309 msgstr "" 310 311 #: admin/views/operators.php:109 312 msgid "Assign to many products at once" 313 msgstr "" 314 315 #: admin/views/operators.php:126 316 #: includes/class-operators.php:161 317 msgid "Operator record" 318 msgstr "" 319 320 #: admin/views/operators.php:136 321 msgid "Limit to category" 322 msgstr "" 323 324 #: admin/views/operators.php:139 267 325 msgid "All products" 268 326 msgstr "" 269 327 270 #: admin/views/operators.php:1 16328 #: admin/views/operators.php:153 271 329 msgid "Existing values" 272 330 msgstr "" 273 331 274 #: admin/views/operators.php:1 20332 #: admin/views/operators.php:157 275 333 msgid "Only fill in products that have no value yet" 276 334 msgstr "" 277 335 278 336 #. translators: %s: the number of products handled per run. 279 #: admin/views/operators.php:1 26337 #: admin/views/operators.php:163 280 338 msgid "Up to %s products per run. If there are more, run it again." 281 339 msgstr "" 282 340 283 #: admin/views/operators.php:1 35341 #: admin/views/operators.php:172 284 342 msgid "Assign" 285 343 msgstr "" 286 344 287 #: admin/views/operators.php:1 38345 #: admin/views/operators.php:175 288 346 msgid "Your records" 289 347 msgstr "" 290 348 291 #: admin/views/operators.php:1 41349 #: admin/views/operators.php:178 292 350 msgid "Operator records and whether each is complete" 293 351 msgstr "" 294 352 295 #: admin/views/operators.php:1 44353 #: admin/views/operators.php:181 296 354 msgid "Name" 297 355 msgstr "" 298 356 299 #: admin/views/operators.php:1 46357 #: admin/views/operators.php:183 300 358 msgid "Details" 301 359 msgstr "" 302 360 303 #: admin/views/operators.php:1 60361 #: admin/views/operators.php:197 304 362 msgid "not set" 305 363 msgstr "" 306 364 307 #: admin/views/operators.php: 163365 #: admin/views/operators.php:200 308 366 msgid "outside the EU" 309 367 msgstr "" 310 368 311 #: admin/views/operators.php: 170369 #: admin/views/operators.php:207 312 370 msgid "Complete" 313 371 msgstr "" 314 372 315 #: admin/views/operators.php: 171373 #: admin/views/operators.php:208 316 374 msgid "Needs a postal address and an email or web address" 317 375 msgstr "" 318 376 319 #: admin/views/operators.php:3 1377 #: admin/views/operators.php:37 320 378 msgid "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." 321 379 msgstr "" 322 380 381 #: admin/views/operators.php:53 382 msgid "Nothing needed changing: there are no products in the category you chose." 383 msgstr "" 384 385 #: admin/views/operators.php:55 386 msgid "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." 387 msgstr "" 388 389 #: admin/views/operators.php:57 390 msgid "Nothing needed changing: every product in scope already uses this operator record for this role." 391 msgstr "" 392 323 393 #. translators: %s: number of products updated. 324 #: admin/views/operators.php: 40394 #: admin/views/operators.php:68 325 395 msgid "%s product updated." 326 396 msgid_plural "%s products updated." … … 328 398 msgstr[1] "" 329 399 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 401 msgid "There are more products in scope still to do — run it again with the same choices to continue." 402 msgstr "" 403 404 #: admin/views/operators.php:75 405 msgid "That was every product in scope." 406 msgstr "" 407 408 #: admin/views/operators.php:81 335 409 msgid "Nothing was assigned. Choose an operator record and try again." 336 410 msgstr "" 337 411 338 #: admin/views/operators.php: 57412 #: admin/views/operators.php:94 339 413 #: includes/class-operators.php:162 340 414 msgid "Add operator record" 341 415 msgstr "" 342 416 343 #: admin/views/operators.php: 60417 #: admin/views/operators.php:97 344 418 msgid "Manage all records" 345 msgstr ""346 347 #: admin/views/operators.php:67348 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:72352 msgid "Assign to many products at once"353 msgstr ""354 355 #: admin/views/operators.php:89356 #: includes/class-operators.php:161357 msgid "Operator record"358 msgstr ""359 360 #: admin/views/operators.php:99361 msgid "Limit to category"362 419 msgstr "" 363 420 … … 452 509 453 510 #: admin/views/pro.php:63 454 msgid "Audit a catalogue and export a dossier from a script, across as many stores as you run."511 msgid "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." 455 512 msgstr "" 456 513 … … 536 593 msgstr "" 537 594 538 #: admin/views/settings.php:107 595 #: admin/views/settings.php:105 596 msgid "Notice wording" 597 msgstr "" 598 599 #: admin/views/settings.php:108 600 msgid "Leave empty to use the built-in plain-language summary." 601 msgstr "" 602 603 #: admin/views/settings.php:116 539 604 msgid "What this plugin does not do" 540 605 msgstr "" 541 606 542 #: admin/views/settings.php:11 0543 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 contactany external service, and it never states that your store is compliant. Nothing here is legal advice."607 #: admin/views/settings.php:119 608 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 sends none of your product information to any external service, and it never states that your store is compliant. Nothing here is legal advice." 544 609 msgstr "" 545 610 … … 573 638 574 639 #. 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 641 msgid "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." 642 msgid_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." 643 msgstr[0] "" 644 msgstr[1] "" 578 645 579 646 #. translators: %s: the date the obligation started. 580 #: admin/views/settings.php: 64647 #: admin/views/settings.php:73 581 648 msgid "Mandatory since %s under Commission Implementing Regulation (EU) 2025/1960." 582 649 msgstr "" 583 650 584 #: admin/views/settings.php: 73651 #: admin/views/settings.php:82 585 652 msgid "Read this before relying on the default wording." 586 653 msgstr "" 587 654 588 #: admin/views/settings.php: 74655 #: admin/views/settings.php:83 589 656 msgid "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." 590 657 msgstr "" 591 658 592 #: admin/views/settings.php:8 0659 #: admin/views/settings.php:89 593 660 msgid "Display" 594 661 msgstr "" 595 662 596 #: admin/views/settings.php: 84663 #: admin/views/settings.php:93 597 664 msgid "Show the legal guarantee notice on product pages" 598 665 msgstr "" 599 666 600 #: admin/views/settings.php:9 0667 #: admin/views/settings.php:99 601 668 msgid "Start showing it now, before the obligation begins" 602 669 msgstr "" 603 670 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 613 672 msgid "You are not allowed to edit product compliance data." 614 673 msgstr "" … … 622 681 msgstr "" 623 682 624 #: includes/class-evaluator.php:1 29683 #: includes/class-evaluator.php:145 625 684 msgid "Product image" 626 685 msgstr "" … … 726 785 msgstr "" 727 786 728 #: includes/class-operators.php:3 31787 #: includes/class-operators.php:354 729 788 msgid "Operator details" 730 789 msgstr "" -
lodestone-product-compliance/trunk/lodestone-product-compliance.php
r3705566 r3713160 2 2 /** 3 3 * Plugin Name: Lodestone Product Compliance – GPSR for WooCommerce 4 * Plugin URI: https://madebylodestone.com/product-compliance 4 5 * 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. 16 * Version: 1.0.2 6 7 * Requires at least: 6.4 7 8 * Requires PHP: 7.4 … … 19 20 defined( 'ABSPATH' ) || exit; 20 21 21 define( 'LODECOMPLY_VERSION', '1.0. 0' );22 define( 'LODECOMPLY_VERSION', '1.0.2' ); 22 23 define( 'LODECOMPLY_FILE', __FILE__ ); 23 24 -
lodestone-product-compliance/trunk/readme.txt
r3705566 r3713160 6 6 Requires PHP: 7.4 7 7 Requires Plugins: woocommerce 8 Stable tag: 1.0. 18 Stable tag: 1.0.2 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 33 33 * Stores manufacturers and EU responsible persons as **reusable records**. One importer behind 300 34 34 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. 36 37 * Shows the information **in the offer** — a block under the add-to-cart area and an optional 37 38 product tab — and lets you switch either off if your theme already prints it. … … 64 65 a printable per-product compliance record, a CSV export, coverage over time, per-language warning 65 66 coverage for multilingual stores, the GARAN durability guarantee, and WP-CLI. 67 68 = One question, asked once = 69 70 We ask you for exactly one thing, once. After a bulk assignment fills five or more products in one 71 step, the screen you land on asks whether you would rate this plugin on WordPress.org. It is not an 72 admin notice, it is only shown to people who can manage plugins, and whichever button you press — 73 including "No thanks" — it never appears again. 66 74 67 75 == Frequently Asked Questions == … … 108 116 = Does it send my data anywhere? = 109 117 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. 118 No. The plugin itself makes no outbound requests, and nothing about your products or customers 119 leaves your site. Licensing is handled by the Freemius SDK, which sends nothing on its own unless 120 you opt in. Two things you do yourself do reach Freemius: opening the upgrade page loads its 121 prices and checkout, and the optional feedback form shown when you deactivate is sent if you fill 122 it in. 112 123 113 124 = Will it slow down my product pages? = … … 118 129 = I have 5,000 products. Will the audit handle that? = 119 130 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 131 Yes. There is no limit on the number of products. Every product is checked the moment it is saved in 132 wp-admin, and the ones nobody has saved there since you installed the plugin are checked 100 at a time each time you 133 open the dashboard, rather than all at once, so a large catalogue fills in over repeated visits 134 instead of timing out — 5,000 products take about fifty. The screen tells you how many have been 122 135 checked so far. 123 136 … … 131 144 == Changelog == 132 145 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 133 165 = 1.0.1 = 134 166 * Listed as "Lodestone Product Compliance - GPSR for WooCommerce", so it can be -
lodestone-product-compliance/trunk/vendor/composer/autoload_classmap.php
r3705566 r3713160 8 8 return array( 9 9 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 10 'Portfolio\\Core\\Csv' => $vendorDir . '/portfolio/core/src/Csv.php', 10 11 'Portfolio\\Core\\Freemius' => $vendorDir . '/portfolio/core/src/Freemius.php', 11 12 'Portfolio\\Core\\Notices' => $vendorDir . '/portfolio/core/src/Notices.php', 12 13 'Portfolio\\Core\\Plugin' => $vendorDir . '/portfolio/core/src/Plugin.php', 13 14 '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', 14 17 ); -
lodestone-product-compliance/trunk/vendor/composer/autoload_static.php
r3705566 r3713160 27 27 public static $classMap = array ( 28 28 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 29 'Portfolio\\Core\\Csv' => __DIR__ . '/..' . '/portfolio/core/src/Csv.php', 29 30 'Portfolio\\Core\\Freemius' => __DIR__ . '/..' . '/portfolio/core/src/Freemius.php', 30 31 'Portfolio\\Core\\Notices' => __DIR__ . '/..' . '/portfolio/core/src/Notices.php', 31 32 'Portfolio\\Core\\Plugin' => __DIR__ . '/..' . '/portfolio/core/src/Plugin.php', 32 33 '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', 33 36 ); 34 37 -
lodestone-product-compliance/trunk/vendor/composer/installed.php
r3705566 r3713160 2 2 'root' => array( 3 3 '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', 7 7 'type' => 'project', 8 8 'install_path' => __DIR__ . '/../../', … … 30 30 ), 31 31 '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', 35 35 'type' => 'project', 36 36 'install_path' => __DIR__ . '/../../', -
lodestone-product-compliance/trunk/vendor/portfolio/core/src/Freemius.php
r3705566 r3713160 42 42 } 43 43 44 returnfs_dynamic_init( [44 $fs = fs_dynamic_init( [ 45 45 'id' => $args['id'], 46 46 'slug' => $args['slug'], … … 101 101 'is_org_compliant' => $args['is_org_compliant'] ?? true, 102 102 ] ); 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; 103 152 } 104 153 } -
lodestone-product-compliance/trunk/vendor/portfolio/core/src/Notices.php
r3705566 r3713160 39 39 40 40 private string $slug; 41 private string $cap; 41 42 private array $queued = []; 42 43 … … 47 48 * own screens. Optional only so that a caller written before this 48 49 * 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. 49 55 */ 50 public function __construct( string $slug, string $screen = '' ) {56 public function __construct( string $slug, string $screen = '', string $cap = 'manage_options' ) { 51 57 $this->slug = $slug; 58 $this->cap = '' !== $cap ? $cap : 'manage_options'; 52 59 53 60 // Registered, not stored: the only consumer is the static check below, which has to … … 160 167 public function ajax_dismiss(): void { 161 168 check_ajax_referer( $this->slug . '_dismiss', 'nonce' ); 162 if ( ! current_user_can( 'manage_options') ) {169 if ( ! current_user_can( $this->cap ) ) { 163 170 wp_send_json_error( null, 403 ); 164 171 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)