Changeset 3705284
- Timestamp:
- 09/21/2026 09:48:42 AM (8 weeks ago)
- Location:
- oznaczai-image-labeling
- Files:
-
- 18 added
- 18 edited
- 1 copied
-
tags/1.7.0 (copied) (copied from oznaczai-image-labeling/trunk )
-
tags/1.7.0/assets/css/admin.css (modified) (2 diffs)
-
tags/1.7.0/assets/css/frontend.css (modified) (1 diff)
-
tags/1.7.0/assets/icons (added)
-
tags/1.7.0/assets/icons/eu (added)
-
tags/1.7.0/assets/icons/eu/README.txt (added)
-
tags/1.7.0/assets/icons/eu/eu-ai-black.svg (added)
-
tags/1.7.0/assets/icons/eu/eu-ai-generated-black.svg (added)
-
tags/1.7.0/assets/icons/eu/eu-ai-generated-white.svg (added)
-
tags/1.7.0/assets/icons/eu/eu-ai-modified-black.svg (added)
-
tags/1.7.0/assets/icons/eu/eu-ai-modified-white.svg (added)
-
tags/1.7.0/assets/icons/eu/eu-ai-white.svg (added)
-
tags/1.7.0/assets/js/admin.js (modified) (4 diffs)
-
tags/1.7.0/assets/js/frontend.js (modified) (35 diffs)
-
tags/1.7.0/changelog.txt (modified) (1 diff)
-
tags/1.7.0/includes/class-oznaczai-image-labeling-plugin.php (modified) (30 diffs)
-
tags/1.7.0/oznaczai.php (modified) (3 diffs)
-
tags/1.7.0/readme.txt (modified) (5 diffs)
-
tags/1.7.0/uninstall.php (modified) (1 diff)
-
trunk/assets/css/admin.css (modified) (2 diffs)
-
trunk/assets/css/frontend.css (modified) (1 diff)
-
trunk/assets/icons (added)
-
trunk/assets/icons/eu (added)
-
trunk/assets/icons/eu/README.txt (added)
-
trunk/assets/icons/eu/eu-ai-black.svg (added)
-
trunk/assets/icons/eu/eu-ai-generated-black.svg (added)
-
trunk/assets/icons/eu/eu-ai-generated-white.svg (added)
-
trunk/assets/icons/eu/eu-ai-modified-black.svg (added)
-
trunk/assets/icons/eu/eu-ai-modified-white.svg (added)
-
trunk/assets/icons/eu/eu-ai-white.svg (added)
-
trunk/assets/js/admin.js (modified) (4 diffs)
-
trunk/assets/js/frontend.js (modified) (35 diffs)
-
trunk/changelog.txt (modified) (1 diff)
-
trunk/includes/class-oznaczai-image-labeling-plugin.php (modified) (30 diffs)
-
trunk/oznaczai.php (modified) (3 diffs)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
oznaczai-image-labeling/tags/1.7.0/assets/css/admin.css
r3641997 r3705284 595 595 } 596 596 597 /* Image symbol variants inside the admin previews. Mirrors the frontend rule so 598 the preview keeps its WYSIWYG promise. */ 599 .oznaczai-preset-demo b.oznaczai-preview-symbol--image, 600 .oznaczai-preview-badge b.oznaczai-preview-symbol--image, 601 .oznaczai-symbol-preview b.oznaczai-preview-symbol--image { 602 min-width: 0; 603 width: calc(18px * var(--oznaczai-preview-icon-ratio, 1)); 604 height: 18px; 605 border-radius: 0; 606 background-color: transparent; 607 background-image: var(--oznaczai-preview-icon-on-light); 608 background-repeat: no-repeat; 609 background-position: center; 610 background-size: contain; 611 font-size: 0; 612 } 613 614 .oznaczai-preview-badge--minimal b.oznaczai-preview-symbol--image, 615 .oznaczai-preview-badge--dark b.oznaczai-preview-symbol--image, 616 .oznaczai-preview-badge--contrast-light b.oznaczai-preview-symbol--image { 617 background-image: var(--oznaczai-preview-icon-on-dark); 618 } 619 597 620 .oznaczai-preview-badge--icon-only { 598 621 width: 24px; … … 673 696 border-top: 1px solid #e4e8ef; 674 697 align-items: start; 698 } 699 700 .oznaczai-shortcode { 701 display: inline-block; 702 padding: 4px 8px; 703 border: 1px solid #e4e8ef; 704 border-radius: 4px; 705 background: #f6f7f9; 706 font-size: 12px; 707 } 708 709 .oznaczai-symbol-row { 710 display: grid; 711 grid-template-columns: minmax(0, 280px) minmax(0, 1fr); 712 gap: 12px 24px; 713 align-items: start; 714 margin-top: 18px; 715 padding-top: 16px; 716 border-top: 1px solid #e4e8ef; 717 } 718 719 .oznaczai-symbol-row > .description { 720 margin: 0; 721 align-self: center; 722 color: #6b7280; 723 font-size: 12px; 724 line-height: 1.5; 725 } 726 727 .oznaczai-symbol-row > .oznaczai-toggle { 728 grid-column: 1 / -1; 729 } 730 731 @media (max-width: 782px) { 732 .oznaczai-symbol-row { 733 grid-template-columns: minmax(0, 1fr); 734 } 675 735 } 676 736 -
oznaczai-image-labeling/tags/1.7.0/assets/css/frontend.css
r3641997 r3705284 258 258 } 259 259 260 /* AI transparency legend ([oznaczai_transparency_legend]). 261 Deliberately plain: it inherits the theme's typography and colours so it 262 reads as part of the page rather than as a plugin widget. */ 263 .oznaczai-legend { 264 margin: 0 0 1.5em; 265 } 266 267 .oznaczai-legend__title { 268 margin: 0 0 0.75em; 269 font-size: 1.15em; 270 } 271 272 .oznaczai-legend__list { 273 margin: 0; 274 padding: 0; 275 list-style: none; 276 } 277 278 .oznaczai-legend__item { 279 display: flex; 280 align-items: flex-start; 281 gap: 0.75em; 282 padding: 0.6em 0; 283 border-bottom: 1px solid rgba(127, 127, 127, 0.22); 284 } 285 286 .oznaczai-legend__item:last-child { 287 border-bottom: 0; 288 } 289 290 .oznaczai-legend__symbol { 291 display: inline-flex; 292 align-items: center; 293 justify-content: center; 294 flex: 0 0 auto; 295 width: 20px; 296 height: 20px; 297 margin-top: 0.15em; 298 border-radius: 3px; 299 background: rgba(127, 127, 127, 0.16); 300 font-size: 9px; 301 font-weight: 600; 302 line-height: 1; 303 } 304 305 .oznaczai-legend__symbol--image { 306 width: auto; 307 max-width: 100%; 308 border-radius: 0; 309 background: none; 310 } 311 312 .oznaczai-legend__body { 313 display: flex; 314 flex-direction: column; 315 gap: 0.15em; 316 min-width: 0; 317 } 318 319 .oznaczai-legend__name { 320 font-weight: 600; 321 } 322 323 .oznaczai-legend__description { 324 opacity: 0.85; 325 } 326 327 .oznaczai-legend__note { 328 margin: 0.9em 0 0; 329 font-size: 0.875em; 330 opacity: 0.75; 331 } 332 333 /* Image symbol variants (official EU icons). 334 The artwork is a fixed two-tone drawing, so it is placed as an image rather 335 than recoloured. The white artwork is the default because the default preset 336 draws a light label on a dark translucent surface; the black artwork is used 337 wherever the label sits on a light surface. */ 338 .oznaczai-badge__icon--image { 339 width: calc(var(--oznaczai-icon-size, 13px) * var(--oznaczai-icon-ratio, 1)); 340 height: var(--oznaczai-icon-size, 13px); 341 border-radius: 0; 342 background-color: transparent; 343 background-image: var(--oznaczai-icon-on-dark); 344 background-repeat: no-repeat; 345 background-position: center; 346 background-size: contain; 347 font-size: 0; 348 } 349 350 .oznaczai-badge--light .oznaczai-badge__icon--image, 351 .oznaczai-badge--glass .oznaczai-badge__icon--image, 352 .oznaczai-badge--caption .oznaczai-badge__icon--image, 353 .oznaczai-badge--icon-only .oznaczai-badge__icon--image, 354 .oznaczai-badge--contrast-dark .oznaczai-badge__icon--image, 355 .oznaczai-bg-note.oznaczai-badge--contrast-dark .oznaczai-badge__icon--image { 356 background-color: transparent; 357 background-image: var(--oznaczai-icon-on-light); 358 } 359 360 .oznaczai-badge--contrast-light .oznaczai-badge__icon--image, 361 .oznaczai-bg-note.oznaczai-badge--contrast-light .oznaczai-badge__icon--image { 362 background-color: transparent; 363 background-image: var(--oznaczai-icon-on-dark); 364 } 365 366 /* When the symbol already spells out its own meaning there is no text node to 367 space away from it. */ 368 .oznaczai-badge--symbol-lockup { 369 gap: 0; 370 } 371 260 372 .oznaczai-badge__text { 261 373 min-width: 0; -
oznaczai-image-labeling/tags/1.7.0/assets/js/admin.js
r3641997 r3705284 221 221 } 222 222 223 function renderBadge(element, template, label, forceIcon) { 223 function escapeAttribute(value) { 224 return escapeText(value).replace(/"/g, '"'); 225 } 226 227 function activeSymbol(status) { 228 var variants = window.oznaczaiImageLabelingAdmin && window.oznaczaiImageLabelingAdmin.iconVariants 229 ? window.oznaczaiImageLabelingAdmin.iconVariants 230 : {}; 231 var variant = variants[fieldValue('icon_variant', 'oznaczai')] || variants.oznaczai; 232 if (!variant || !variant.symbols) { 233 return null; 234 } 235 return variant.symbols[status] || variant.symbols.generated || null; 236 } 237 238 function symbolMarkup(status) { 239 var symbol = activeSymbol(status); 240 241 if (symbol && symbol.onLight && symbol.onDark) { 242 return '<b aria-hidden="true" class="oznaczai-preview-symbol--image" style="' + 243 '--oznaczai-preview-icon-on-light:url("' + escapeAttribute(symbol.onLight) + '");' + 244 '--oznaczai-preview-icon-on-dark:url("' + escapeAttribute(symbol.onDark) + '");' + 245 '--oznaczai-preview-icon-ratio:' + (parseFloat(symbol.ratio) || 1) + '"></b>'; 246 } 247 248 return '<b aria-hidden="true">' + escapeText(symbol && symbol.text ? symbol.text : 'AI') + '</b>'; 249 } 250 251 // Mirrors shouldRenderText() in frontend.js so the preview cannot promise 252 // something the published label will not do. 253 function symbolCarriesWording(status) { 254 var symbol = activeSymbol(status); 255 return !!(symbol && symbol.onLight && symbol.ratio && parseFloat(symbol.ratio) > 1.5); 256 } 257 258 function renderBadge(element, template, label, forceIcon, status) { 224 259 var config = applyPresetConfig(element, template); 225 260 var showIcon = forceIcon || (iconInput && iconInput.checked); 226 var text = config.iconOnly ? '' : escapeText(label); 261 var hideText = showIcon && !checked('icon_show_text') && symbolCarriesWording(status || previewType); 262 var text = (config.iconOnly || hideText) ? '' : escapeText(label); 227 263 var isPresetDemo = element.hasAttribute('data-oznaczai-preset-preview'); 228 264 var baseClass = isPresetDemo ? 'oznaczai-preset-demo' : 'oznaczai-preview-badge'; … … 231 267 element.classList.add('oznaczai-preview-badge--icon-only'); 232 268 } 233 element.innerHTML = (showIcon ? '<b aria-hidden="true">AI</b>': '') + text;269 element.innerHTML = (showIcon ? symbolMarkup(status || previewType) : '') + text; 234 270 element.setAttribute('aria-label', label); 235 271 return config; … … 291 327 } 292 328 if (behavior === 'icon' && !previewLabel.querySelector('b')) { 293 previewLabel.innerHTML = '<b aria-hidden="true">AI</b>';329 previewLabel.innerHTML = symbolMarkup(previewType); 294 330 } 295 331 previewLabel.classList.toggle('oznaczai-preview-badge--short-label', behavior === 'short'); … … 384 420 input.addEventListener('change', refreshPreview); 385 421 }); 422 423 // Symbol variant and its text toggle change both the live preview and the 424 // preset cards, so the cards are re-rendered too. 425 Array.prototype.forEach.call( 426 document.querySelectorAll('[data-oznaczai-icon-variant], input[name$="[icon_show_text]"]'), 427 function (input) { 428 input.addEventListener('change', function () { 429 renderPresetCards(); 430 refreshPreview(); 431 }); 432 } 433 ); 386 434 387 435 document.addEventListener('click', function (event) { -
oznaczai-image-labeling/tags/1.7.0/assets/js/frontend.js
r3641997 r3705284 15 15 '[style*="background-image"]' 16 16 ].join(','); 17 var revolutionSelector = 'sr7-module, rs-module'; 18 var ownNodeSelector = '.oznaczai-badge, .oznaczai-bg-note, .oznaczai-popover'; 17 19 var scheduled = false; 18 20 var config = window.oznaczaiImageLabelingFrontend || {}; 19 21 var mediaMap = config.mediaMap || {}; 20 22 var schemaEntries = {}; 23 var schemaDirty = false; 24 25 // Scan scheduling state. The observer collects the smallest useful roots 26 // instead of forcing a full document rescan for every unrelated mutation. 27 var pendingRoots = []; 28 var pendingFull = false; 29 var maxPendingRoots = 60; 30 31 // Guards against the observer reacting to OznaczAI's own DOM writes. 32 var applying = 0; 33 var observer = null; 34 35 // Caches. WeakMap/WeakSet keep entries collectable with their elements. 36 var revolutionCache = new WeakMap(); 37 var revolutionEpoch = 0; 38 var contrastKeys = new WeakMap(); 39 var noteSignatures = new WeakMap(); 40 var inlineBackgrounds = new WeakMap(); 41 var loadWatched = new WeakSet(); 42 var sliderScanQueued = new WeakSet(); 43 44 var resizeTimer = null; 21 45 22 46 function meaningfulChildren(element) { … … 55 79 } 56 80 57 function buildIcon() { 81 function iconVariant() { 82 var variants = config.iconVariants || {}; 83 var key = config.iconVariant || 'oznaczai'; 84 return variants[key] || variants.oznaczai || null; 85 } 86 87 function resolveSymbol(status) { 88 var variant = iconVariant(); 89 if (!variant || !variant.symbols) { 90 return null; 91 } 92 return variant.symbols[status] || variant.symbols.generated || null; 93 } 94 95 function buildIcon(status) { 58 96 var icon = document.createElement('span'); 97 var symbol = resolveSymbol(status); 98 59 99 icon.className = 'oznaczai-badge__icon'; 60 100 icon.setAttribute('aria-hidden', 'true'); 61 icon.textContent = 'AI'; 101 102 // Image symbols are drawn from a file rather than inlined, so the same 103 // asset is cached once and the markup stays small on every page. 104 if (symbol && symbol.onLight && symbol.onDark) { 105 icon.classList.add('oznaczai-badge__icon--image'); 106 icon.style.setProperty('--oznaczai-icon-on-light', 'url("' + symbol.onLight + '")'); 107 icon.style.setProperty('--oznaczai-icon-on-dark', 'url("' + symbol.onDark + '")'); 108 icon.style.setProperty('--oznaczai-icon-ratio', symbol.ratio || 1); 109 return icon; 110 } 111 112 icon.textContent = symbol && symbol.text ? symbol.text : 'AI'; 62 113 return icon; 63 114 } 64 115 65 function fillLabel(element, label, showIcon) { 116 /** 117 * Decide whether the wording is drawn beside the symbol. It is always kept 118 * when there is no symbol, and it is only dropped for symbols that already 119 * spell out their own meaning. 120 */ 121 function shouldRenderText(status, showIcon) { 122 var symbol; 123 124 if (!showIcon || config.iconShowText !== false) { 125 return true; 126 } 127 128 symbol = resolveSymbol(status); 129 return !(symbol && symbol.onLight && symbol.ratio && symbol.ratio > 1.5); 130 } 131 132 function fillLabel(element, label, showIcon, status) { 133 var withText = shouldRenderText(status, showIcon); 134 66 135 element.textContent = ''; 67 136 if (showIcon) { 68 element.appendChild(buildIcon()); 69 } 70 element.appendChild(buildBadgeTextElement(label)); 137 element.appendChild(buildIcon(status)); 138 } 139 if (withText) { 140 element.appendChild(buildBadgeTextElement(label)); 141 } 142 element.classList.toggle('oznaczai-badge--symbol-lockup', !withText); 143 // The accessible name always carries the full wording, including when the 144 // visible text is suppressed or the symbol is image-only. 71 145 element.setAttribute('role', 'note'); 72 146 element.setAttribute('aria-label', label); … … 217 291 } 218 292 293 /** 294 * Build a cheap key describing every input the contrast result depends on. 295 * Canvas sampling only runs again when one of these actually changed. 296 */ 297 function contrastKey(badge, source) { 298 var badgeRect = badge.getBoundingClientRect(); 299 var sourceKey = ''; 300 301 if (source && source.tagName === 'IMG') { 302 sourceKey = (source.currentSrc || source.getAttribute('src') || '') + ':' + (source.complete ? '1' : '0'); 303 } 304 305 return [ 306 sourceKey, 307 Math.round(badgeRect.width), 308 Math.round(badgeRect.height), 309 Math.round(badgeRect.left), 310 Math.round(badgeRect.top) 311 ].join('|'); 312 } 313 219 314 function applyAutoContrast(badge, source, fallbackElement) { 220 315 var level; 316 var key; 317 318 if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) { 319 clearContrastClasses(badge); 320 return; 321 } 322 if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') { 323 clearContrastClasses(badge); 324 return; 325 } 326 327 key = contrastKey(badge, source); 328 if (contrastKeys.get(badge) === key) { 329 return; 330 } 221 331 222 332 clearContrastClasses(badge); 223 if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) { 224 return; 225 } 226 if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') { 227 return; 228 } 333 // Record the attempt before sampling. A sample that cannot succeed for 334 // these inputs (tainted canvas, fully transparent ancestry) will not 335 // succeed on a retry either, so retrying costs work and changes nothing. 336 // A genuine change produces a different key and is picked up normally. 337 contrastKeys.set(badge, key); 229 338 230 339 if (source && source.tagName === 'IMG') { … … 266 375 function getCurrentTemplate() { 267 376 return config.template || 'minimal'; 268 }269 270 function templateClasses() {271 return [272 'oznaczai-badge--minimal',273 'oznaczai-badge--compact',274 'oznaczai-badge--subtle',275 'oznaczai-badge--light',276 'oznaczai-badge--caption',277 'oznaczai-badge--glass',278 'oznaczai-badge--dark',279 'oznaczai-badge--icon-only'280 ];281 }282 283 function containerTemplateClasses() {284 return [285 'oznaczai-template--minimal',286 'oznaczai-template--compact',287 'oznaczai-template--subtle',288 'oznaczai-template--light',289 'oznaczai-template--caption',290 'oznaczai-template--glass',291 'oznaczai-template--dark',292 'oznaczai-template--icon-only'293 ];294 377 } 295 378 … … 338 421 339 422 function resolveContextPosition(context, fallback, status, presetOverride) { 340 var style = config.style || {};341 var hero = style.hero || {};342 423 var presetConfigs = config.presetConfigs || {}; 343 424 var custom = resolveStyleCustom(context); … … 458 539 } 459 540 541 if (schemaEntries[absoluteUrl]) { 542 return; 543 } 544 460 545 schemaEntries[absoluteUrl] = { 461 546 '@type': 'ImageObject', … … 463 548 digitalSourceType: sourceType 464 549 }; 550 schemaDirty = true; 465 551 } 466 552 … … 469 555 var graph; 470 556 471 if (!config.schemaMarkup ) {557 if (!config.schemaMarkup || !schemaDirty) { 472 558 return; 473 559 } … … 492 578 '@graph': graph 493 579 }); 580 schemaDirty = false; 494 581 } 495 582 … … 501 588 var device; 502 589 var behavior; 590 var status; 503 591 504 592 if (!badge) { … … 506 594 } 507 595 596 status = getImageStatus(image); 508 597 badge.classList.remove('oznaczai-badge--symbol-only', 'oznaczai-badge--short-label'); 509 598 badge.removeAttribute('title'); … … 521 610 if (behavior === 'icon') { 522 611 if (!badge.querySelector('.oznaczai-badge__icon')) { 523 badge.insertBefore(buildIcon( ), badge.firstChild);612 badge.insertBefore(buildIcon(status), badge.firstChild); 524 613 } 525 614 badge.classList.add('oznaczai-badge--symbol-only'); … … 536 625 if (width > 0 && width <= 160) { 537 626 if (!badge.querySelector('.oznaczai-badge__icon')) { 538 badge.insertBefore(buildIcon( ), badge.firstChild);627 badge.insertBefore(buildIcon(status), badge.firstChild); 539 628 } 540 629 badge.classList.remove('oznaczai-badge--short-label'); … … 546 635 } 547 636 548 function refreshResponsiveSymbols() { 549 var images = document.querySelectorAll(imageSelector); 637 function findImageBadge(image) { 638 var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image'); 639 return container ? getDirectBadge(container) : null; 640 } 641 642 /** 643 * Re-evaluate responsive behaviour for already labeled images. This depends on 644 * viewport and element width, so it runs on resize and after image load only. 645 */ 646 function refreshResponsiveSymbols(root) { 647 var scope = root && root.querySelectorAll ? root : document; 648 var images = scope.querySelectorAll(imageSelector); 649 550 650 Array.prototype.forEach.call(images, function (image) { 551 var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image'); 552 var badge = container ? getDirectBadge(container) : null; 553 var template = resolveTemplate(getImageStatus(image), image, null); 554 var showIcon = getCurrentShowIcon(); 555 updateResponsiveBadge(image, badge, template, showIcon, getCurrentImageLabel(image)); 651 var badge = findImageBadge(image); 652 var template; 653 654 if (!badge) { 655 return; 656 } 657 658 template = resolveTemplate(getImageStatus(image), image, null); 659 updateResponsiveBadge(image, badge, template, getCurrentShowIcon(), getCurrentImageLabel(image)); 660 applyAutoContrast(badge, image, badge.parentElement); 556 661 }); 557 662 } … … 561 666 return false; 562 667 } 563 return !!image.closest('sr7-module, rs-module'); 668 return !!image.closest(revolutionSelector); 669 } 670 671 /** 672 * Everything the rendered badge depends on. When this is unchanged there is 673 * nothing to rebuild, so the expensive path is skipped entirely. 674 */ 675 function imageSignature(image) { 676 return [ 677 image.getAttribute('data-oznaczai-type') || '', 678 image.getAttribute('data-oznaczai-label') || '', 679 image.getAttribute('data-oznaczai-custom-label') || '', 680 image.getAttribute('data-oznaczai-preset') || '' 681 ].join('|'); 682 } 683 684 function watchImageLoad(image) { 685 if (loadWatched.has(image) || image.complete) { 686 return; 687 } 688 loadWatched.add(image); 689 image.addEventListener('load', function () { 690 var badge = findImageBadge(image); 691 if (!badge) { 692 return; 693 } 694 withOwnMutations(function () { 695 updateResponsiveBadge(image, badge, resolveTemplate(getImageStatus(image), image, null), getCurrentShowIcon(), getCurrentImageLabel(image)); 696 applyAutoContrast(badge, image, badge.parentElement); 697 }); 698 }, { once: true }); 564 699 } 565 700 566 701 function createBadge(image) { 567 var label = getCurrentImageLabel(image); 568 var position = getCurrentPosition(); 569 var status = getImageStatus(image); 570 var context = resolveContext(image, null); 571 var template = resolveTemplate(status, image, context); 572 var showIcon = getCurrentShowIcon(); 702 var signature = imageSignature(image); 703 var label; 704 var position; 705 var status; 706 var context; 707 var template; 708 var showIcon; 573 709 var target; 574 710 var parent; … … 579 715 var originalImageWidth; 580 716 717 if (image.getAttribute('data-oznaczai-ready') === '1' && image.getAttribute('data-oznaczai-sig') === signature) { 718 return; 719 } 720 721 label = getCurrentImageLabel(image); 722 position = getCurrentPosition(); 723 status = getImageStatus(image); 724 context = resolveContext(image, null); 725 template = resolveTemplate(status, image, context); 726 showIcon = getCurrentShowIcon(); 727 581 728 if (!label) { 582 729 return; … … 587 734 if (isManagedByRevolution(image)) { 588 735 image.setAttribute('data-oznaczai-ready', '1'); 589 return; 590 } 591 592 registerSchemaEntry(image.currentSrc || image.getAttribute('src'), getImageStatus(image)); 736 image.setAttribute('data-oznaczai-sig', signature); 737 return; 738 } 739 740 registerSchemaEntry(image.currentSrc || image.getAttribute('src'), status); 593 741 594 742 originalParentWidth = image.parentElement ? image.parentElement.getBoundingClientRect().width : 0; … … 684 832 ); 685 833 badge.classList.add('oznaczai-badge--' + template); 686 fillLabel(badge, label, showIcon );834 fillLabel(badge, label, showIcon, status); 687 835 applyStyleData(badge, status, template, context); 688 836 applyAutoContrast(badge, image, container); 689 837 updateResponsiveBadge(image, badge, template, showIcon, label); 690 838 setupPopover(badge, label); 839 watchImageLoad(image); 691 840 image.setAttribute('data-oznaczai-ready', '1'); 841 image.setAttribute('data-oznaczai-sig', signature); 692 842 } 693 843 … … 727 877 } 728 878 879 /** 880 * mediaMap entries no longer carry the label text. Resolve it the same way a 881 * regular image does so background and slider labels stay identical. 882 */ 883 function resolveMapLabel(data) { 884 var status = data && data.status ? data.status : 'generated'; 885 if (data && data.label) { 886 return data.label; 887 } 888 return (config.labels && config.labels[status]) || ''; 889 } 890 891 function mapEntryToData(data, url) { 892 if (!data) { 893 return null; 894 } 895 // NOTE: `preset` is deliberately not carried over. Up to and including 896 // 1.6.24 the background and Slider Revolution extractors never passed the 897 // per-image preset through, so hero/slider labels have always used the 898 // hero preset. Propagating it here would silently restyle existing sites 899 // on upgrade, so the change is left for a release that can announce it. 900 return { 901 id: data.id, 902 status: data.status, 903 label: resolveMapLabel(data), 904 url: url 905 }; 906 } 907 729 908 function addUniqueUrl(urls, value) { 730 909 var clean = typeof value === 'string' ? value.trim() : ''; … … 844 1023 } 845 1024 1025 /** 1026 * Cheap fingerprint used to decide whether the expensive full slide walk has 1027 * to run again. Slider Revolution swaps sources and markup on slide change, 1028 * so those are exactly the inputs worth watching. 1029 */ 1030 function revolutionSlideKey(slide) { 1031 var image = slide.querySelector ? slide.querySelector('img, sr7-bg, rs-bg') : null; 1032 return [ 1033 revolutionEpoch, 1034 slide.childElementCount, 1035 image ? (image.getAttribute('src') || image.getAttribute('data-src') || image.getAttribute('data-lazyload') || '') : '', 1036 slide.getAttribute('data-key') || slide.getAttribute('data-skey') || '' 1037 ].join('|'); 1038 } 1039 846 1040 function findAutomaticRevolutionSlideData(slide) { 847 var urls = extractRevolutionSlideUrls(slide); 1041 var key = revolutionSlideKey(slide); 1042 var cached = revolutionCache.get(slide); 1043 var urls; 848 1044 var i; 849 1045 var data; 1046 var result = null; 1047 1048 if (cached && cached.key === key) { 1049 return cached.data; 1050 } 1051 1052 urls = extractRevolutionSlideUrls(slide); 850 1053 851 1054 for (i = 0; i < urls.length; i += 1) { 852 1055 data = lookupMedia(urls[i]); 853 1056 if (data) { 854 return { 855 id: data.id, 856 status: data.status, 857 label: data.label, 858 url: urls[i] 859 }; 860 } 861 } 862 863 return null; 1057 result = mapEntryToData(data, urls[i]); 1058 break; 1059 } 1060 } 1061 1062 revolutionCache.set(slide, { key: key, data: result }); 1063 return result; 864 1064 } 865 1065 … … 885 1085 data = lookupMedia(urls[i]); 886 1086 if (data) { 887 return { 888 id: data.id, 889 status: data.status, 890 label: data.label, 891 url: urls[i] 892 }; 1087 return mapEntryToData(data, urls[i]); 893 1088 } 894 1089 } … … 898 1093 899 1094 function findSliderOwner(element) { 900 var revolution = element.closest( 'sr7-module, rs-module');1095 var revolution = element.closest(revolutionSelector); 901 1096 if (revolution) { 902 1097 return revolution; … … 910 1105 911 1106 function findSectionOwner(element) { 912 var revolution = element.closest( 'sr7-module, rs-module');1107 var revolution = element.closest(revolutionSelector); 913 1108 if (revolution) { 914 1109 return revolution; … … 934 1129 var note; 935 1130 var template; 1131 var signature; 936 1132 if (!owner || !owner.parentNode) { 937 1133 return null; … … 945 1141 owner.parentNode.insertBefore(note, owner.nextSibling); 946 1142 } 947 setBelowNoteAlignment(note, position); 948 fillLabel(note, label, config.showIcon !== false); 1143 949 1144 status = status || 'generated'; 950 1145 context = context || 'background'; 951 1146 template = preset || resolveTemplate(status, null, context); 1147 signature = [label, position, status, template, context].join('|'); 1148 1149 // Slider libraries mutate slide classes on every transition frame. Without 1150 // this guard the note would be torn down and rebuilt each time. 1151 if (noteSignatures.get(note) === signature) { 1152 return note; 1153 } 1154 noteSignatures.set(note, signature); 1155 1156 setBelowNoteAlignment(note, position); 1157 fillLabel(note, label, config.showIcon !== false, status); 952 1158 note.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only'); 953 1159 note.classList.add('oznaczai-badge--' + template); … … 1054 1260 if (active) { 1055 1261 labeled = active.hasAttribute('data-oznaczai-slide-label') ? active : null; 1056 } else if (slider.matches( 'sr7-module, rs-module')) {1262 } else if (slider.matches(revolutionSelector)) { 1057 1263 labeled = null; 1058 1264 } else { … … 1097 1303 if ('MutationObserver' in window) { 1098 1304 new MutationObserver(function () { 1099 updateSliderNote(slider); 1305 // Slide transitions fire dozens of records per second. Collapse them 1306 // into one update per frame. 1307 if (sliderScanQueued.has(slider)) { 1308 return; 1309 } 1310 sliderScanQueued.add(slider); 1311 window.requestAnimationFrame(function () { 1312 sliderScanQueued.delete(slider); 1313 withOwnMutations(function () { 1314 updateSliderNote(slider); 1315 }); 1316 }); 1100 1317 }).observe(slider, { 1101 1318 attributes: true, … … 1149 1366 function createOverlayLabel(target, data, display, context) { 1150 1367 var badge = target.querySelector(':scope > .oznaczai-badge.oznaczai-badge--bg'); 1368 var status = data.status || 'generated'; 1369 var template; 1370 var signature; 1371 1151 1372 context = context || 'background'; 1152 var template = data.preset || resolveTemplate(data.status || 'generated', null, context); 1373 template = data.preset || resolveTemplate(status, null, context); 1374 signature = [data.label, display, status, template, context].join('|'); 1375 1376 if (badge && noteSignatures.get(badge) === signature) { 1377 return; 1378 } 1153 1379 1154 1380 target.classList.add('oznaczai-bg-target'); … … 1170 1396 } 1171 1397 1398 noteSignatures.set(badge, signature); 1399 1172 1400 badge.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only', 'oznaczai-badge--hero-bar'); 1173 1401 badge.classList.add('oznaczai-badge--' + template); … … 1175 1403 badge.classList.add('oznaczai-badge--hero-bar'); 1176 1404 } 1177 fillLabel(badge, data.label, config.showIcon !== false );1178 applyStyleData(badge, data.status || 'generated', template, context);1405 fillLabel(badge, data.label, config.showIcon !== false, status); 1406 applyStyleData(badge, status, template, context); 1179 1407 applyAutoContrast(badge, null, target); 1180 1408 setupPopover(badge, data.label); … … 1249 1477 return; 1250 1478 } 1479 // A negative result is remembered too. Without this, every generic 1480 // hero/banner/Elementor node was re-measured with getComputedStyle on 1481 // every single scan. 1482 if (target.getAttribute('data-oznaczai-bg-checked') === '1') { 1483 return; 1484 } 1251 1485 // Real content images are handled by createBadge(). A generic hero/banner 1252 1486 // selector must never promote an <img> into a second background disclosure. 1253 1487 if (target.matches && target.matches('img, picture, source')) { 1488 target.setAttribute('data-oznaczai-bg-checked', '1'); 1254 1489 return; 1255 1490 } … … 1257 1492 if (data) { 1258 1493 applyBackgroundLabel(target, data); 1259 } 1494 return; 1495 } 1496 target.setAttribute('data-oznaczai-bg-checked', '1'); 1260 1497 } 1261 1498 … … 1271 1508 } 1272 1509 1273 function scan(root) { 1274 var images; 1275 var manualBackgrounds; 1276 var automaticBackgrounds; 1277 var monitoredSliders; 1278 var revolutionSliders; 1279 1280 root = root || document; 1281 images = collect(root, imageSelector); 1282 manualBackgrounds = collect(root, manualBackgroundSelector); 1283 automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : []; 1284 monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]'); 1285 revolutionSliders = config.autoBackgrounds ? collect(root, 'sr7-module, rs-module') : []; 1510 function scanRoot(root) { 1511 var images = collect(root, imageSelector); 1512 var manualBackgrounds = collect(root, manualBackgroundSelector); 1513 var automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : []; 1514 var revolutionSliders = config.autoBackgrounds ? collect(root, revolutionSelector) : []; 1515 var monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]'); 1286 1516 1287 1517 images.forEach(createBadge); … … 1290 1520 revolutionSliders.forEach(scanRevolutionSlider); 1291 1521 monitoredSliders.forEach(updateSliderNote); 1292 refreshResponsiveSymbols(); 1293 flushSchemaMarkup(); 1294 } 1295 1296 function scheduleScan() { 1522 } 1523 1524 /** 1525 * Run a callback with the global observer detached so OznaczAI never reacts 1526 * to its own DOM writes. disconnect() also empties the pending record queue, 1527 * which is what actually breaks the observer -> scan -> observer loop. 1528 */ 1529 function withOwnMutations(callback) { 1530 applying += 1; 1531 if (observer) { 1532 observer.disconnect(); 1533 } 1534 try { 1535 callback(); 1536 } finally { 1537 applying -= 1; 1538 if (observer && applying === 0) { 1539 startObserving(); 1540 } 1541 } 1542 } 1543 1544 function scan(root) { 1545 withOwnMutations(function () { 1546 scanRoot(root || document); 1547 flushSchemaMarkup(); 1548 }); 1549 } 1550 1551 function isOwnNode(node) { 1552 return !!(node && node.nodeType === 1 && node.closest && node.closest(ownNodeSelector)); 1553 } 1554 1555 /** 1556 * Compare only the inline background-image declaration. This reads the 1557 * element's own style object, so unlike getComputedStyle it does not force 1558 * the browser to flush pending style or layout work. 1559 */ 1560 function inlineBackgroundChanged(element) { 1561 var current; 1562 1563 if (!element || !element.style) { 1564 return true; 1565 } 1566 1567 current = element.style.backgroundImage || ''; 1568 if (inlineBackgrounds.get(element) === current) { 1569 return false; 1570 } 1571 1572 inlineBackgrounds.set(element, current); 1573 return true; 1574 } 1575 1576 function queueRoot(node) { 1577 var scopeRoot; 1578 1579 if (pendingFull || !node || node.nodeType !== 1 || isOwnNode(node)) { 1580 return; 1581 } 1582 1583 // Anything inside a Slider Revolution module has to be evaluated at module 1584 // level, because slide detection works on the whole module. 1585 scopeRoot = node.closest ? node.closest(revolutionSelector) : null; 1586 scopeRoot = scopeRoot || node; 1587 1588 if (pendingRoots.indexOf(scopeRoot) !== -1) { 1589 return; 1590 } 1591 1592 if (pendingRoots.length >= maxPendingRoots) { 1593 pendingFull = true; 1594 pendingRoots.length = 0; 1595 return; 1596 } 1597 1598 pendingRoots.push(scopeRoot); 1599 } 1600 1601 function runScheduledScan() { 1602 var roots = pendingRoots.slice(); 1603 var full = pendingFull; 1604 1605 scheduled = false; 1606 pendingRoots.length = 0; 1607 pendingFull = false; 1608 1609 withOwnMutations(function () { 1610 if (full) { 1611 scanRoot(document); 1612 } else { 1613 roots.forEach(function (root) { 1614 if (root === document || (root.isConnected !== false)) { 1615 scanRoot(root); 1616 } 1617 }); 1618 } 1619 flushSchemaMarkup(); 1620 }); 1621 } 1622 1623 function scheduleScan(full) { 1624 if (full === true) { 1625 pendingFull = true; 1626 pendingRoots.length = 0; 1627 } 1297 1628 if (scheduled) { 1298 1629 return; 1299 1630 } 1300 1631 scheduled = true; 1301 window.requestAnimationFrame(function () { 1302 scheduled = false; 1303 scan(document); 1304 }); 1632 window.requestAnimationFrame(runScheduledScan); 1633 } 1634 1635 function scheduleFullScan() { 1636 scheduleScan(true); 1637 } 1638 1639 function startObserving() { 1640 if (!observer) { 1641 return; 1642 } 1643 observer.observe(document.body, { 1644 childList: true, 1645 subtree: true, 1646 attributes: true, 1647 // `class` is intentionally not observed globally. Slider state classes 1648 // are handled by the per-slider observer in ensureSliderMonitoring(), 1649 // and every other class change on the page is irrelevant to labeling. 1650 attributeFilter: ['style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb'] 1651 }); 1652 } 1653 1654 function handleMutations(mutations) { 1655 var queued = false; 1656 var i; 1657 var mutation; 1658 var j; 1659 var node; 1660 1661 if (applying > 0) { 1662 return; 1663 } 1664 1665 for (i = 0; i < mutations.length; i += 1) { 1666 mutation = mutations[i]; 1667 1668 if (mutation.type === 'attributes') { 1669 if (isOwnNode(mutation.target)) { 1670 continue; 1671 } 1672 // Scroll animations and motion effects rewrite `style` on every 1673 // frame without ever touching the background. Reading the inline 1674 // value is cheap and avoids forcing a style recalculation for 1675 // changes that cannot affect labeling. 1676 if (mutation.attributeName === 'style' && !inlineBackgroundChanged(mutation.target)) { 1677 continue; 1678 } 1679 // A background source may have just been swapped in, so the cached 1680 // negative result for this element is no longer trustworthy. 1681 if (mutation.target.hasAttribute && mutation.target.hasAttribute('data-oznaczai-bg-checked')) { 1682 mutation.target.removeAttribute('data-oznaczai-bg-checked'); 1683 } 1684 queueRoot(mutation.target); 1685 queued = true; 1686 continue; 1687 } 1688 1689 if (mutation.addedNodes) { 1690 for (j = 0; j < mutation.addedNodes.length; j += 1) { 1691 node = mutation.addedNodes[j]; 1692 if (node.nodeType !== 1 || isOwnNode(node)) { 1693 continue; 1694 } 1695 queueRoot(node); 1696 queued = true; 1697 } 1698 } 1699 } 1700 1701 if (queued) { 1702 scheduleScan(); 1703 } 1704 } 1705 1706 function handleResize() { 1707 if (resizeTimer) { 1708 window.clearTimeout(resizeTimer); 1709 } 1710 resizeTimer = window.setTimeout(function () { 1711 resizeTimer = null; 1712 withOwnMutations(function () { 1713 refreshResponsiveSymbols(document); 1714 }); 1715 }, 200); 1716 } 1717 1718 function handleRevolutionEvent() { 1719 // Slide media changed, so the per-slide URL cache has to be rebuilt. 1720 revolutionEpoch += 1; 1721 scheduleFullScan(); 1305 1722 } 1306 1723 1307 1724 function init() { 1308 1725 scan(document); 1726 withOwnMutations(function () { 1727 refreshResponsiveSymbols(document); 1728 }); 1729 1730 // Themes and lazy-loaders often finish late. These two passes are cheap 1731 // now because processed images short-circuit on their signature. 1309 1732 window.setTimeout(function () { scan(document); }, 700); 1310 1733 window.setTimeout(function () { scan(document); }, 1800); 1311 window.addEventListener('resize', scheduleScan, { passive: true });1734 window.addEventListener('resize', handleResize, { passive: true }); 1312 1735 1313 1736 // Slider Revolution 7 exposes native lifecycle events. Re-scan after a … … 1315 1738 // current-slide disclosure stay synchronized. Legacy SR6 remains covered 1316 1739 // by the DOM observer and data-slideactive monitoring below. 1317 document.addEventListener('sr.module.ready', scheduleScan);1318 document.addEventListener('sr.slide.afterChange', scheduleScan);1740 document.addEventListener('sr.module.ready', handleRevolutionEvent); 1741 document.addEventListener('sr.slide.afterChange', handleRevolutionEvent); 1319 1742 1320 1743 if ('MutationObserver' in window) { 1321 new MutationObserver(function (mutations) { 1322 var shouldScan = mutations.some(function (mutation) { 1323 return (mutation.addedNodes && mutation.addedNodes.length > 0) || mutation.type === 'attributes'; 1324 }); 1325 if (shouldScan) { 1326 scheduleScan(); 1327 } 1328 }).observe(document.body, { 1329 childList: true, 1330 subtree: true, 1331 attributes: true, 1332 attributeFilter: ['class', 'style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb', 'data-current', 'data-slideactive'] 1333 }); 1744 observer = new MutationObserver(handleMutations); 1745 startObserving(); 1334 1746 } 1335 1747 -
oznaczai-image-labeling/tags/1.7.0/changelog.txt
r3641997 r3705284 1 = 1.7.0 = 2 * Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled. 3 * Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly. 4 * Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked. 5 * Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected. 6 * Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label. 7 * Removed a database write that could happen while a public page was being rendered. 8 * Bulk labeling now writes the labeled media index once instead of once per image. 9 * Reduced the size of the labeled media data passed to the browser by removing duplicated entries. 10 * The legacy storage migration now runs only on install and version change instead of on every request. 11 * Fixed a migration from very old versions that could store a label preset that no longer exists. 12 * Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint. 13 * Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it. 14 * Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged. 15 * Removed settings screen code that was no longer used by the current panel. 16 1 17 = 1.6.24 = 2 18 * Rebuilt preset cards with a simple flex column flow so title, description and preview badge can never overlap. -
oznaczai-image-labeling/tags/1.7.0/includes/class-oznaczai-image-labeling-plugin.php
r3641997 r3705284 21 21 const OPTION_LABELED_IDS = 'oznaczai_image_labeling_labeled_media_ids'; 22 22 const OPTION_VERSION = 'oznaczai_image_labeling_version'; 23 const OPTION_TRANSPARENCY_PAGE = 'oznaczai_image_labeling_transparency_page_id'; 23 24 const TRANSIENT_MEDIA_MAP = 'oznaczai_image_labeling_media_map_v1'; 24 25 … … 67 68 68 69 /** 70 * Cached labeled attachment index for the current request. 71 * 72 * @var array<int>|null 73 */ 74 private $labeled_ids = null; 75 76 /** 77 * Stale index entries detected while building the media map on a public 78 * request. They are written out on the next administrative save. 79 * 80 * @var array<int>|null 81 */ 82 private $labeled_ids_pending_cleanup = null; 83 84 /** 69 85 * Get singleton instance. 70 86 * … … 178 194 add_filter( 'manage_media_columns', array( $this, 'add_media_column' ) ); 179 195 add_action( 'manage_media_custom_column', array( $this, 'render_media_column' ), 10, 2 ); 196 add_action( 'restrict_manage_posts', array( $this, 'render_media_library_filter' ), 10, 2 ); 197 add_action( 'pre_get_posts', array( $this, 'filter_media_library_query' ) ); 180 198 add_filter( 'bulk_actions-upload', array( $this, 'add_bulk_actions' ) ); 181 199 add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_actions' ), 10, 3 ); 182 200 add_action( 'admin_notices', array( $this, 'show_bulk_action_notice' ) ); 201 add_action( 'admin_notices', array( $this, 'show_transparency_page_notice' ) ); 183 202 add_action( 'wp_ajax_oznaczai_image_labeling_update_media_status', array( $this, 'ajax_update_media_status' ) ); 203 add_action( 'admin_post_oznaczai_image_labeling_create_transparency_page', array( $this, 'handle_create_transparency_page' ) ); 184 204 185 205 add_action( 'edit_attachment', array( $this, 'clear_media_map_cache' ) ); … … 195 215 */ 196 216 public function maybe_upgrade() { 197 self::migrate_legacy_storage();198 199 217 $stored_version = (string) get_option( self::OPTION_VERSION, '' ); 200 218 if ( '' !== $stored_version && ! version_compare( $stored_version, OZNACZAI_IMAGE_LABELING_VERSION, '<' ) ) { 201 219 return; 202 220 } 221 222 // Only reached on a fresh install or a version change. Installations that 223 // are already up to date no longer pay for the legacy lookup on every 224 // request. Activation still calls this directly, so upgrades that skip 225 // deactivation are covered either way. 226 self::migrate_legacy_storage(); 203 227 204 228 if ( '' === $stored_version || version_compare( $stored_version, '1.6.0', '<' ) ) { … … 234 258 add_shortcode( 'oznaczai_image_label', array( $this, 'render_badge_shortcode' ) ); 235 259 add_shortcode( 'oznaczai_image_label_background', array( $this, 'render_background_badge_shortcode' ) ); 260 add_shortcode( 'oznaczai_transparency_legend', array( $this, 'render_transparency_legend_shortcode' ) ); 236 261 } 237 262 … … 281 306 'hero_position' => 'below', 282 307 'show_icon' => 1, 308 'icon_variant' => 'oznaczai', 309 'icon_show_text' => 1, 283 310 'bg_display' => 'below', 284 311 'auto_backgrounds' => 1, … … 403 430 404 431 if ( empty( $stored['template'] ) && ! empty( $stored['style'] ) ) { 405 $stored['template'] = 'light' === $stored['style'] ? 'light' : 'subtle'; 432 // 'subtle' was dropped from the preset list, so it resolved to no 433 // preset configuration and the frontend already fell back to the 434 // 'minimal' custom properties. Storing 'minimal' keeps exactly the 435 // same rendered label while making the stored value valid again. 436 $stored['template'] = 'light' === $stored['style'] ? 'light' : 'minimal'; 406 437 } 407 438 … … 626 657 627 658 /** 659 * Label symbol variants. 660 * 661 * A preset controls how the label looks. A symbol variant controls which 662 * mark appears inside it. They are independent on purpose, so every existing 663 * preset keeps working with every symbol. 664 * 665 * The default variant reproduces the historical OznaczAI "AI" mark exactly, 666 * so an upgrade never changes an existing site's labels on its own. 667 * 668 * The bundled EU assets are the official European Commission icons. See 669 * assets/icons/eu/README.txt for their source and terms of use. 670 * 671 * @return array<string,array<string,mixed>> 672 */ 673 private static function get_icon_variants() { 674 $base = OZNACZAI_IMAGE_LABELING_URL . 'assets/icons/eu/'; 675 676 $basic = array( 677 'onLight' => $base . 'eu-ai-black.svg', 678 'onDark' => $base . 'eu-ai-white.svg', 679 'ratio' => 1, 680 ); 681 682 return array( 683 'oznaczai' => array( 684 'label' => __( 'OznaczAI symbol', 'oznaczai-image-labeling' ), 685 'symbols' => array( 686 'generated' => array( 'text' => 'AI' ), 687 'modified' => array( 'text' => 'AI' ), 688 'visualization' => array( 'text' => 'AI' ), 689 ), 690 ), 691 'eu-basic' => array( 692 'label' => __( 'EU AI icon', 'oznaczai-image-labeling' ), 693 'symbols' => array( 694 'generated' => $basic, 695 'modified' => $basic, 696 'visualization' => $basic, 697 ), 698 ), 699 'eu-status' => array( 700 'label' => __( 'EU AI icons per label type', 'oznaczai-image-labeling' ), 701 'symbols' => array( 702 'generated' => array( 703 'onLight' => $base . 'eu-ai-generated-black.svg', 704 'onDark' => $base . 'eu-ai-generated-white.svg', 705 'ratio' => 3.157, 706 ), 707 'modified' => array( 708 'onLight' => $base . 'eu-ai-modified-black.svg', 709 'onDark' => $base . 'eu-ai-modified-white.svg', 710 'ratio' => 3, 711 ), 712 // There is no official variant meaning "visualization", so this 713 // status intentionally falls back to the neutral basic icon 714 // instead of being presented as fully generated or modified. 715 'visualization' => $basic, 716 ), 717 ), 718 ); 719 } 720 721 /** 722 * Validate a symbol variant key. 723 * 724 * @param string $variant Variant key. 725 * @return string 726 */ 727 private function sanitize_icon_variant( $variant ) { 728 $variant = sanitize_key( $variant ); 729 return array_key_exists( $variant, self::get_icon_variants() ) ? $variant : 'oznaczai'; 730 } 731 732 /** 733 * Whether the active symbol variant already carries its own wording. 734 * 735 * The EU per-type assets are wide lockups that spell out their meaning as 736 * vector outlines, so repeating the label text beside them is redundant. 737 * 738 * @param string $variant Variant key. 739 * @return bool 740 */ 741 private function icon_variant_has_wording( $variant ) { 742 return 'eu-status' === $variant; 743 } 744 745 /** 628 746 * Validate a preset key. 629 747 * … … 706 824 707 825 /** 708 * Return custom style config for a supported visual context.709 *710 * @param string $context Context key.711 * @param array $settings Current settings.712 * @return array713 */714 private function get_context_custom_style_config( $context, $settings ) {715 return array(716 'textColor' => $settings[ 'context_' . $context . '_text_color' ],717 'noBackground' => ! empty( $settings[ 'context_' . $context . '_no_background' ] ),718 'bgColor' => $settings[ 'context_' . $context . '_bg_color' ],719 'bgOpacity' => (int) $settings[ 'context_' . $context . '_bg_opacity' ],720 'borderColor' => $settings[ 'context_' . $context . '_border_color' ],721 'borderWidth' => (int) $settings[ 'context_' . $context . '_border_width' ],722 'radius' => (int) $settings[ 'context_' . $context . '_radius' ],723 'fontSize' => (int) $settings[ 'context_' . $context . '_font_size' ],724 'fontWeight' => (int) $settings[ 'context_' . $context . '_font_weight' ],725 'paddingX' => (int) $settings[ 'context_' . $context . '_padding_x' ],726 'paddingY' => (int) $settings[ 'context_' . $context . '_padding_y' ],727 'iconColor' => $settings[ 'context_' . $context . '_icon_color' ],728 'iconSize' => (int) $settings[ 'context_' . $context . '_icon_size' ],729 'shadow' => ! empty( $settings[ 'context_' . $context . '_shadow' ] ),730 'position' => $settings[ 'context_' . $context . '_position' ],731 );732 }733 734 /**735 826 * Localized default public labels. 736 827 * … … 1332 1423 1333 1424 $valid_ids[] = $attachment_id; 1425 $full_url = wp_get_attachment_url( $attachment_id ); 1334 1426 $urls = array_filter( 1335 1427 array( 1336 wp_get_attachment_url( $attachment_id ),1428 $full_url, 1337 1429 function_exists( 'wp_get_original_image_url' ) ? wp_get_original_image_url( $attachment_id ) : '', 1338 1430 ) … … 1340 1432 1341 1433 $metadata = wp_get_attachment_metadata( $attachment_id ); 1342 $full_url = wp_get_attachment_url( $attachment_id );1343 1434 if ( $full_url && is_array( $metadata ) && ! empty( $metadata['sizes'] ) ) { 1344 1435 $base_url = trailingslashit( dirname( $full_url ) ); … … 1365 1456 continue; 1366 1457 } 1367 $map[ $path ] = $data; 1368 $map[ $path . '.webp' ] = $data; 1369 $webp_path = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path ); 1458 $map[ $path ] = $data; 1459 1460 // A "<file>.webp" companion path is intentionally not stored. The 1461 // frontend lookup already strips a trailing .webp and retries, so 1462 // the extra key was pure duplication of every entry. The variant 1463 // where the extension is replaced is still needed, because that 1464 // form cannot be derived by stripping. 1465 $webp_path = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path ); 1370 1466 if ( is_string( $webp_path ) && $webp_path !== $path ) { 1371 1467 $map[ $webp_path ] = $data; … … 1374 1470 } 1375 1471 1472 // Stale entries are recorded for the next admin-side write instead of 1473 // being flushed here. This method runs on public page views, which must 1474 // never issue a database write. 1376 1475 if ( $valid_ids !== $ids ) { 1377 update_option( self::OPTION_LABELED_IDS, $valid_ids, false );1476 $this->labeled_ids_pending_cleanup = $valid_ids; 1378 1477 } 1379 1478 … … 1391 1490 */ 1392 1491 private function get_labeled_media_ids() { 1492 if ( is_array( $this->labeled_ids ) ) { 1493 return $this->labeled_ids; 1494 } 1495 1393 1496 $ids = get_option( self::OPTION_LABELED_IDS, null ); 1394 1497 if ( ! is_array( $ids ) ) { 1395 return $this->rebuild_labeled_media_index(); 1498 $this->labeled_ids = $this->rebuild_labeled_media_index(); 1499 return $this->labeled_ids; 1396 1500 } 1397 1501 1398 1502 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ); 1399 1503 sort( $ids, SORT_NUMERIC ); 1504 $this->labeled_ids = $ids; 1400 1505 1401 1506 return $ids; … … 1446 1551 $ids = array_values( array_unique( $ids ) ); 1447 1552 sort( $ids, SORT_NUMERIC ); 1553 $this->labeled_ids = $ids; 1448 1554 update_option( self::OPTION_LABELED_IDS, $ids, false ); 1449 1555 … … 1456 1562 * @param int $attachment_id Attachment ID. 1457 1563 * @param string $status Sanitized status. 1564 * @param bool $defer Keep the change in memory instead of writing. 1458 1565 * @return void 1459 1566 */ 1460 private function update_labeled_media_index( $attachment_id, $status ) {1567 private function update_labeled_media_index( $attachment_id, $status, $defer = false ) { 1461 1568 $attachment_id = absint( $attachment_id ); 1462 1569 if ( ! $attachment_id ) { … … 1472 1579 1473 1580 sort( $ids, SORT_NUMERIC ); 1581 $this->labeled_ids = $ids; 1582 1583 if ( $defer ) { 1584 return; 1585 } 1586 1587 $this->persist_labeled_media_index(); 1588 } 1589 1590 /** 1591 * Write the labeled media index once. 1592 * 1593 * Bulk actions collect every change first and call this at the end, so a 1594 * 200-image selection performs one write instead of 200. 1595 * 1596 * @return void 1597 */ 1598 private function persist_labeled_media_index() { 1599 $ids = $this->get_labeled_media_ids(); 1600 1601 if ( is_array( $this->labeled_ids_pending_cleanup ) ) { 1602 $ids = array_values( array_intersect( $ids, $this->labeled_ids_pending_cleanup ) ); 1603 sort( $ids, SORT_NUMERIC ); 1604 $this->labeled_ids = $ids; 1605 $this->labeled_ids_pending_cleanup = null; 1606 } 1607 1474 1608 update_option( self::OPTION_LABELED_IDS, $ids, false ); 1475 1609 } … … 1484 1618 $this->update_labeled_media_index( absint( $attachment_id ), 'none' ); 1485 1619 $this->clear_media_map_cache(); 1620 } 1621 1622 /** 1623 * Check whether an attachment currently carries an OznaczAI label. 1624 * 1625 * @param int $attachment_id Attachment ID. 1626 * @return bool 1627 */ 1628 private function is_labeled_attachment( $attachment_id ) { 1629 $attachment_id = absint( $attachment_id ); 1630 if ( ! $attachment_id ) { 1631 return false; 1632 } 1633 1634 return in_array( $attachment_id, $this->get_labeled_media_ids(), true ); 1486 1635 } 1487 1636 … … 1579 1728 */ 1580 1729 public function clear_media_map_cache_after_metadata_update( $data, $attachment_id ) { 1581 unset( $attachment_id ); 1730 // This filter also runs for every plain upload and every thumbnail 1731 // regeneration. Rebuilding the whole map for media that carries no label 1732 // turned a 500-image import into 500 cold rebuilds on the next page view. 1733 if ( ! $this->is_labeled_attachment( $attachment_id ) ) { 1734 return $data; 1735 } 1736 1582 1737 $this->clear_media_map_cache(); 1583 1738 return $data; … … 1633 1788 ), 1634 1789 'presetConfigs' => self::get_preset_configs(), 1790 'iconVariants' => self::get_icon_variants(), 1791 'iconVariant' => $this->sanitize_icon_variant( $settings['icon_variant'] ), 1792 'iconShowText' => ! empty( $settings['icon_show_text'] ), 1635 1793 'showIcon' => ! empty( $settings['show_icon'] ), 1636 1794 'template' => $settings['template'], … … 1696 1854 array( 1697 1855 'presetConfigs' => self::get_preset_configs(), 1856 'iconVariants' => self::get_icon_variants(), 1698 1857 ) 1699 1858 ); … … 1720 1879 1721 1880 /** 1722 * Register settings and fields. 1881 * Register the settings option. 1882 * 1883 * The screen is rendered by render_settings_page() rather than through 1884 * do_settings_sections(), so only the option registration is needed here. 1885 * It provides the options.php allow-list entry and the sanitize callback. 1723 1886 * 1724 1887 * @return void … … 1733 1896 'default' => self::default_settings(), 1734 1897 ) 1735 );1736 1737 add_settings_section(1738 'oznaczai_image_labeling_labels_section',1739 __( 'Label text', 'oznaczai-image-labeling' ),1740 '__return_false',1741 'oznaczai-image-labeling'1742 );1743 1744 $this->register_text_setting_field( 'label_generated', __( 'AI-generated image', 'oznaczai-image-labeling' ) );1745 $this->register_text_setting_field( 'label_modified', __( 'AI-modified image', 'oznaczai-image-labeling' ) );1746 $this->register_text_setting_field( 'label_visualization', __( 'AI visualization', 'oznaczai-image-labeling' ) );1747 1748 add_settings_section(1749 'oznaczai_image_labeling_appearance_section',1750 __( 'Label appearance', 'oznaczai-image-labeling' ),1751 '__return_false',1752 'oznaczai-image-labeling'1753 );1754 1755 add_settings_field(1756 'position',1757 __( 'Position', 'oznaczai-image-labeling' ),1758 array( $this, 'render_position_field' ),1759 'oznaczai-image-labeling',1760 'oznaczai_image_labeling_appearance_section'1761 );1762 1763 add_settings_field(1764 'template',1765 __( 'Template', 'oznaczai-image-labeling' ),1766 array( $this, 'render_template_field' ),1767 'oznaczai-image-labeling',1768 'oznaczai_image_labeling_appearance_section'1769 );1770 1771 add_settings_field(1772 'show_icon',1773 __( 'AI icon', 'oznaczai-image-labeling' ),1774 array( $this, 'render_icon_field' ),1775 'oznaczai-image-labeling',1776 'oznaczai_image_labeling_appearance_section'1777 );1778 1779 add_settings_field(1780 'responsive_symbol',1781 __( 'Small-image symbol', 'oznaczai-image-labeling' ),1782 array( $this, 'render_responsive_symbol_field' ),1783 'oznaczai-image-labeling',1784 'oznaczai_image_labeling_appearance_section'1785 );1786 1787 add_settings_section(1788 'oznaczai_image_labeling_background_section',1789 __( 'Hero and background images', 'oznaczai-image-labeling' ),1790 '__return_false',1791 'oznaczai-image-labeling'1792 );1793 1794 add_settings_field(1795 'bg_display',1796 __( 'Hero / background display', 'oznaczai-image-labeling' ),1797 array( $this, 'render_bg_display_field' ),1798 'oznaczai-image-labeling',1799 'oznaczai_image_labeling_background_section'1800 );1801 1802 add_settings_field(1803 'auto_backgrounds',1804 __( 'Automatic detection', 'oznaczai-image-labeling' ),1805 array( $this, 'render_auto_backgrounds_field' ),1806 'oznaczai-image-labeling',1807 'oznaczai_image_labeling_background_section'1808 );1809 1810 add_settings_section(1811 'oznaczai_image_labeling_metadata_section',1812 __( 'Metadata and automation', 'oznaczai-image-labeling' ),1813 '__return_false',1814 'oznaczai-image-labeling'1815 );1816 1817 add_settings_field(1818 'detect_metadata',1819 __( 'Upload metadata hints', 'oznaczai-image-labeling' ),1820 array( $this, 'render_detect_metadata_field' ),1821 'oznaczai-image-labeling',1822 'oznaczai_image_labeling_metadata_section'1823 );1824 1825 add_settings_field(1826 'schema_markup',1827 __( 'Schema.org metadata', 'oznaczai-image-labeling' ),1828 array( $this, 'render_schema_markup_field' ),1829 'oznaczai-image-labeling',1830 'oznaczai_image_labeling_metadata_section'1831 );1832 }1833 1834 /**1835 * Register one text option field.1836 *1837 * @param string $key Setting key.1838 * @param string $label Field label.1839 * @return void1840 */1841 private function register_text_setting_field( $key, $label ) {1842 add_settings_field(1843 $key,1844 $label,1845 array( $this, 'render_text_field' ),1846 'oznaczai-image-labeling',1847 'oznaczai_image_labeling_labels_section',1848 array( 'key' => $key )1849 1898 ); 1850 1899 } … … 1906 1955 'hero_position' => isset( $input['hero_position'] ) && in_array( $input['hero_position'], $context_positions, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['hero_position'], 1907 1956 'show_icon' => empty( $input['show_icon'] ) ? 0 : 1, 1957 'icon_variant' => $this->sanitize_icon_variant( isset( $input['icon_variant'] ) ? $input['icon_variant'] : $defaults['icon_variant'] ), 1958 'icon_show_text' => empty( $input['icon_show_text'] ) ? 0 : 1, 1908 1959 'bg_display' => isset( $input['bg_display'] ) && in_array( $input['bg_display'], $bg_displays, true ) ? $input['bg_display'] : ( isset( $input['hero_position'] ) && in_array( $input['hero_position'], $bg_displays, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['bg_display'] ), 1909 1960 'auto_backgrounds' => empty( $input['auto_backgrounds'] ) ? 0 : 1, … … 1969 2020 $value = is_numeric( $value ) ? (int) $value : (int) $default; 1970 2021 return max( $min, min( $max, $value ) ); 1971 }1972 1973 /**1974 * Render settings text field.1975 *1976 * @param array $args Field arguments.1977 * @return void1978 */1979 public function render_text_field( $args ) {1980 $settings = $this->get_settings();1981 $key = $args['key'];1982 $status = str_replace( 'label_', '', $key );1983 $defaults = $this->get_default_label_texts();1984 $placeholder = isset( $defaults[ $status ] ) ? $defaults[ $status ] : '';1985 printf(1986 '<input type="text" class="regular-text" maxlength="160" name="%1$s[%2$s]" value="%3$s" placeholder="%4$s">',1987 esc_attr( self::OPTION_SETTINGS ),1988 esc_attr( $key ),1989 esc_attr( $settings[ $key ] ),1990 esc_attr( $placeholder )1991 );1992 echo '<p class="description">' . esc_html__( 'Leave empty to use the translated default for the active site language.', 'oznaczai-image-labeling' ) . '</p>';1993 }1994 1995 /**1996 * Render position field.1997 *1998 * @return void1999 */2000 public function render_position_field() {2001 $settings = $this->get_settings();2002 $options = array(2003 'bottom-left' => __( 'Bottom left', 'oznaczai-image-labeling' ),2004 'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),2005 'top-left' => __( 'Top left', 'oznaczai-image-labeling' ),2006 'top-right' => __( 'Top right', 'oznaczai-image-labeling' ),2007 );2008 2009 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[position]">';2010 foreach ( $options as $value => $label ) {2011 printf(2012 '<option value="%1$s"%2$s>%3$s</option>',2013 esc_attr( $value ),2014 selected( $settings['position'], $value, false ),2015 esc_html( $label )2016 );2017 }2018 echo '</select>';2019 echo '<p class="description">' . esc_html__( 'For the Caption below image template, this setting controls left or right caption alignment.', 'oznaczai-image-labeling' ) . '</p>';2020 }2021 2022 /**2023 * Render template field.2024 *2025 * @return void2026 */2027 public function render_template_field() {2028 $settings = $this->get_settings();2029 $options = array(2030 'minimal' => array(2031 'label' => __( 'Minimal', 'oznaczai-image-labeling' ),2032 'description' => __( 'The least prominent option. Very light background, without borders or shadows.', 'oznaczai-image-labeling' ),2033 ),2034 'compact' => array(2035 'label' => __( 'Compact', 'oznaczai-image-labeling' ),2036 'description' => __( 'A small dark label with restrained visual weight.', 'oznaczai-image-labeling' ),2037 ),2038 'subtle' => array(2039 'label' => __( 'Subtle', 'oznaczai-image-labeling' ),2040 'description' => __( 'A semi-transparent label without shadow or bold text.', 'oznaczai-image-labeling' ),2041 ),2042 'light' => array(2043 'label' => __( 'Light', 'oznaczai-image-labeling' ),2044 'description' => __( 'Light, delicate text with an almost invisible background.', 'oznaczai-image-labeling' ),2045 ),2046 'caption' => array(2047 'label' => __( 'Caption below image', 'oznaczai-image-labeling' ),2048 'description' => __( 'A lightweight caption directly below the image. It does not cover the image.', 'oznaczai-image-labeling' ),2049 ),2050 );2051 2052 echo '<div class="oznaczai-template-grid">';2053 foreach ( $options as $value => $option ) {2054 printf(2055 '<label class="oznaczai-template-option"><input type="radio" name="%1$s[template]" value="%2$s"%3$s><span class="oznaczai-template-card"><span class="oznaczai-template-card__title">%4$s</span><span class="oznaczai-template-preview oznaczai-template-preview--%2$s"><span class="oznaczai-template-preview__icon" aria-hidden="true">AI</span><span>%6$s</span></span><span class="oznaczai-template-card__description">%5$s</span></span></label>',2056 esc_attr( self::OPTION_SETTINGS ),2057 esc_attr( $value ),2058 checked( $settings['template'], $value, false ),2059 esc_html( $option['label'] ),2060 esc_html( $option['description'] ),2061 esc_html__( 'AI-generated image', 'oznaczai-image-labeling' )2062 );2063 }2064 echo '</div>';2065 echo '<p class="description">' . esc_html__( 'Minimal is the least prominent template. You can also disable the AI icon for an even lighter result.', 'oznaczai-image-labeling' ) . '</p>';2066 }2067 2068 /**2069 * Render icon checkbox.2070 *2071 * @return void2072 */2073 public function render_icon_field() {2074 $settings = $this->get_settings();2075 printf(2076 '<label><input type="checkbox" name="%1$s[show_icon]" value="1"%2$s> %3$s</label>',2077 esc_attr( self::OPTION_SETTINGS ),2078 checked( ! empty( $settings['show_icon'] ), true, false ),2079 esc_html__( 'Show a small icon next to the text', 'oznaczai-image-labeling' )2080 );2081 }2082 2083 /**2084 * Render small-image symbol setting.2085 *2086 * @return void2087 */2088 public function render_responsive_symbol_field() {2089 $settings = $this->get_settings();2090 printf(2091 '<label><input type="checkbox" name="%1$s[responsive_symbol]" value="1"%2$s> %3$s</label>',2092 esc_attr( self::OPTION_SETTINGS ),2093 checked( ! empty( $settings['responsive_symbol'] ), true, false ),2094 esc_html__( 'Use a compact AI symbol on small images instead of the full text label', 'oznaczai-image-labeling' )2095 );2096 echo '<p class="description">' . esc_html__( 'The symbol expands to the full label on hover or keyboard focus. The accessible label remains available to assistive technology. Caption below image is never collapsed.', 'oznaczai-image-labeling' ) . '</p>';2097 }2098 2099 2100 /**2101 * Render hero/background display field.2102 *2103 * @return void2104 */2105 public function render_bg_display_field() {2106 $settings = $this->get_settings();2107 $options = array(2108 'below' => __( 'Below image / section', 'oznaczai-image-labeling' ),2109 'bar' => __( 'Subtle bar at the bottom of the hero', 'oznaczai-image-labeling' ),2110 'bottom-left' => __( 'Bottom left in hero', 'oznaczai-image-labeling' ),2111 'bottom-right' => __( 'Bottom right in hero', 'oznaczai-image-labeling' ),2112 );2113 2114 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[bg_display]">';2115 foreach ( $options as $value => $label ) {2116 printf(2117 '<option value="%1$s"%2$s>%3$s</option>',2118 esc_attr( $value ),2119 selected( $settings['bg_display'], $value, false ),2120 esc_html( $label )2121 );2122 }2123 echo '</select>';2124 echo '<p class="description">' . esc_html__( 'For a custom section background, add the CSS class oznaczai-bg-generated, oznaczai-bg-modified, or oznaczai-bg-visualization. The Below image / section option is the safest choice for heroes with their own text.', 'oznaczai-image-labeling' ) . '</p>';2125 }2126 2127 2128 /**2129 * Render automatic background detection field.2130 *2131 * @return void2132 */2133 public function render_auto_backgrounds_field() {2134 $settings = $this->get_settings();2135 printf(2136 '<label><input type="checkbox" name="%1$s[auto_backgrounds]" value="1"%2$s> %3$s</label>',2137 esc_attr( self::OPTION_SETTINGS ),2138 checked( ! empty( $settings['auto_backgrounds'] ), true, false ),2139 esc_html__( 'Automatically detect labeled images in hero sections, section backgrounds, and Elementor sliders', 'oznaczai-image-labeling' )2140 );2141 echo '<p class="description">' . esc_html__( 'Mark the file in the Media Library. No CSS classes or shortcode are required for supported layouts.', 'oznaczai-image-labeling' ) . '</p>';2142 }2143 2144 /**2145 * Render upload metadata detection setting.2146 *2147 * @return void2148 */2149 public function render_detect_metadata_field() {2150 $settings = $this->get_settings();2151 printf(2152 '<label><input type="checkbox" name="%1$s[detect_metadata]" value="1"%2$s> %3$s</label>',2153 esc_attr( self::OPTION_SETTINGS ),2154 checked( ! empty( $settings['detect_metadata'] ), true, false ),2155 esc_html__( 'Check newly uploaded images for AI-related IPTC/XMP, Content Credentials markers, and common generator signatures', 'oznaczai-image-labeling' )2156 );2157 echo '<p class="description">' . esc_html__( 'Detection only creates a review hint. It never labels an image automatically and does not validate C2PA signatures.', 'oznaczai-image-labeling' ) . '</p>';2158 }2159 2160 /**2161 * Render Schema.org setting.2162 *2163 * @return void2164 */2165 public function render_schema_markup_field() {2166 $settings = $this->get_settings();2167 printf(2168 '<label><input type="checkbox" name="%1$s[schema_markup]" value="1"%2$s> %3$s</label>',2169 esc_attr( self::OPTION_SETTINGS ),2170 checked( ! empty( $settings['schema_markup'] ), true, false ),2171 esc_html__( 'Add Schema.org digitalSourceType JSON-LD for labeled images found on the page', 'oznaczai-image-labeling' )2172 );2173 echo '<p class="description">' . esc_html__( 'This is machine-readable publisher metadata. It is separate from Content Credentials and does not provide or guarantee legal compliance.', 'oznaczai-image-labeling' ) . '</p>';2174 2022 } 2175 2023 … … 2458 2306 <?php endforeach; ?> 2459 2307 </div> 2308 <div class="oznaczai-symbol-row"> 2309 <label class="oznaczai-field"> 2310 <span><?php esc_html_e( 'Label symbol', 'oznaczai-image-labeling' ); ?></span> 2311 <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[icon_variant]" data-oznaczai-icon-variant> 2312 <?php foreach ( self::get_icon_variants() as $variant_key => $variant ) : ?> 2313 <option value="<?php echo esc_attr( $variant_key ); ?>" <?php selected( $this->sanitize_icon_variant( $settings['icon_variant'] ), $variant_key ); ?>><?php echo esc_html( $variant['label'] ); ?></option> 2314 <?php endforeach; ?> 2315 </select> 2316 </label> 2317 <p class="description"> 2318 <?php esc_html_e( 'The OznaczAI symbol is the default. The EU options use the official European Commission icons for labelling AI-generated content. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning.', 'oznaczai-image-labeling' ); ?> 2319 </p> 2320 <?php $this->render_toggle_control( 'icon_show_text', __( 'Show text next to the symbol', 'oznaczai-image-labeling' ), __( 'Turn this off to show only the symbol when it already contains its own wording.', 'oznaczai-image-labeling' ), $settings ); ?> 2321 </div> 2460 2322 <div class="oznaczai-advanced-grid"> 2461 2323 <?php $this->render_toggle_control( 'show_icon', __( 'Show AI icon', 'oznaczai-image-labeling' ), __( 'Display the small AI mark next to the label text.', 'oznaczai-image-labeling' ), $settings ); ?> 2462 2324 <?php $this->render_toggle_control( 'schema_markup', __( 'Schema.org JSON-LD', 'oznaczai-image-labeling' ), __( 'Add digitalSourceType metadata for labeled images found on the page.', 'oznaczai-image-labeling' ), $settings ); ?> 2463 2325 </div> 2326 </section> 2327 2328 <section class="oznaczai-card" id="oznaczai-transparency"> 2329 <div class="oznaczai-card-head"> 2330 <h2><?php esc_html_e( '7. AI transparency legend', 'oznaczai-image-labeling' ); ?></h2> 2331 <span aria-hidden="true">i</span> 2332 </div> 2333 <p class="oznaczai-card-intro"> 2334 <?php esc_html_e( 'Place this shortcode on any page to explain what your AI labels mean. It follows your current label texts and symbol automatically.', 'oznaczai-image-labeling' ); ?> 2335 </p> 2336 <p><code class="oznaczai-shortcode">[oznaczai_transparency_legend]</code></p> 2337 <?php $transparency_page = $this->get_transparency_page(); ?> 2338 <?php if ( $transparency_page instanceof WP_Post ) : ?> 2339 <p class="description"> 2340 <?php 2341 printf( 2342 /* translators: %s: link to the existing page. */ 2343 esc_html__( 'A page already exists: %s', 'oznaczai-image-labeling' ), 2344 '<a href="' . esc_url( (string) get_edit_post_link( $transparency_page->ID, 'url' ) ) . '">' . esc_html( get_the_title( $transparency_page ) ) . '</a>' 2345 ); 2346 ?> 2347 </p> 2348 <?php elseif ( current_user_can( 'publish_pages' ) ) : ?> 2349 <p class="description"><?php esc_html_e( 'You can also create a draft page containing this legend. It is never published automatically.', 'oznaczai-image-labeling' ); ?></p> 2350 <p> 2351 <a class="button button-secondary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oznaczai_image_labeling_create_transparency_page' ), 'oznaczai_image_labeling_create_transparency_page' ) ); ?>"> 2352 <?php esc_html_e( 'Create AI transparency page', 'oznaczai-image-labeling' ); ?> 2353 </a> 2354 </p> 2355 <?php endif; ?> 2464 2356 </section> 2465 2357 … … 2568 2460 2569 2461 /** 2570 * Render context appearance controls.2571 *2572 * @param string $context Context key.2573 * @param string $label Context label.2574 * @param array $settings Current settings.2575 * @return void2576 */2577 private function render_context_appearance_controls( $context, $label, $settings ) {2578 $prefix = 'context_' . $context;2579 $positions = array(2580 'top-left' => __( 'Top left', 'oznaczai-image-labeling' ),2581 'top-right' => __( 'Top right', 'oznaczai-image-labeling' ),2582 'bottom-left' => __( 'Bottom left', 'oznaczai-image-labeling' ),2583 'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),2584 'below' => __( 'Below image / section', 'oznaczai-image-labeling' ),2585 'bar' => __( 'Subtle bar', 'oznaczai-image-labeling' ),2586 );2587 ?>2588 <div class="oznaczai-context-style-card" data-oznaczai-context-style="<?php echo esc_attr( $context ); ?>">2589 <h3><?php echo esc_html( $label ); ?></h3>2590 <div class="oznaczai-mode-row">2591 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="global" <?php checked( $settings[ $prefix . '_mode' ], 'global' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use global settings', 'oznaczai-image-labeling' ); ?></label>2592 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="preset" <?php checked( $settings[ $prefix . '_mode' ], 'preset' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use separate preset', 'oznaczai-image-labeling' ); ?></label>2593 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="custom" <?php checked( $settings[ $prefix . '_mode' ], 'custom' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Customize manually', 'oznaczai-image-labeling' ); ?></label>2594 </div>2595 <div data-oznaczai-context-preset="<?php echo esc_attr( $context ); ?>">2596 <label class="oznaczai-field">2597 <span><?php esc_html_e( 'Preset', 'oznaczai-image-labeling' ); ?></span>2598 <?php $this->render_preset_select( $prefix . '_preset', $settings[ $prefix . '_preset' ] ); ?>2599 </label>2600 </div>2601 <div class="oznaczai-style-grid" data-oznaczai-context-custom="<?php echo esc_attr( $context ); ?>">2602 <?php2603 $this->render_style_input( $prefix . '_text_color', __( 'Text color', 'oznaczai-image-labeling' ), 'color', $settings );2604 ?>2605 <div class="oznaczai-background-tools">2606 <p class="oznaczai-label"><?php esc_html_e( 'Background', 'oznaczai-image-labeling' ); ?></p>2607 <label class="oznaczai-checkline">2608 <input type="checkbox" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_no_background]" value="1" <?php checked( ! empty( $settings[ $prefix . '_no_background' ] ) ); ?> data-oznaczai-context-input>2609 <?php esc_html_e( 'No background', 'oznaczai-image-labeling' ); ?>2610 </label>2611 </div>2612 <?php2613 $this->render_style_input( $prefix . '_bg_color', __( 'Background color', 'oznaczai-image-labeling' ), 'color', $settings );2614 ?>2615 <label class="oznaczai-field oznaczai-range-field">2616 <span><?php esc_html_e( 'Background opacity', 'oznaczai-image-labeling' ); ?></span>2617 <input type="range" min="0" max="100" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_bg_opacity]" value="<?php echo esc_attr( $settings[ $prefix . '_bg_opacity' ] ); ?>" data-oznaczai-context-input>2618 <output><?php echo esc_html( absint( $settings[ $prefix . '_bg_opacity' ] ) ); ?>%</output>2619 </label>2620 <?php2621 $this->render_style_input( $prefix . '_border_color', __( 'Border color', 'oznaczai-image-labeling' ), 'color', $settings );2622 $this->render_style_input( $prefix . '_border_width', __( 'Border width', 'oznaczai-image-labeling' ), 'number', $settings, 0, 6 );2623 $this->render_style_input( $prefix . '_radius', __( 'Radius', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );2624 $this->render_style_input( $prefix . '_font_size', __( 'Font size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 18 );2625 $this->render_style_input( $prefix . '_font_weight', __( 'Font weight', 'oznaczai-image-labeling' ), 'number', $settings, 300, 800 );2626 $this->render_style_input( $prefix . '_padding_x', __( 'Horizontal padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 24 );2627 $this->render_style_input( $prefix . '_padding_y', __( 'Vertical padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );2628 $this->render_style_input( $prefix . '_icon_color', __( 'AI icon color', 'oznaczai-image-labeling' ), 'color', $settings );2629 $this->render_style_input( $prefix . '_icon_size', __( 'AI icon size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 24 );2630 ?>2631 <?php $this->render_toggle_control( $prefix . '_shadow', __( 'Subtle shadow', 'oznaczai-image-labeling' ), __( 'Add a restrained shadow to this context label.', 'oznaczai-image-labeling' ), $settings ); ?>2632 <?php if ( in_array( $context, array( 'background', 'slider' ), true ) ) : ?>2633 <label class="oznaczai-field">2634 <span><?php esc_html_e( 'Label position', 'oznaczai-image-labeling' ); ?></span>2635 <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_position]" data-oznaczai-context-input>2636 <?php foreach ( $positions as $value => $position_label ) : ?>2637 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $settings[ $prefix . '_position' ], $value ); ?>><?php echo esc_html( $position_label ); ?></option>2638 <?php endforeach; ?>2639 </select>2640 </label>2641 <?php endif; ?>2642 </div>2643 </div>2644 <?php2645 }2646 2647 /**2648 2462 * Render a compact style input. 2649 2463 * … … 2764 2578 2765 2579 /** 2580 * Explanations shown in the transparency legend. 2581 * 2582 * These describe what the site owner stated about an image. They are not 2583 * legal statements and must not be worded as any kind of compliance claim. 2584 * 2585 * @return array<string,string> 2586 */ 2587 private function get_legend_descriptions() { 2588 return array( 2589 'generated' => __( 'The site owner has marked this image as generated using AI.', 'oznaczai-image-labeling' ), 2590 'modified' => __( 'The site owner has marked this image as modified using AI.', 'oznaczai-image-labeling' ), 2591 'visualization' => __( 'The site owner has marked this image as a visualization created using AI.', 'oznaczai-image-labeling' ), 2592 ); 2593 } 2594 2595 /** 2596 * Render the symbol used for a status inside the legend. 2597 * 2598 * @param string $status Image status. 2599 * @param string $variant Active symbol variant. 2600 * @return string 2601 */ 2602 private function render_legend_symbol( $status, $variant ) { 2603 $variants = self::get_icon_variants(); 2604 $symbols = isset( $variants[ $variant ]['symbols'] ) ? $variants[ $variant ]['symbols'] : array(); 2605 $symbol = isset( $symbols[ $status ] ) ? $symbols[ $status ] : array(); 2606 2607 if ( ! empty( $symbol['onLight'] ) ) { 2608 return sprintf( 2609 '<img class="oznaczai-legend__symbol oznaczai-legend__symbol--image" src="%1$s" alt="" width="%2$d" height="20" loading="lazy" decoding="async">', 2610 esc_url( $symbol['onLight'] ), 2611 absint( round( 20 * ( isset( $symbol['ratio'] ) ? (float) $symbol['ratio'] : 1 ) ) ) 2612 ); 2613 } 2614 2615 return sprintf( 2616 '<span class="oznaczai-legend__symbol" aria-hidden="true">%s</span>', 2617 esc_html( isset( $symbol['text'] ) ? $symbol['text'] : 'AI' ) 2618 ); 2619 } 2620 2621 /** 2622 * Render the "AI transparency" legend. 2623 * 2624 * The legend is built from the statuses, label texts and symbol variant that 2625 * are actually in use, so it never drifts from what visitors see on images. 2626 * 2627 * @param array $atts Shortcode attributes. 2628 * @return string 2629 */ 2630 public function render_transparency_legend_shortcode( $atts ) { 2631 $atts = shortcode_atts( 2632 array( 2633 'title' => '', 2634 ), 2635 $atts, 2636 'oznaczai_transparency_legend' 2637 ); 2638 2639 $settings = $this->get_settings(); 2640 $variant = $this->sanitize_icon_variant( $settings['icon_variant'] ); 2641 $descriptions = $this->get_legend_descriptions(); 2642 $title = $this->sanitize_label( $atts['title'] ); 2643 $output = '<div class="oznaczai-legend">'; 2644 2645 if ( '' !== $title ) { 2646 $output .= '<h2 class="oznaczai-legend__title">' . esc_html( $title ) . '</h2>'; 2647 } 2648 2649 $output .= '<ul class="oznaczai-legend__list">'; 2650 2651 foreach ( array( 'generated', 'modified', 'visualization' ) as $status ) { 2652 $output .= sprintf( 2653 '<li class="oznaczai-legend__item oznaczai-legend__item--%1$s">%2$s<span class="oznaczai-legend__body"><span class="oznaczai-legend__name">%3$s</span><span class="oznaczai-legend__description">%4$s</span></span></li>', 2654 esc_attr( $status ), 2655 $this->render_legend_symbol( $status, $variant ), 2656 esc_html( $this->get_configured_label( $status ) ), 2657 esc_html( isset( $descriptions[ $status ] ) ? $descriptions[ $status ] : '' ) 2658 ); 2659 } 2660 2661 $output .= '</ul>'; 2662 $output .= '<p class="oznaczai-legend__note">' . esc_html__( 'Labels are applied by the site owner. Not every image created or edited with AI carries a label, and these markings do not constitute a legal assessment.', 'oznaczai-image-labeling' ) . '</p>'; 2663 $output .= '</div>'; 2664 2665 return $output; 2666 } 2667 2668 /** 2669 * Return the existing transparency page, if one is still usable. 2670 * 2671 * @return WP_Post|null 2672 */ 2673 private function get_transparency_page() { 2674 $page_id = absint( get_option( self::OPTION_TRANSPARENCY_PAGE, 0 ) ); 2675 if ( ! $page_id ) { 2676 return null; 2677 } 2678 2679 $page = get_post( $page_id ); 2680 if ( ! $page instanceof WP_Post || 'page' !== $page->post_type || 'trash' === $page->post_status ) { 2681 return null; 2682 } 2683 2684 return $page; 2685 } 2686 2687 /** 2688 * Create a draft "AI transparency" page containing the legend shortcode. 2689 * 2690 * The page is only ever created on an explicit administrator action and is 2691 * always left as a draft, so nothing is published without review. 2692 * 2693 * @return void 2694 */ 2695 public function handle_create_transparency_page() { 2696 if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'publish_pages' ) ) { 2697 wp_die( 2698 esc_html__( 'You do not have permission to create this page.', 'oznaczai-image-labeling' ), 2699 '', 2700 array( 'response' => 403 ) 2701 ); 2702 } 2703 2704 check_admin_referer( 'oznaczai_image_labeling_create_transparency_page' ); 2705 2706 $settings_url = admin_url( 'admin.php?page=oznaczai-image-labeling' ); 2707 $existing = $this->get_transparency_page(); 2708 2709 if ( $existing instanceof WP_Post ) { 2710 wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'exists', $settings_url ) ); 2711 exit; 2712 } 2713 2714 $page_id = wp_insert_post( 2715 array( 2716 'post_title' => __( 'AI transparency', 'oznaczai-image-labeling' ), 2717 'post_content' => '[oznaczai_transparency_legend]', 2718 'post_status' => 'draft', 2719 'post_type' => 'page', 2720 ), 2721 true 2722 ); 2723 2724 if ( is_wp_error( $page_id ) || ! $page_id ) { 2725 wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'error', $settings_url ) ); 2726 exit; 2727 } 2728 2729 update_option( self::OPTION_TRANSPARENCY_PAGE, absint( $page_id ), false ); 2730 2731 wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'created', $settings_url ) ); 2732 exit; 2733 } 2734 2735 /** 2736 * Show the result of the transparency page action. 2737 * 2738 * @return void 2739 */ 2740 public function show_transparency_page_notice() { 2741 // The notice belongs to the OznaczAI settings screen, which is where the 2742 // action redirects back to. Restricting it here stops the query parameter 2743 // from surfacing the notice on unrelated wp-admin screens. 2744 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; 2745 if ( ! $screen || 'toplevel_page_oznaczai-image-labeling' !== $screen->id ) { 2746 return; 2747 } 2748 2749 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data. 2750 if ( ! isset( $_GET['oznaczai_image_labeling_page'] ) || ! current_user_can( 'manage_options' ) ) { 2751 return; 2752 } 2753 2754 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data. 2755 $result = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_page'] ) ); 2756 $page = $this->get_transparency_page(); 2757 2758 if ( 'error' === $result ) { 2759 printf( 2760 '<div class="notice notice-error is-dismissible"><p>%s</p></div>', 2761 esc_html__( 'The AI transparency page could not be created.', 'oznaczai-image-labeling' ) 2762 ); 2763 return; 2764 } 2765 2766 if ( ! in_array( $result, array( 'created', 'exists' ), true ) || ! $page instanceof WP_Post ) { 2767 return; 2768 } 2769 2770 $message = 'created' === $result 2771 ? __( 'Draft page created. Review the wording and publish it when you are ready.', 'oznaczai-image-labeling' ) 2772 : __( 'The AI transparency page already exists.', 'oznaczai-image-labeling' ); 2773 2774 printf( 2775 '<div class="notice notice-success is-dismissible"><p>%1$s <a href="%2$s">%3$s</a></p></div>', 2776 esc_html( $message ), 2777 esc_url( (string) get_edit_post_link( $page->ID, 'url' ) ), 2778 esc_html__( 'Edit the page', 'oznaczai-image-labeling' ) 2779 ); 2780 } 2781 2782 /** 2766 2783 * Add media list column. 2767 2784 * … … 2836 2853 2837 2854 /** 2855 * Available Media Library filter choices. 2856 * 2857 * @return array<string,string> 2858 */ 2859 private function get_media_filter_choices() { 2860 return array( 2861 'generated' => __( 'AI label: AI-generated', 'oznaczai-image-labeling' ), 2862 'modified' => __( 'AI label: AI-modified', 'oznaczai-image-labeling' ), 2863 'visualization' => __( 'AI label: AI visualization', 'oznaczai-image-labeling' ), 2864 'unlabeled' => __( 'AI label: no label', 'oznaczai-image-labeling' ), 2865 'hint' => __( 'AI label: metadata hint found', 'oznaczai-image-labeling' ), 2866 ); 2867 } 2868 2869 /** 2870 * Read the requested Media Library filter. 2871 * 2872 * @return string Empty string when no supported filter is active. 2873 */ 2874 private function get_requested_media_filter() { 2875 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters. 2876 if ( ! isset( $_GET['oznaczai_image_labeling_filter'] ) ) { 2877 return ''; 2878 } 2879 2880 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters. 2881 $filter = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_filter'] ) ); 2882 2883 return array_key_exists( $filter, $this->get_media_filter_choices() ) ? $filter : ''; 2884 } 2885 2886 /** 2887 * Render the OznaczAI filter next to the native Media Library filters. 2888 * 2889 * @param string $post_type Current list table post type. 2890 * @param string $which Tablenav position. 2891 * @return void 2892 */ 2893 public function render_media_library_filter( $post_type, $which ) { 2894 global $pagenow; 2895 2896 if ( 'attachment' !== $post_type || 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) { 2897 return; 2898 } 2899 2900 // The media list table renders this action twice; only the top bar carries 2901 // the native filter controls and the Filter submit button. 2902 if ( 'bar' !== $which ) { 2903 return; 2904 } 2905 2906 $current = $this->get_requested_media_filter(); 2907 2908 echo '<label class="screen-reader-text" for="oznaczai-image-labeling-filter">'; 2909 esc_html_e( 'Filter by AI label', 'oznaczai-image-labeling' ); 2910 echo '</label>'; 2911 echo '<select name="oznaczai_image_labeling_filter" id="oznaczai-image-labeling-filter">'; 2912 printf( 2913 '<option value=""%1$s>%2$s</option>', 2914 selected( $current, '', false ), 2915 esc_html__( 'All AI labels', 'oznaczai-image-labeling' ) 2916 ); 2917 foreach ( $this->get_media_filter_choices() as $value => $label ) { 2918 printf( 2919 '<option value="%1$s"%2$s>%3$s</option>', 2920 esc_attr( $value ), 2921 selected( $current, $value, false ), 2922 esc_html( $label ) 2923 ); 2924 } 2925 echo '</select>'; 2926 } 2927 2928 /** 2929 * Apply the OznaczAI filter to the Media Library list query. 2930 * 2931 * Filtering happens in the WordPress query so large libraries are never 2932 * transferred to the browser. 2933 * 2934 * @param WP_Query $query Current query. 2935 * @return void 2936 */ 2937 public function filter_media_library_query( $query ) { 2938 global $pagenow; 2939 2940 if ( ! is_admin() || ! $query instanceof WP_Query || ! $query->is_main_query() ) { 2941 return; 2942 } 2943 2944 if ( 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) { 2945 return; 2946 } 2947 2948 $filter = $this->get_requested_media_filter(); 2949 if ( '' === $filter ) { 2950 return; 2951 } 2952 2953 $meta_query = $query->get( 'meta_query' ); 2954 $meta_query = is_array( $meta_query ) ? $meta_query : array(); 2955 2956 if ( 'hint' === $filter ) { 2957 $meta_query[] = array( 2958 'key' => self::META_DETECTION_STATUS, 2959 'compare' => 'EXISTS', 2960 ); 2961 } elseif ( 'unlabeled' === $filter ) { 2962 // Current releases delete the meta row when a label is removed, but an 2963 // installation upgraded from an older version can still hold an empty 2964 // or explicit 'none' value, so both shapes count as unlabeled. 2965 $meta_query[] = array( 2966 'relation' => 'OR', 2967 array( 2968 'key' => self::META_STATUS, 2969 'compare' => 'NOT EXISTS', 2970 ), 2971 array( 2972 'key' => self::META_STATUS, 2973 'value' => array( '', 'none' ), 2974 'compare' => 'IN', 2975 ), 2976 ); 2977 } else { 2978 $meta_query[] = array( 2979 'key' => self::META_STATUS, 2980 'value' => $filter, 2981 'compare' => '=', 2982 ); 2983 } 2984 2985 $query->set( 'meta_query', $meta_query ); 2986 2987 // OznaczAI only labels images. Without this, every PDF and video would 2988 // appear under "no label". An explicit media type choice still wins. 2989 if ( in_array( $filter, array( 'unlabeled', 'hint' ), true ) && ! $query->get( 'post_mime_type' ) ) { 2990 $query->set( 'post_mime_type', 'image' ); 2991 } 2992 } 2993 2994 /** 2838 2995 * Save status changed directly in the Media list. 2839 2996 * … … 2933 3090 2934 3091 unset( $this->status_cache[ $post_id ], $this->label_cache[ $post_id ] ); 2935 $this->update_labeled_media_index( $post_id, $status );3092 $this->update_labeled_media_index( $post_id, $status, true ); 2936 3093 ++$count; 2937 3094 } 2938 3095 2939 3096 if ( $count > 0 ) { 3097 $this->persist_labeled_media_index(); 2940 3098 $this->clear_media_map_cache(); 2941 3099 $this->purge_page_cache(); -
oznaczai-image-labeling/tags/1.7.0/oznaczai.php
r3641997 r3705284 3 3 * Plugin Name: OznaczAI – AI Image Disclosure for EU AI Act 4 4 * Description: Adds visible labels to AI-generated and AI-modified images to support transparency disclosures, including EU AI Act use cases. 5 * Version: 1. 6.245 * Version: 1.7.0 6 6 * Author: Golden Pixel 7 7 * License: GPL-2.0-or-later … … 9 9 * Text Domain: oznaczai-image-labeling 10 10 * Requires at least: 6.2 11 * Tested up to: 7. 011 * Tested up to: 7.1 12 12 * Requires PHP: 7.4 13 13 */ … … 32 32 } 33 33 34 define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1. 6.24' );34 define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1.7.0' ); 35 35 define( 'OZNACZAI_IMAGE_LABELING_FILE', __FILE__ ); 36 36 define( 'OZNACZAI_IMAGE_LABELING_DIR', plugin_dir_path( __FILE__ ) ); -
oznaczai-image-labeling/tags/1.7.0/readme.txt
r3641997 r3705284 3 3 Tags: eu ai act, ai disclosure, ai transparency, deepfake, image labeling 4 4 Requires at least: 6.2 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 6.247 Stable tag: 1.7.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 32 32 * No label 33 33 34 35 == Screenshots ==36 37 1. Label AI-generated and AI-modified images directly from the WordPress Media Library.38 2. Configure label presets, custom styling, hero and slider labels, and live preview.39 3. Visible AI disclosure labels on a real website, including hero images and images inside page content.40 4. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens.41 42 43 34 = Main features = 44 35 45 36 * Quick status selection in the Media Library list view 46 37 * Automatic saving without opening each attachment 38 * Filter the Media Library by AI label, including images with no label 47 39 * Bulk labeling for multiple images 48 40 * Custom label wording … … 73 65 The Caption below image preset places the disclosure outside the image so it does not cover visual content. 74 66 67 = Label symbol = 68 69 The label symbol is separate from the label preset, so any symbol can be combined with any preset. 70 71 * OznaczAI symbol (default) 72 * EU AI icon 73 * EU AI icons per label type 74 75 The EU options use the official European Commission icons for labelling AI-generated content. AI-generated uses the Fully AI-Generated icon and AI-modified uses the Partially AI-Modified icon. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning. 76 77 Choosing an EU icon does not change what your labels mean and is not a statement about legal compliance. Existing installations keep the OznaczAI symbol until the setting is changed. 78 79 = AI transparency legend = 80 81 The shortcode below outputs a short, neutral legend that explains the labels used on your site. It follows your current label texts and symbol automatically. 82 83 [oznaczai_transparency_legend] 84 85 You can optionally create a draft page containing this legend from the plugin settings screen. The page is never published automatically. 86 75 87 == Installation == 76 88 … … 117 129 No WordPress plugin can provide or guarantee complete legal compliance. Site owners should assess their own content, role, audience, and applicable legal requirements. 118 130 131 == Third-party assets == 132 133 The optional EU label symbols are the official icons for labelling AI-generated content published by the European Commission at https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content 134 135 The published terms state that the icons "are made publicly available for everyone to use freely, without the need for attribution to the Commission or the AI Office". The files bundled with OznaczAI are unmodified copies of the official SVG assets; only their file names were changed. See assets/icons/eu/README.txt in the plugin folder for the recorded source and terms. 136 137 OznaczAI is not affiliated with, endorsed by, or approved by the European Commission or the AI Office. Displaying an icon does not establish that any legal obligation applies to an image or that it has been met. 138 119 139 == Privacy == 120 140 … … 143 163 It automatically detects many common WordPress and Elementor implementations, including Slider Revolution 6/7 image layers and slide backgrounds. Custom themes or other third-party widgets may require one of the fallback CSS classes or the background label shortcode. 144 164 165 == Screenshots == 166 167 1. Label AI-generated and AI-modified images directly from the WordPress Media Library. 168 2. Configure label presets, custom styling, hero and slider labels, and live preview. 169 3. Visible AI disclosure labels on a real website, including hero images and images inside page content. 170 4. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens. 171 145 172 == Changelog == 173 174 = 1.7.0 = 175 * Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled. 176 * Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly. 177 * Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked. 178 * Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected. 179 * Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label. 180 * Removed a database write that could happen while a public page was being rendered. 181 * Bulk labeling now writes the labeled media index once instead of once per image. 182 * Reduced the size of the labeled media data passed to the browser by removing duplicated entries. 183 * The legacy storage migration now runs only on install and version change instead of on every request. 184 * Fixed a migration from very old versions that could store a label preset that no longer exists. 185 * Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint. 186 * Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it. 187 * Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged. 188 * Removed settings screen code that was no longer used by the current panel. 146 189 147 190 = 1.6.24 = -
oznaczai-image-labeling/tags/1.7.0/uninstall.php
r3641997 r3705284 14 14 delete_option( 'oznaczai_image_labeling_labeled_media_ids' ); 15 15 delete_option( 'oznaczai_image_labeling_version' ); 16 17 /* 18 * Only the stored reference is removed. Any page created through the 19 * "AI transparency" action is ordinary site content that the administrator 20 * owns and may have edited or published, so it is never deleted here. 21 */ 22 delete_option( 'oznaczai_image_labeling_transparency_page_id' ); 23 16 24 delete_transient( 'oznaczai_image_labeling_media_map_v1' ); -
oznaczai-image-labeling/trunk/assets/css/admin.css
r3641997 r3705284 595 595 } 596 596 597 /* Image symbol variants inside the admin previews. Mirrors the frontend rule so 598 the preview keeps its WYSIWYG promise. */ 599 .oznaczai-preset-demo b.oznaczai-preview-symbol--image, 600 .oznaczai-preview-badge b.oznaczai-preview-symbol--image, 601 .oznaczai-symbol-preview b.oznaczai-preview-symbol--image { 602 min-width: 0; 603 width: calc(18px * var(--oznaczai-preview-icon-ratio, 1)); 604 height: 18px; 605 border-radius: 0; 606 background-color: transparent; 607 background-image: var(--oznaczai-preview-icon-on-light); 608 background-repeat: no-repeat; 609 background-position: center; 610 background-size: contain; 611 font-size: 0; 612 } 613 614 .oznaczai-preview-badge--minimal b.oznaczai-preview-symbol--image, 615 .oznaczai-preview-badge--dark b.oznaczai-preview-symbol--image, 616 .oznaczai-preview-badge--contrast-light b.oznaczai-preview-symbol--image { 617 background-image: var(--oznaczai-preview-icon-on-dark); 618 } 619 597 620 .oznaczai-preview-badge--icon-only { 598 621 width: 24px; … … 673 696 border-top: 1px solid #e4e8ef; 674 697 align-items: start; 698 } 699 700 .oznaczai-shortcode { 701 display: inline-block; 702 padding: 4px 8px; 703 border: 1px solid #e4e8ef; 704 border-radius: 4px; 705 background: #f6f7f9; 706 font-size: 12px; 707 } 708 709 .oznaczai-symbol-row { 710 display: grid; 711 grid-template-columns: minmax(0, 280px) minmax(0, 1fr); 712 gap: 12px 24px; 713 align-items: start; 714 margin-top: 18px; 715 padding-top: 16px; 716 border-top: 1px solid #e4e8ef; 717 } 718 719 .oznaczai-symbol-row > .description { 720 margin: 0; 721 align-self: center; 722 color: #6b7280; 723 font-size: 12px; 724 line-height: 1.5; 725 } 726 727 .oznaczai-symbol-row > .oznaczai-toggle { 728 grid-column: 1 / -1; 729 } 730 731 @media (max-width: 782px) { 732 .oznaczai-symbol-row { 733 grid-template-columns: minmax(0, 1fr); 734 } 675 735 } 676 736 -
oznaczai-image-labeling/trunk/assets/css/frontend.css
r3641997 r3705284 258 258 } 259 259 260 /* AI transparency legend ([oznaczai_transparency_legend]). 261 Deliberately plain: it inherits the theme's typography and colours so it 262 reads as part of the page rather than as a plugin widget. */ 263 .oznaczai-legend { 264 margin: 0 0 1.5em; 265 } 266 267 .oznaczai-legend__title { 268 margin: 0 0 0.75em; 269 font-size: 1.15em; 270 } 271 272 .oznaczai-legend__list { 273 margin: 0; 274 padding: 0; 275 list-style: none; 276 } 277 278 .oznaczai-legend__item { 279 display: flex; 280 align-items: flex-start; 281 gap: 0.75em; 282 padding: 0.6em 0; 283 border-bottom: 1px solid rgba(127, 127, 127, 0.22); 284 } 285 286 .oznaczai-legend__item:last-child { 287 border-bottom: 0; 288 } 289 290 .oznaczai-legend__symbol { 291 display: inline-flex; 292 align-items: center; 293 justify-content: center; 294 flex: 0 0 auto; 295 width: 20px; 296 height: 20px; 297 margin-top: 0.15em; 298 border-radius: 3px; 299 background: rgba(127, 127, 127, 0.16); 300 font-size: 9px; 301 font-weight: 600; 302 line-height: 1; 303 } 304 305 .oznaczai-legend__symbol--image { 306 width: auto; 307 max-width: 100%; 308 border-radius: 0; 309 background: none; 310 } 311 312 .oznaczai-legend__body { 313 display: flex; 314 flex-direction: column; 315 gap: 0.15em; 316 min-width: 0; 317 } 318 319 .oznaczai-legend__name { 320 font-weight: 600; 321 } 322 323 .oznaczai-legend__description { 324 opacity: 0.85; 325 } 326 327 .oznaczai-legend__note { 328 margin: 0.9em 0 0; 329 font-size: 0.875em; 330 opacity: 0.75; 331 } 332 333 /* Image symbol variants (official EU icons). 334 The artwork is a fixed two-tone drawing, so it is placed as an image rather 335 than recoloured. The white artwork is the default because the default preset 336 draws a light label on a dark translucent surface; the black artwork is used 337 wherever the label sits on a light surface. */ 338 .oznaczai-badge__icon--image { 339 width: calc(var(--oznaczai-icon-size, 13px) * var(--oznaczai-icon-ratio, 1)); 340 height: var(--oznaczai-icon-size, 13px); 341 border-radius: 0; 342 background-color: transparent; 343 background-image: var(--oznaczai-icon-on-dark); 344 background-repeat: no-repeat; 345 background-position: center; 346 background-size: contain; 347 font-size: 0; 348 } 349 350 .oznaczai-badge--light .oznaczai-badge__icon--image, 351 .oznaczai-badge--glass .oznaczai-badge__icon--image, 352 .oznaczai-badge--caption .oznaczai-badge__icon--image, 353 .oznaczai-badge--icon-only .oznaczai-badge__icon--image, 354 .oznaczai-badge--contrast-dark .oznaczai-badge__icon--image, 355 .oznaczai-bg-note.oznaczai-badge--contrast-dark .oznaczai-badge__icon--image { 356 background-color: transparent; 357 background-image: var(--oznaczai-icon-on-light); 358 } 359 360 .oznaczai-badge--contrast-light .oznaczai-badge__icon--image, 361 .oznaczai-bg-note.oznaczai-badge--contrast-light .oznaczai-badge__icon--image { 362 background-color: transparent; 363 background-image: var(--oznaczai-icon-on-dark); 364 } 365 366 /* When the symbol already spells out its own meaning there is no text node to 367 space away from it. */ 368 .oznaczai-badge--symbol-lockup { 369 gap: 0; 370 } 371 260 372 .oznaczai-badge__text { 261 373 min-width: 0; -
oznaczai-image-labeling/trunk/assets/js/admin.js
r3641997 r3705284 221 221 } 222 222 223 function renderBadge(element, template, label, forceIcon) { 223 function escapeAttribute(value) { 224 return escapeText(value).replace(/"/g, '"'); 225 } 226 227 function activeSymbol(status) { 228 var variants = window.oznaczaiImageLabelingAdmin && window.oznaczaiImageLabelingAdmin.iconVariants 229 ? window.oznaczaiImageLabelingAdmin.iconVariants 230 : {}; 231 var variant = variants[fieldValue('icon_variant', 'oznaczai')] || variants.oznaczai; 232 if (!variant || !variant.symbols) { 233 return null; 234 } 235 return variant.symbols[status] || variant.symbols.generated || null; 236 } 237 238 function symbolMarkup(status) { 239 var symbol = activeSymbol(status); 240 241 if (symbol && symbol.onLight && symbol.onDark) { 242 return '<b aria-hidden="true" class="oznaczai-preview-symbol--image" style="' + 243 '--oznaczai-preview-icon-on-light:url("' + escapeAttribute(symbol.onLight) + '");' + 244 '--oznaczai-preview-icon-on-dark:url("' + escapeAttribute(symbol.onDark) + '");' + 245 '--oznaczai-preview-icon-ratio:' + (parseFloat(symbol.ratio) || 1) + '"></b>'; 246 } 247 248 return '<b aria-hidden="true">' + escapeText(symbol && symbol.text ? symbol.text : 'AI') + '</b>'; 249 } 250 251 // Mirrors shouldRenderText() in frontend.js so the preview cannot promise 252 // something the published label will not do. 253 function symbolCarriesWording(status) { 254 var symbol = activeSymbol(status); 255 return !!(symbol && symbol.onLight && symbol.ratio && parseFloat(symbol.ratio) > 1.5); 256 } 257 258 function renderBadge(element, template, label, forceIcon, status) { 224 259 var config = applyPresetConfig(element, template); 225 260 var showIcon = forceIcon || (iconInput && iconInput.checked); 226 var text = config.iconOnly ? '' : escapeText(label); 261 var hideText = showIcon && !checked('icon_show_text') && symbolCarriesWording(status || previewType); 262 var text = (config.iconOnly || hideText) ? '' : escapeText(label); 227 263 var isPresetDemo = element.hasAttribute('data-oznaczai-preset-preview'); 228 264 var baseClass = isPresetDemo ? 'oznaczai-preset-demo' : 'oznaczai-preview-badge'; … … 231 267 element.classList.add('oznaczai-preview-badge--icon-only'); 232 268 } 233 element.innerHTML = (showIcon ? '<b aria-hidden="true">AI</b>': '') + text;269 element.innerHTML = (showIcon ? symbolMarkup(status || previewType) : '') + text; 234 270 element.setAttribute('aria-label', label); 235 271 return config; … … 291 327 } 292 328 if (behavior === 'icon' && !previewLabel.querySelector('b')) { 293 previewLabel.innerHTML = '<b aria-hidden="true">AI</b>';329 previewLabel.innerHTML = symbolMarkup(previewType); 294 330 } 295 331 previewLabel.classList.toggle('oznaczai-preview-badge--short-label', behavior === 'short'); … … 384 420 input.addEventListener('change', refreshPreview); 385 421 }); 422 423 // Symbol variant and its text toggle change both the live preview and the 424 // preset cards, so the cards are re-rendered too. 425 Array.prototype.forEach.call( 426 document.querySelectorAll('[data-oznaczai-icon-variant], input[name$="[icon_show_text]"]'), 427 function (input) { 428 input.addEventListener('change', function () { 429 renderPresetCards(); 430 refreshPreview(); 431 }); 432 } 433 ); 386 434 387 435 document.addEventListener('click', function (event) { -
oznaczai-image-labeling/trunk/assets/js/frontend.js
r3641997 r3705284 15 15 '[style*="background-image"]' 16 16 ].join(','); 17 var revolutionSelector = 'sr7-module, rs-module'; 18 var ownNodeSelector = '.oznaczai-badge, .oznaczai-bg-note, .oznaczai-popover'; 17 19 var scheduled = false; 18 20 var config = window.oznaczaiImageLabelingFrontend || {}; 19 21 var mediaMap = config.mediaMap || {}; 20 22 var schemaEntries = {}; 23 var schemaDirty = false; 24 25 // Scan scheduling state. The observer collects the smallest useful roots 26 // instead of forcing a full document rescan for every unrelated mutation. 27 var pendingRoots = []; 28 var pendingFull = false; 29 var maxPendingRoots = 60; 30 31 // Guards against the observer reacting to OznaczAI's own DOM writes. 32 var applying = 0; 33 var observer = null; 34 35 // Caches. WeakMap/WeakSet keep entries collectable with their elements. 36 var revolutionCache = new WeakMap(); 37 var revolutionEpoch = 0; 38 var contrastKeys = new WeakMap(); 39 var noteSignatures = new WeakMap(); 40 var inlineBackgrounds = new WeakMap(); 41 var loadWatched = new WeakSet(); 42 var sliderScanQueued = new WeakSet(); 43 44 var resizeTimer = null; 21 45 22 46 function meaningfulChildren(element) { … … 55 79 } 56 80 57 function buildIcon() { 81 function iconVariant() { 82 var variants = config.iconVariants || {}; 83 var key = config.iconVariant || 'oznaczai'; 84 return variants[key] || variants.oznaczai || null; 85 } 86 87 function resolveSymbol(status) { 88 var variant = iconVariant(); 89 if (!variant || !variant.symbols) { 90 return null; 91 } 92 return variant.symbols[status] || variant.symbols.generated || null; 93 } 94 95 function buildIcon(status) { 58 96 var icon = document.createElement('span'); 97 var symbol = resolveSymbol(status); 98 59 99 icon.className = 'oznaczai-badge__icon'; 60 100 icon.setAttribute('aria-hidden', 'true'); 61 icon.textContent = 'AI'; 101 102 // Image symbols are drawn from a file rather than inlined, so the same 103 // asset is cached once and the markup stays small on every page. 104 if (symbol && symbol.onLight && symbol.onDark) { 105 icon.classList.add('oznaczai-badge__icon--image'); 106 icon.style.setProperty('--oznaczai-icon-on-light', 'url("' + symbol.onLight + '")'); 107 icon.style.setProperty('--oznaczai-icon-on-dark', 'url("' + symbol.onDark + '")'); 108 icon.style.setProperty('--oznaczai-icon-ratio', symbol.ratio || 1); 109 return icon; 110 } 111 112 icon.textContent = symbol && symbol.text ? symbol.text : 'AI'; 62 113 return icon; 63 114 } 64 115 65 function fillLabel(element, label, showIcon) { 116 /** 117 * Decide whether the wording is drawn beside the symbol. It is always kept 118 * when there is no symbol, and it is only dropped for symbols that already 119 * spell out their own meaning. 120 */ 121 function shouldRenderText(status, showIcon) { 122 var symbol; 123 124 if (!showIcon || config.iconShowText !== false) { 125 return true; 126 } 127 128 symbol = resolveSymbol(status); 129 return !(symbol && symbol.onLight && symbol.ratio && symbol.ratio > 1.5); 130 } 131 132 function fillLabel(element, label, showIcon, status) { 133 var withText = shouldRenderText(status, showIcon); 134 66 135 element.textContent = ''; 67 136 if (showIcon) { 68 element.appendChild(buildIcon()); 69 } 70 element.appendChild(buildBadgeTextElement(label)); 137 element.appendChild(buildIcon(status)); 138 } 139 if (withText) { 140 element.appendChild(buildBadgeTextElement(label)); 141 } 142 element.classList.toggle('oznaczai-badge--symbol-lockup', !withText); 143 // The accessible name always carries the full wording, including when the 144 // visible text is suppressed or the symbol is image-only. 71 145 element.setAttribute('role', 'note'); 72 146 element.setAttribute('aria-label', label); … … 217 291 } 218 292 293 /** 294 * Build a cheap key describing every input the contrast result depends on. 295 * Canvas sampling only runs again when one of these actually changed. 296 */ 297 function contrastKey(badge, source) { 298 var badgeRect = badge.getBoundingClientRect(); 299 var sourceKey = ''; 300 301 if (source && source.tagName === 'IMG') { 302 sourceKey = (source.currentSrc || source.getAttribute('src') || '') + ':' + (source.complete ? '1' : '0'); 303 } 304 305 return [ 306 sourceKey, 307 Math.round(badgeRect.width), 308 Math.round(badgeRect.height), 309 Math.round(badgeRect.left), 310 Math.round(badgeRect.top) 311 ].join('|'); 312 } 313 219 314 function applyAutoContrast(badge, source, fallbackElement) { 220 315 var level; 316 var key; 317 318 if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) { 319 clearContrastClasses(badge); 320 return; 321 } 322 if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') { 323 clearContrastClasses(badge); 324 return; 325 } 326 327 key = contrastKey(badge, source); 328 if (contrastKeys.get(badge) === key) { 329 return; 330 } 221 331 222 332 clearContrastClasses(badge); 223 if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) { 224 return; 225 } 226 if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') { 227 return; 228 } 333 // Record the attempt before sampling. A sample that cannot succeed for 334 // these inputs (tainted canvas, fully transparent ancestry) will not 335 // succeed on a retry either, so retrying costs work and changes nothing. 336 // A genuine change produces a different key and is picked up normally. 337 contrastKeys.set(badge, key); 229 338 230 339 if (source && source.tagName === 'IMG') { … … 266 375 function getCurrentTemplate() { 267 376 return config.template || 'minimal'; 268 }269 270 function templateClasses() {271 return [272 'oznaczai-badge--minimal',273 'oznaczai-badge--compact',274 'oznaczai-badge--subtle',275 'oznaczai-badge--light',276 'oznaczai-badge--caption',277 'oznaczai-badge--glass',278 'oznaczai-badge--dark',279 'oznaczai-badge--icon-only'280 ];281 }282 283 function containerTemplateClasses() {284 return [285 'oznaczai-template--minimal',286 'oznaczai-template--compact',287 'oznaczai-template--subtle',288 'oznaczai-template--light',289 'oznaczai-template--caption',290 'oznaczai-template--glass',291 'oznaczai-template--dark',292 'oznaczai-template--icon-only'293 ];294 377 } 295 378 … … 338 421 339 422 function resolveContextPosition(context, fallback, status, presetOverride) { 340 var style = config.style || {};341 var hero = style.hero || {};342 423 var presetConfigs = config.presetConfigs || {}; 343 424 var custom = resolveStyleCustom(context); … … 458 539 } 459 540 541 if (schemaEntries[absoluteUrl]) { 542 return; 543 } 544 460 545 schemaEntries[absoluteUrl] = { 461 546 '@type': 'ImageObject', … … 463 548 digitalSourceType: sourceType 464 549 }; 550 schemaDirty = true; 465 551 } 466 552 … … 469 555 var graph; 470 556 471 if (!config.schemaMarkup ) {557 if (!config.schemaMarkup || !schemaDirty) { 472 558 return; 473 559 } … … 492 578 '@graph': graph 493 579 }); 580 schemaDirty = false; 494 581 } 495 582 … … 501 588 var device; 502 589 var behavior; 590 var status; 503 591 504 592 if (!badge) { … … 506 594 } 507 595 596 status = getImageStatus(image); 508 597 badge.classList.remove('oznaczai-badge--symbol-only', 'oznaczai-badge--short-label'); 509 598 badge.removeAttribute('title'); … … 521 610 if (behavior === 'icon') { 522 611 if (!badge.querySelector('.oznaczai-badge__icon')) { 523 badge.insertBefore(buildIcon( ), badge.firstChild);612 badge.insertBefore(buildIcon(status), badge.firstChild); 524 613 } 525 614 badge.classList.add('oznaczai-badge--symbol-only'); … … 536 625 if (width > 0 && width <= 160) { 537 626 if (!badge.querySelector('.oznaczai-badge__icon')) { 538 badge.insertBefore(buildIcon( ), badge.firstChild);627 badge.insertBefore(buildIcon(status), badge.firstChild); 539 628 } 540 629 badge.classList.remove('oznaczai-badge--short-label'); … … 546 635 } 547 636 548 function refreshResponsiveSymbols() { 549 var images = document.querySelectorAll(imageSelector); 637 function findImageBadge(image) { 638 var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image'); 639 return container ? getDirectBadge(container) : null; 640 } 641 642 /** 643 * Re-evaluate responsive behaviour for already labeled images. This depends on 644 * viewport and element width, so it runs on resize and after image load only. 645 */ 646 function refreshResponsiveSymbols(root) { 647 var scope = root && root.querySelectorAll ? root : document; 648 var images = scope.querySelectorAll(imageSelector); 649 550 650 Array.prototype.forEach.call(images, function (image) { 551 var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image'); 552 var badge = container ? getDirectBadge(container) : null; 553 var template = resolveTemplate(getImageStatus(image), image, null); 554 var showIcon = getCurrentShowIcon(); 555 updateResponsiveBadge(image, badge, template, showIcon, getCurrentImageLabel(image)); 651 var badge = findImageBadge(image); 652 var template; 653 654 if (!badge) { 655 return; 656 } 657 658 template = resolveTemplate(getImageStatus(image), image, null); 659 updateResponsiveBadge(image, badge, template, getCurrentShowIcon(), getCurrentImageLabel(image)); 660 applyAutoContrast(badge, image, badge.parentElement); 556 661 }); 557 662 } … … 561 666 return false; 562 667 } 563 return !!image.closest('sr7-module, rs-module'); 668 return !!image.closest(revolutionSelector); 669 } 670 671 /** 672 * Everything the rendered badge depends on. When this is unchanged there is 673 * nothing to rebuild, so the expensive path is skipped entirely. 674 */ 675 function imageSignature(image) { 676 return [ 677 image.getAttribute('data-oznaczai-type') || '', 678 image.getAttribute('data-oznaczai-label') || '', 679 image.getAttribute('data-oznaczai-custom-label') || '', 680 image.getAttribute('data-oznaczai-preset') || '' 681 ].join('|'); 682 } 683 684 function watchImageLoad(image) { 685 if (loadWatched.has(image) || image.complete) { 686 return; 687 } 688 loadWatched.add(image); 689 image.addEventListener('load', function () { 690 var badge = findImageBadge(image); 691 if (!badge) { 692 return; 693 } 694 withOwnMutations(function () { 695 updateResponsiveBadge(image, badge, resolveTemplate(getImageStatus(image), image, null), getCurrentShowIcon(), getCurrentImageLabel(image)); 696 applyAutoContrast(badge, image, badge.parentElement); 697 }); 698 }, { once: true }); 564 699 } 565 700 566 701 function createBadge(image) { 567 var label = getCurrentImageLabel(image); 568 var position = getCurrentPosition(); 569 var status = getImageStatus(image); 570 var context = resolveContext(image, null); 571 var template = resolveTemplate(status, image, context); 572 var showIcon = getCurrentShowIcon(); 702 var signature = imageSignature(image); 703 var label; 704 var position; 705 var status; 706 var context; 707 var template; 708 var showIcon; 573 709 var target; 574 710 var parent; … … 579 715 var originalImageWidth; 580 716 717 if (image.getAttribute('data-oznaczai-ready') === '1' && image.getAttribute('data-oznaczai-sig') === signature) { 718 return; 719 } 720 721 label = getCurrentImageLabel(image); 722 position = getCurrentPosition(); 723 status = getImageStatus(image); 724 context = resolveContext(image, null); 725 template = resolveTemplate(status, image, context); 726 showIcon = getCurrentShowIcon(); 727 581 728 if (!label) { 582 729 return; … … 587 734 if (isManagedByRevolution(image)) { 588 735 image.setAttribute('data-oznaczai-ready', '1'); 589 return; 590 } 591 592 registerSchemaEntry(image.currentSrc || image.getAttribute('src'), getImageStatus(image)); 736 image.setAttribute('data-oznaczai-sig', signature); 737 return; 738 } 739 740 registerSchemaEntry(image.currentSrc || image.getAttribute('src'), status); 593 741 594 742 originalParentWidth = image.parentElement ? image.parentElement.getBoundingClientRect().width : 0; … … 684 832 ); 685 833 badge.classList.add('oznaczai-badge--' + template); 686 fillLabel(badge, label, showIcon );834 fillLabel(badge, label, showIcon, status); 687 835 applyStyleData(badge, status, template, context); 688 836 applyAutoContrast(badge, image, container); 689 837 updateResponsiveBadge(image, badge, template, showIcon, label); 690 838 setupPopover(badge, label); 839 watchImageLoad(image); 691 840 image.setAttribute('data-oznaczai-ready', '1'); 841 image.setAttribute('data-oznaczai-sig', signature); 692 842 } 693 843 … … 727 877 } 728 878 879 /** 880 * mediaMap entries no longer carry the label text. Resolve it the same way a 881 * regular image does so background and slider labels stay identical. 882 */ 883 function resolveMapLabel(data) { 884 var status = data && data.status ? data.status : 'generated'; 885 if (data && data.label) { 886 return data.label; 887 } 888 return (config.labels && config.labels[status]) || ''; 889 } 890 891 function mapEntryToData(data, url) { 892 if (!data) { 893 return null; 894 } 895 // NOTE: `preset` is deliberately not carried over. Up to and including 896 // 1.6.24 the background and Slider Revolution extractors never passed the 897 // per-image preset through, so hero/slider labels have always used the 898 // hero preset. Propagating it here would silently restyle existing sites 899 // on upgrade, so the change is left for a release that can announce it. 900 return { 901 id: data.id, 902 status: data.status, 903 label: resolveMapLabel(data), 904 url: url 905 }; 906 } 907 729 908 function addUniqueUrl(urls, value) { 730 909 var clean = typeof value === 'string' ? value.trim() : ''; … … 844 1023 } 845 1024 1025 /** 1026 * Cheap fingerprint used to decide whether the expensive full slide walk has 1027 * to run again. Slider Revolution swaps sources and markup on slide change, 1028 * so those are exactly the inputs worth watching. 1029 */ 1030 function revolutionSlideKey(slide) { 1031 var image = slide.querySelector ? slide.querySelector('img, sr7-bg, rs-bg') : null; 1032 return [ 1033 revolutionEpoch, 1034 slide.childElementCount, 1035 image ? (image.getAttribute('src') || image.getAttribute('data-src') || image.getAttribute('data-lazyload') || '') : '', 1036 slide.getAttribute('data-key') || slide.getAttribute('data-skey') || '' 1037 ].join('|'); 1038 } 1039 846 1040 function findAutomaticRevolutionSlideData(slide) { 847 var urls = extractRevolutionSlideUrls(slide); 1041 var key = revolutionSlideKey(slide); 1042 var cached = revolutionCache.get(slide); 1043 var urls; 848 1044 var i; 849 1045 var data; 1046 var result = null; 1047 1048 if (cached && cached.key === key) { 1049 return cached.data; 1050 } 1051 1052 urls = extractRevolutionSlideUrls(slide); 850 1053 851 1054 for (i = 0; i < urls.length; i += 1) { 852 1055 data = lookupMedia(urls[i]); 853 1056 if (data) { 854 return { 855 id: data.id, 856 status: data.status, 857 label: data.label, 858 url: urls[i] 859 }; 860 } 861 } 862 863 return null; 1057 result = mapEntryToData(data, urls[i]); 1058 break; 1059 } 1060 } 1061 1062 revolutionCache.set(slide, { key: key, data: result }); 1063 return result; 864 1064 } 865 1065 … … 885 1085 data = lookupMedia(urls[i]); 886 1086 if (data) { 887 return { 888 id: data.id, 889 status: data.status, 890 label: data.label, 891 url: urls[i] 892 }; 1087 return mapEntryToData(data, urls[i]); 893 1088 } 894 1089 } … … 898 1093 899 1094 function findSliderOwner(element) { 900 var revolution = element.closest( 'sr7-module, rs-module');1095 var revolution = element.closest(revolutionSelector); 901 1096 if (revolution) { 902 1097 return revolution; … … 910 1105 911 1106 function findSectionOwner(element) { 912 var revolution = element.closest( 'sr7-module, rs-module');1107 var revolution = element.closest(revolutionSelector); 913 1108 if (revolution) { 914 1109 return revolution; … … 934 1129 var note; 935 1130 var template; 1131 var signature; 936 1132 if (!owner || !owner.parentNode) { 937 1133 return null; … … 945 1141 owner.parentNode.insertBefore(note, owner.nextSibling); 946 1142 } 947 setBelowNoteAlignment(note, position); 948 fillLabel(note, label, config.showIcon !== false); 1143 949 1144 status = status || 'generated'; 950 1145 context = context || 'background'; 951 1146 template = preset || resolveTemplate(status, null, context); 1147 signature = [label, position, status, template, context].join('|'); 1148 1149 // Slider libraries mutate slide classes on every transition frame. Without 1150 // this guard the note would be torn down and rebuilt each time. 1151 if (noteSignatures.get(note) === signature) { 1152 return note; 1153 } 1154 noteSignatures.set(note, signature); 1155 1156 setBelowNoteAlignment(note, position); 1157 fillLabel(note, label, config.showIcon !== false, status); 952 1158 note.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only'); 953 1159 note.classList.add('oznaczai-badge--' + template); … … 1054 1260 if (active) { 1055 1261 labeled = active.hasAttribute('data-oznaczai-slide-label') ? active : null; 1056 } else if (slider.matches( 'sr7-module, rs-module')) {1262 } else if (slider.matches(revolutionSelector)) { 1057 1263 labeled = null; 1058 1264 } else { … … 1097 1303 if ('MutationObserver' in window) { 1098 1304 new MutationObserver(function () { 1099 updateSliderNote(slider); 1305 // Slide transitions fire dozens of records per second. Collapse them 1306 // into one update per frame. 1307 if (sliderScanQueued.has(slider)) { 1308 return; 1309 } 1310 sliderScanQueued.add(slider); 1311 window.requestAnimationFrame(function () { 1312 sliderScanQueued.delete(slider); 1313 withOwnMutations(function () { 1314 updateSliderNote(slider); 1315 }); 1316 }); 1100 1317 }).observe(slider, { 1101 1318 attributes: true, … … 1149 1366 function createOverlayLabel(target, data, display, context) { 1150 1367 var badge = target.querySelector(':scope > .oznaczai-badge.oznaczai-badge--bg'); 1368 var status = data.status || 'generated'; 1369 var template; 1370 var signature; 1371 1151 1372 context = context || 'background'; 1152 var template = data.preset || resolveTemplate(data.status || 'generated', null, context); 1373 template = data.preset || resolveTemplate(status, null, context); 1374 signature = [data.label, display, status, template, context].join('|'); 1375 1376 if (badge && noteSignatures.get(badge) === signature) { 1377 return; 1378 } 1153 1379 1154 1380 target.classList.add('oznaczai-bg-target'); … … 1170 1396 } 1171 1397 1398 noteSignatures.set(badge, signature); 1399 1172 1400 badge.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only', 'oznaczai-badge--hero-bar'); 1173 1401 badge.classList.add('oznaczai-badge--' + template); … … 1175 1403 badge.classList.add('oznaczai-badge--hero-bar'); 1176 1404 } 1177 fillLabel(badge, data.label, config.showIcon !== false );1178 applyStyleData(badge, data.status || 'generated', template, context);1405 fillLabel(badge, data.label, config.showIcon !== false, status); 1406 applyStyleData(badge, status, template, context); 1179 1407 applyAutoContrast(badge, null, target); 1180 1408 setupPopover(badge, data.label); … … 1249 1477 return; 1250 1478 } 1479 // A negative result is remembered too. Without this, every generic 1480 // hero/banner/Elementor node was re-measured with getComputedStyle on 1481 // every single scan. 1482 if (target.getAttribute('data-oznaczai-bg-checked') === '1') { 1483 return; 1484 } 1251 1485 // Real content images are handled by createBadge(). A generic hero/banner 1252 1486 // selector must never promote an <img> into a second background disclosure. 1253 1487 if (target.matches && target.matches('img, picture, source')) { 1488 target.setAttribute('data-oznaczai-bg-checked', '1'); 1254 1489 return; 1255 1490 } … … 1257 1492 if (data) { 1258 1493 applyBackgroundLabel(target, data); 1259 } 1494 return; 1495 } 1496 target.setAttribute('data-oznaczai-bg-checked', '1'); 1260 1497 } 1261 1498 … … 1271 1508 } 1272 1509 1273 function scan(root) { 1274 var images; 1275 var manualBackgrounds; 1276 var automaticBackgrounds; 1277 var monitoredSliders; 1278 var revolutionSliders; 1279 1280 root = root || document; 1281 images = collect(root, imageSelector); 1282 manualBackgrounds = collect(root, manualBackgroundSelector); 1283 automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : []; 1284 monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]'); 1285 revolutionSliders = config.autoBackgrounds ? collect(root, 'sr7-module, rs-module') : []; 1510 function scanRoot(root) { 1511 var images = collect(root, imageSelector); 1512 var manualBackgrounds = collect(root, manualBackgroundSelector); 1513 var automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : []; 1514 var revolutionSliders = config.autoBackgrounds ? collect(root, revolutionSelector) : []; 1515 var monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]'); 1286 1516 1287 1517 images.forEach(createBadge); … … 1290 1520 revolutionSliders.forEach(scanRevolutionSlider); 1291 1521 monitoredSliders.forEach(updateSliderNote); 1292 refreshResponsiveSymbols(); 1293 flushSchemaMarkup(); 1294 } 1295 1296 function scheduleScan() { 1522 } 1523 1524 /** 1525 * Run a callback with the global observer detached so OznaczAI never reacts 1526 * to its own DOM writes. disconnect() also empties the pending record queue, 1527 * which is what actually breaks the observer -> scan -> observer loop. 1528 */ 1529 function withOwnMutations(callback) { 1530 applying += 1; 1531 if (observer) { 1532 observer.disconnect(); 1533 } 1534 try { 1535 callback(); 1536 } finally { 1537 applying -= 1; 1538 if (observer && applying === 0) { 1539 startObserving(); 1540 } 1541 } 1542 } 1543 1544 function scan(root) { 1545 withOwnMutations(function () { 1546 scanRoot(root || document); 1547 flushSchemaMarkup(); 1548 }); 1549 } 1550 1551 function isOwnNode(node) { 1552 return !!(node && node.nodeType === 1 && node.closest && node.closest(ownNodeSelector)); 1553 } 1554 1555 /** 1556 * Compare only the inline background-image declaration. This reads the 1557 * element's own style object, so unlike getComputedStyle it does not force 1558 * the browser to flush pending style or layout work. 1559 */ 1560 function inlineBackgroundChanged(element) { 1561 var current; 1562 1563 if (!element || !element.style) { 1564 return true; 1565 } 1566 1567 current = element.style.backgroundImage || ''; 1568 if (inlineBackgrounds.get(element) === current) { 1569 return false; 1570 } 1571 1572 inlineBackgrounds.set(element, current); 1573 return true; 1574 } 1575 1576 function queueRoot(node) { 1577 var scopeRoot; 1578 1579 if (pendingFull || !node || node.nodeType !== 1 || isOwnNode(node)) { 1580 return; 1581 } 1582 1583 // Anything inside a Slider Revolution module has to be evaluated at module 1584 // level, because slide detection works on the whole module. 1585 scopeRoot = node.closest ? node.closest(revolutionSelector) : null; 1586 scopeRoot = scopeRoot || node; 1587 1588 if (pendingRoots.indexOf(scopeRoot) !== -1) { 1589 return; 1590 } 1591 1592 if (pendingRoots.length >= maxPendingRoots) { 1593 pendingFull = true; 1594 pendingRoots.length = 0; 1595 return; 1596 } 1597 1598 pendingRoots.push(scopeRoot); 1599 } 1600 1601 function runScheduledScan() { 1602 var roots = pendingRoots.slice(); 1603 var full = pendingFull; 1604 1605 scheduled = false; 1606 pendingRoots.length = 0; 1607 pendingFull = false; 1608 1609 withOwnMutations(function () { 1610 if (full) { 1611 scanRoot(document); 1612 } else { 1613 roots.forEach(function (root) { 1614 if (root === document || (root.isConnected !== false)) { 1615 scanRoot(root); 1616 } 1617 }); 1618 } 1619 flushSchemaMarkup(); 1620 }); 1621 } 1622 1623 function scheduleScan(full) { 1624 if (full === true) { 1625 pendingFull = true; 1626 pendingRoots.length = 0; 1627 } 1297 1628 if (scheduled) { 1298 1629 return; 1299 1630 } 1300 1631 scheduled = true; 1301 window.requestAnimationFrame(function () { 1302 scheduled = false; 1303 scan(document); 1304 }); 1632 window.requestAnimationFrame(runScheduledScan); 1633 } 1634 1635 function scheduleFullScan() { 1636 scheduleScan(true); 1637 } 1638 1639 function startObserving() { 1640 if (!observer) { 1641 return; 1642 } 1643 observer.observe(document.body, { 1644 childList: true, 1645 subtree: true, 1646 attributes: true, 1647 // `class` is intentionally not observed globally. Slider state classes 1648 // are handled by the per-slider observer in ensureSliderMonitoring(), 1649 // and every other class change on the page is irrelevant to labeling. 1650 attributeFilter: ['style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb'] 1651 }); 1652 } 1653 1654 function handleMutations(mutations) { 1655 var queued = false; 1656 var i; 1657 var mutation; 1658 var j; 1659 var node; 1660 1661 if (applying > 0) { 1662 return; 1663 } 1664 1665 for (i = 0; i < mutations.length; i += 1) { 1666 mutation = mutations[i]; 1667 1668 if (mutation.type === 'attributes') { 1669 if (isOwnNode(mutation.target)) { 1670 continue; 1671 } 1672 // Scroll animations and motion effects rewrite `style` on every 1673 // frame without ever touching the background. Reading the inline 1674 // value is cheap and avoids forcing a style recalculation for 1675 // changes that cannot affect labeling. 1676 if (mutation.attributeName === 'style' && !inlineBackgroundChanged(mutation.target)) { 1677 continue; 1678 } 1679 // A background source may have just been swapped in, so the cached 1680 // negative result for this element is no longer trustworthy. 1681 if (mutation.target.hasAttribute && mutation.target.hasAttribute('data-oznaczai-bg-checked')) { 1682 mutation.target.removeAttribute('data-oznaczai-bg-checked'); 1683 } 1684 queueRoot(mutation.target); 1685 queued = true; 1686 continue; 1687 } 1688 1689 if (mutation.addedNodes) { 1690 for (j = 0; j < mutation.addedNodes.length; j += 1) { 1691 node = mutation.addedNodes[j]; 1692 if (node.nodeType !== 1 || isOwnNode(node)) { 1693 continue; 1694 } 1695 queueRoot(node); 1696 queued = true; 1697 } 1698 } 1699 } 1700 1701 if (queued) { 1702 scheduleScan(); 1703 } 1704 } 1705 1706 function handleResize() { 1707 if (resizeTimer) { 1708 window.clearTimeout(resizeTimer); 1709 } 1710 resizeTimer = window.setTimeout(function () { 1711 resizeTimer = null; 1712 withOwnMutations(function () { 1713 refreshResponsiveSymbols(document); 1714 }); 1715 }, 200); 1716 } 1717 1718 function handleRevolutionEvent() { 1719 // Slide media changed, so the per-slide URL cache has to be rebuilt. 1720 revolutionEpoch += 1; 1721 scheduleFullScan(); 1305 1722 } 1306 1723 1307 1724 function init() { 1308 1725 scan(document); 1726 withOwnMutations(function () { 1727 refreshResponsiveSymbols(document); 1728 }); 1729 1730 // Themes and lazy-loaders often finish late. These two passes are cheap 1731 // now because processed images short-circuit on their signature. 1309 1732 window.setTimeout(function () { scan(document); }, 700); 1310 1733 window.setTimeout(function () { scan(document); }, 1800); 1311 window.addEventListener('resize', scheduleScan, { passive: true });1734 window.addEventListener('resize', handleResize, { passive: true }); 1312 1735 1313 1736 // Slider Revolution 7 exposes native lifecycle events. Re-scan after a … … 1315 1738 // current-slide disclosure stay synchronized. Legacy SR6 remains covered 1316 1739 // by the DOM observer and data-slideactive monitoring below. 1317 document.addEventListener('sr.module.ready', scheduleScan);1318 document.addEventListener('sr.slide.afterChange', scheduleScan);1740 document.addEventListener('sr.module.ready', handleRevolutionEvent); 1741 document.addEventListener('sr.slide.afterChange', handleRevolutionEvent); 1319 1742 1320 1743 if ('MutationObserver' in window) { 1321 new MutationObserver(function (mutations) { 1322 var shouldScan = mutations.some(function (mutation) { 1323 return (mutation.addedNodes && mutation.addedNodes.length > 0) || mutation.type === 'attributes'; 1324 }); 1325 if (shouldScan) { 1326 scheduleScan(); 1327 } 1328 }).observe(document.body, { 1329 childList: true, 1330 subtree: true, 1331 attributes: true, 1332 attributeFilter: ['class', 'style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb', 'data-current', 'data-slideactive'] 1333 }); 1744 observer = new MutationObserver(handleMutations); 1745 startObserving(); 1334 1746 } 1335 1747 -
oznaczai-image-labeling/trunk/changelog.txt
r3641997 r3705284 1 = 1.7.0 = 2 * Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled. 3 * Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly. 4 * Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked. 5 * Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected. 6 * Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label. 7 * Removed a database write that could happen while a public page was being rendered. 8 * Bulk labeling now writes the labeled media index once instead of once per image. 9 * Reduced the size of the labeled media data passed to the browser by removing duplicated entries. 10 * The legacy storage migration now runs only on install and version change instead of on every request. 11 * Fixed a migration from very old versions that could store a label preset that no longer exists. 12 * Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint. 13 * Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it. 14 * Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged. 15 * Removed settings screen code that was no longer used by the current panel. 16 1 17 = 1.6.24 = 2 18 * Rebuilt preset cards with a simple flex column flow so title, description and preview badge can never overlap. -
oznaczai-image-labeling/trunk/includes/class-oznaczai-image-labeling-plugin.php
r3641997 r3705284 21 21 const OPTION_LABELED_IDS = 'oznaczai_image_labeling_labeled_media_ids'; 22 22 const OPTION_VERSION = 'oznaczai_image_labeling_version'; 23 const OPTION_TRANSPARENCY_PAGE = 'oznaczai_image_labeling_transparency_page_id'; 23 24 const TRANSIENT_MEDIA_MAP = 'oznaczai_image_labeling_media_map_v1'; 24 25 … … 67 68 68 69 /** 70 * Cached labeled attachment index for the current request. 71 * 72 * @var array<int>|null 73 */ 74 private $labeled_ids = null; 75 76 /** 77 * Stale index entries detected while building the media map on a public 78 * request. They are written out on the next administrative save. 79 * 80 * @var array<int>|null 81 */ 82 private $labeled_ids_pending_cleanup = null; 83 84 /** 69 85 * Get singleton instance. 70 86 * … … 178 194 add_filter( 'manage_media_columns', array( $this, 'add_media_column' ) ); 179 195 add_action( 'manage_media_custom_column', array( $this, 'render_media_column' ), 10, 2 ); 196 add_action( 'restrict_manage_posts', array( $this, 'render_media_library_filter' ), 10, 2 ); 197 add_action( 'pre_get_posts', array( $this, 'filter_media_library_query' ) ); 180 198 add_filter( 'bulk_actions-upload', array( $this, 'add_bulk_actions' ) ); 181 199 add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_actions' ), 10, 3 ); 182 200 add_action( 'admin_notices', array( $this, 'show_bulk_action_notice' ) ); 201 add_action( 'admin_notices', array( $this, 'show_transparency_page_notice' ) ); 183 202 add_action( 'wp_ajax_oznaczai_image_labeling_update_media_status', array( $this, 'ajax_update_media_status' ) ); 203 add_action( 'admin_post_oznaczai_image_labeling_create_transparency_page', array( $this, 'handle_create_transparency_page' ) ); 184 204 185 205 add_action( 'edit_attachment', array( $this, 'clear_media_map_cache' ) ); … … 195 215 */ 196 216 public function maybe_upgrade() { 197 self::migrate_legacy_storage();198 199 217 $stored_version = (string) get_option( self::OPTION_VERSION, '' ); 200 218 if ( '' !== $stored_version && ! version_compare( $stored_version, OZNACZAI_IMAGE_LABELING_VERSION, '<' ) ) { 201 219 return; 202 220 } 221 222 // Only reached on a fresh install or a version change. Installations that 223 // are already up to date no longer pay for the legacy lookup on every 224 // request. Activation still calls this directly, so upgrades that skip 225 // deactivation are covered either way. 226 self::migrate_legacy_storage(); 203 227 204 228 if ( '' === $stored_version || version_compare( $stored_version, '1.6.0', '<' ) ) { … … 234 258 add_shortcode( 'oznaczai_image_label', array( $this, 'render_badge_shortcode' ) ); 235 259 add_shortcode( 'oznaczai_image_label_background', array( $this, 'render_background_badge_shortcode' ) ); 260 add_shortcode( 'oznaczai_transparency_legend', array( $this, 'render_transparency_legend_shortcode' ) ); 236 261 } 237 262 … … 281 306 'hero_position' => 'below', 282 307 'show_icon' => 1, 308 'icon_variant' => 'oznaczai', 309 'icon_show_text' => 1, 283 310 'bg_display' => 'below', 284 311 'auto_backgrounds' => 1, … … 403 430 404 431 if ( empty( $stored['template'] ) && ! empty( $stored['style'] ) ) { 405 $stored['template'] = 'light' === $stored['style'] ? 'light' : 'subtle'; 432 // 'subtle' was dropped from the preset list, so it resolved to no 433 // preset configuration and the frontend already fell back to the 434 // 'minimal' custom properties. Storing 'minimal' keeps exactly the 435 // same rendered label while making the stored value valid again. 436 $stored['template'] = 'light' === $stored['style'] ? 'light' : 'minimal'; 406 437 } 407 438 … … 626 657 627 658 /** 659 * Label symbol variants. 660 * 661 * A preset controls how the label looks. A symbol variant controls which 662 * mark appears inside it. They are independent on purpose, so every existing 663 * preset keeps working with every symbol. 664 * 665 * The default variant reproduces the historical OznaczAI "AI" mark exactly, 666 * so an upgrade never changes an existing site's labels on its own. 667 * 668 * The bundled EU assets are the official European Commission icons. See 669 * assets/icons/eu/README.txt for their source and terms of use. 670 * 671 * @return array<string,array<string,mixed>> 672 */ 673 private static function get_icon_variants() { 674 $base = OZNACZAI_IMAGE_LABELING_URL . 'assets/icons/eu/'; 675 676 $basic = array( 677 'onLight' => $base . 'eu-ai-black.svg', 678 'onDark' => $base . 'eu-ai-white.svg', 679 'ratio' => 1, 680 ); 681 682 return array( 683 'oznaczai' => array( 684 'label' => __( 'OznaczAI symbol', 'oznaczai-image-labeling' ), 685 'symbols' => array( 686 'generated' => array( 'text' => 'AI' ), 687 'modified' => array( 'text' => 'AI' ), 688 'visualization' => array( 'text' => 'AI' ), 689 ), 690 ), 691 'eu-basic' => array( 692 'label' => __( 'EU AI icon', 'oznaczai-image-labeling' ), 693 'symbols' => array( 694 'generated' => $basic, 695 'modified' => $basic, 696 'visualization' => $basic, 697 ), 698 ), 699 'eu-status' => array( 700 'label' => __( 'EU AI icons per label type', 'oznaczai-image-labeling' ), 701 'symbols' => array( 702 'generated' => array( 703 'onLight' => $base . 'eu-ai-generated-black.svg', 704 'onDark' => $base . 'eu-ai-generated-white.svg', 705 'ratio' => 3.157, 706 ), 707 'modified' => array( 708 'onLight' => $base . 'eu-ai-modified-black.svg', 709 'onDark' => $base . 'eu-ai-modified-white.svg', 710 'ratio' => 3, 711 ), 712 // There is no official variant meaning "visualization", so this 713 // status intentionally falls back to the neutral basic icon 714 // instead of being presented as fully generated or modified. 715 'visualization' => $basic, 716 ), 717 ), 718 ); 719 } 720 721 /** 722 * Validate a symbol variant key. 723 * 724 * @param string $variant Variant key. 725 * @return string 726 */ 727 private function sanitize_icon_variant( $variant ) { 728 $variant = sanitize_key( $variant ); 729 return array_key_exists( $variant, self::get_icon_variants() ) ? $variant : 'oznaczai'; 730 } 731 732 /** 733 * Whether the active symbol variant already carries its own wording. 734 * 735 * The EU per-type assets are wide lockups that spell out their meaning as 736 * vector outlines, so repeating the label text beside them is redundant. 737 * 738 * @param string $variant Variant key. 739 * @return bool 740 */ 741 private function icon_variant_has_wording( $variant ) { 742 return 'eu-status' === $variant; 743 } 744 745 /** 628 746 * Validate a preset key. 629 747 * … … 706 824 707 825 /** 708 * Return custom style config for a supported visual context.709 *710 * @param string $context Context key.711 * @param array $settings Current settings.712 * @return array713 */714 private function get_context_custom_style_config( $context, $settings ) {715 return array(716 'textColor' => $settings[ 'context_' . $context . '_text_color' ],717 'noBackground' => ! empty( $settings[ 'context_' . $context . '_no_background' ] ),718 'bgColor' => $settings[ 'context_' . $context . '_bg_color' ],719 'bgOpacity' => (int) $settings[ 'context_' . $context . '_bg_opacity' ],720 'borderColor' => $settings[ 'context_' . $context . '_border_color' ],721 'borderWidth' => (int) $settings[ 'context_' . $context . '_border_width' ],722 'radius' => (int) $settings[ 'context_' . $context . '_radius' ],723 'fontSize' => (int) $settings[ 'context_' . $context . '_font_size' ],724 'fontWeight' => (int) $settings[ 'context_' . $context . '_font_weight' ],725 'paddingX' => (int) $settings[ 'context_' . $context . '_padding_x' ],726 'paddingY' => (int) $settings[ 'context_' . $context . '_padding_y' ],727 'iconColor' => $settings[ 'context_' . $context . '_icon_color' ],728 'iconSize' => (int) $settings[ 'context_' . $context . '_icon_size' ],729 'shadow' => ! empty( $settings[ 'context_' . $context . '_shadow' ] ),730 'position' => $settings[ 'context_' . $context . '_position' ],731 );732 }733 734 /**735 826 * Localized default public labels. 736 827 * … … 1332 1423 1333 1424 $valid_ids[] = $attachment_id; 1425 $full_url = wp_get_attachment_url( $attachment_id ); 1334 1426 $urls = array_filter( 1335 1427 array( 1336 wp_get_attachment_url( $attachment_id ),1428 $full_url, 1337 1429 function_exists( 'wp_get_original_image_url' ) ? wp_get_original_image_url( $attachment_id ) : '', 1338 1430 ) … … 1340 1432 1341 1433 $metadata = wp_get_attachment_metadata( $attachment_id ); 1342 $full_url = wp_get_attachment_url( $attachment_id );1343 1434 if ( $full_url && is_array( $metadata ) && ! empty( $metadata['sizes'] ) ) { 1344 1435 $base_url = trailingslashit( dirname( $full_url ) ); … … 1365 1456 continue; 1366 1457 } 1367 $map[ $path ] = $data; 1368 $map[ $path . '.webp' ] = $data; 1369 $webp_path = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path ); 1458 $map[ $path ] = $data; 1459 1460 // A "<file>.webp" companion path is intentionally not stored. The 1461 // frontend lookup already strips a trailing .webp and retries, so 1462 // the extra key was pure duplication of every entry. The variant 1463 // where the extension is replaced is still needed, because that 1464 // form cannot be derived by stripping. 1465 $webp_path = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path ); 1370 1466 if ( is_string( $webp_path ) && $webp_path !== $path ) { 1371 1467 $map[ $webp_path ] = $data; … … 1374 1470 } 1375 1471 1472 // Stale entries are recorded for the next admin-side write instead of 1473 // being flushed here. This method runs on public page views, which must 1474 // never issue a database write. 1376 1475 if ( $valid_ids !== $ids ) { 1377 update_option( self::OPTION_LABELED_IDS, $valid_ids, false );1476 $this->labeled_ids_pending_cleanup = $valid_ids; 1378 1477 } 1379 1478 … … 1391 1490 */ 1392 1491 private function get_labeled_media_ids() { 1492 if ( is_array( $this->labeled_ids ) ) { 1493 return $this->labeled_ids; 1494 } 1495 1393 1496 $ids = get_option( self::OPTION_LABELED_IDS, null ); 1394 1497 if ( ! is_array( $ids ) ) { 1395 return $this->rebuild_labeled_media_index(); 1498 $this->labeled_ids = $this->rebuild_labeled_media_index(); 1499 return $this->labeled_ids; 1396 1500 } 1397 1501 1398 1502 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ); 1399 1503 sort( $ids, SORT_NUMERIC ); 1504 $this->labeled_ids = $ids; 1400 1505 1401 1506 return $ids; … … 1446 1551 $ids = array_values( array_unique( $ids ) ); 1447 1552 sort( $ids, SORT_NUMERIC ); 1553 $this->labeled_ids = $ids; 1448 1554 update_option( self::OPTION_LABELED_IDS, $ids, false ); 1449 1555 … … 1456 1562 * @param int $attachment_id Attachment ID. 1457 1563 * @param string $status Sanitized status. 1564 * @param bool $defer Keep the change in memory instead of writing. 1458 1565 * @return void 1459 1566 */ 1460 private function update_labeled_media_index( $attachment_id, $status ) {1567 private function update_labeled_media_index( $attachment_id, $status, $defer = false ) { 1461 1568 $attachment_id = absint( $attachment_id ); 1462 1569 if ( ! $attachment_id ) { … … 1472 1579 1473 1580 sort( $ids, SORT_NUMERIC ); 1581 $this->labeled_ids = $ids; 1582 1583 if ( $defer ) { 1584 return; 1585 } 1586 1587 $this->persist_labeled_media_index(); 1588 } 1589 1590 /** 1591 * Write the labeled media index once. 1592 * 1593 * Bulk actions collect every change first and call this at the end, so a 1594 * 200-image selection performs one write instead of 200. 1595 * 1596 * @return void 1597 */ 1598 private function persist_labeled_media_index() { 1599 $ids = $this->get_labeled_media_ids(); 1600 1601 if ( is_array( $this->labeled_ids_pending_cleanup ) ) { 1602 $ids = array_values( array_intersect( $ids, $this->labeled_ids_pending_cleanup ) ); 1603 sort( $ids, SORT_NUMERIC ); 1604 $this->labeled_ids = $ids; 1605 $this->labeled_ids_pending_cleanup = null; 1606 } 1607 1474 1608 update_option( self::OPTION_LABELED_IDS, $ids, false ); 1475 1609 } … … 1484 1618 $this->update_labeled_media_index( absint( $attachment_id ), 'none' ); 1485 1619 $this->clear_media_map_cache(); 1620 } 1621 1622 /** 1623 * Check whether an attachment currently carries an OznaczAI label. 1624 * 1625 * @param int $attachment_id Attachment ID. 1626 * @return bool 1627 */ 1628 private function is_labeled_attachment( $attachment_id ) { 1629 $attachment_id = absint( $attachment_id ); 1630 if ( ! $attachment_id ) { 1631 return false; 1632 } 1633 1634 return in_array( $attachment_id, $this->get_labeled_media_ids(), true ); 1486 1635 } 1487 1636 … … 1579 1728 */ 1580 1729 public function clear_media_map_cache_after_metadata_update( $data, $attachment_id ) { 1581 unset( $attachment_id ); 1730 // This filter also runs for every plain upload and every thumbnail 1731 // regeneration. Rebuilding the whole map for media that carries no label 1732 // turned a 500-image import into 500 cold rebuilds on the next page view. 1733 if ( ! $this->is_labeled_attachment( $attachment_id ) ) { 1734 return $data; 1735 } 1736 1582 1737 $this->clear_media_map_cache(); 1583 1738 return $data; … … 1633 1788 ), 1634 1789 'presetConfigs' => self::get_preset_configs(), 1790 'iconVariants' => self::get_icon_variants(), 1791 'iconVariant' => $this->sanitize_icon_variant( $settings['icon_variant'] ), 1792 'iconShowText' => ! empty( $settings['icon_show_text'] ), 1635 1793 'showIcon' => ! empty( $settings['show_icon'] ), 1636 1794 'template' => $settings['template'], … … 1696 1854 array( 1697 1855 'presetConfigs' => self::get_preset_configs(), 1856 'iconVariants' => self::get_icon_variants(), 1698 1857 ) 1699 1858 ); … … 1720 1879 1721 1880 /** 1722 * Register settings and fields. 1881 * Register the settings option. 1882 * 1883 * The screen is rendered by render_settings_page() rather than through 1884 * do_settings_sections(), so only the option registration is needed here. 1885 * It provides the options.php allow-list entry and the sanitize callback. 1723 1886 * 1724 1887 * @return void … … 1733 1896 'default' => self::default_settings(), 1734 1897 ) 1735 );1736 1737 add_settings_section(1738 'oznaczai_image_labeling_labels_section',1739 __( 'Label text', 'oznaczai-image-labeling' ),1740 '__return_false',1741 'oznaczai-image-labeling'1742 );1743 1744 $this->register_text_setting_field( 'label_generated', __( 'AI-generated image', 'oznaczai-image-labeling' ) );1745 $this->register_text_setting_field( 'label_modified', __( 'AI-modified image', 'oznaczai-image-labeling' ) );1746 $this->register_text_setting_field( 'label_visualization', __( 'AI visualization', 'oznaczai-image-labeling' ) );1747 1748 add_settings_section(1749 'oznaczai_image_labeling_appearance_section',1750 __( 'Label appearance', 'oznaczai-image-labeling' ),1751 '__return_false',1752 'oznaczai-image-labeling'1753 );1754 1755 add_settings_field(1756 'position',1757 __( 'Position', 'oznaczai-image-labeling' ),1758 array( $this, 'render_position_field' ),1759 'oznaczai-image-labeling',1760 'oznaczai_image_labeling_appearance_section'1761 );1762 1763 add_settings_field(1764 'template',1765 __( 'Template', 'oznaczai-image-labeling' ),1766 array( $this, 'render_template_field' ),1767 'oznaczai-image-labeling',1768 'oznaczai_image_labeling_appearance_section'1769 );1770 1771 add_settings_field(1772 'show_icon',1773 __( 'AI icon', 'oznaczai-image-labeling' ),1774 array( $this, 'render_icon_field' ),1775 'oznaczai-image-labeling',1776 'oznaczai_image_labeling_appearance_section'1777 );1778 1779 add_settings_field(1780 'responsive_symbol',1781 __( 'Small-image symbol', 'oznaczai-image-labeling' ),1782 array( $this, 'render_responsive_symbol_field' ),1783 'oznaczai-image-labeling',1784 'oznaczai_image_labeling_appearance_section'1785 );1786 1787 add_settings_section(1788 'oznaczai_image_labeling_background_section',1789 __( 'Hero and background images', 'oznaczai-image-labeling' ),1790 '__return_false',1791 'oznaczai-image-labeling'1792 );1793 1794 add_settings_field(1795 'bg_display',1796 __( 'Hero / background display', 'oznaczai-image-labeling' ),1797 array( $this, 'render_bg_display_field' ),1798 'oznaczai-image-labeling',1799 'oznaczai_image_labeling_background_section'1800 );1801 1802 add_settings_field(1803 'auto_backgrounds',1804 __( 'Automatic detection', 'oznaczai-image-labeling' ),1805 array( $this, 'render_auto_backgrounds_field' ),1806 'oznaczai-image-labeling',1807 'oznaczai_image_labeling_background_section'1808 );1809 1810 add_settings_section(1811 'oznaczai_image_labeling_metadata_section',1812 __( 'Metadata and automation', 'oznaczai-image-labeling' ),1813 '__return_false',1814 'oznaczai-image-labeling'1815 );1816 1817 add_settings_field(1818 'detect_metadata',1819 __( 'Upload metadata hints', 'oznaczai-image-labeling' ),1820 array( $this, 'render_detect_metadata_field' ),1821 'oznaczai-image-labeling',1822 'oznaczai_image_labeling_metadata_section'1823 );1824 1825 add_settings_field(1826 'schema_markup',1827 __( 'Schema.org metadata', 'oznaczai-image-labeling' ),1828 array( $this, 'render_schema_markup_field' ),1829 'oznaczai-image-labeling',1830 'oznaczai_image_labeling_metadata_section'1831 );1832 }1833 1834 /**1835 * Register one text option field.1836 *1837 * @param string $key Setting key.1838 * @param string $label Field label.1839 * @return void1840 */1841 private function register_text_setting_field( $key, $label ) {1842 add_settings_field(1843 $key,1844 $label,1845 array( $this, 'render_text_field' ),1846 'oznaczai-image-labeling',1847 'oznaczai_image_labeling_labels_section',1848 array( 'key' => $key )1849 1898 ); 1850 1899 } … … 1906 1955 'hero_position' => isset( $input['hero_position'] ) && in_array( $input['hero_position'], $context_positions, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['hero_position'], 1907 1956 'show_icon' => empty( $input['show_icon'] ) ? 0 : 1, 1957 'icon_variant' => $this->sanitize_icon_variant( isset( $input['icon_variant'] ) ? $input['icon_variant'] : $defaults['icon_variant'] ), 1958 'icon_show_text' => empty( $input['icon_show_text'] ) ? 0 : 1, 1908 1959 'bg_display' => isset( $input['bg_display'] ) && in_array( $input['bg_display'], $bg_displays, true ) ? $input['bg_display'] : ( isset( $input['hero_position'] ) && in_array( $input['hero_position'], $bg_displays, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['bg_display'] ), 1909 1960 'auto_backgrounds' => empty( $input['auto_backgrounds'] ) ? 0 : 1, … … 1969 2020 $value = is_numeric( $value ) ? (int) $value : (int) $default; 1970 2021 return max( $min, min( $max, $value ) ); 1971 }1972 1973 /**1974 * Render settings text field.1975 *1976 * @param array $args Field arguments.1977 * @return void1978 */1979 public function render_text_field( $args ) {1980 $settings = $this->get_settings();1981 $key = $args['key'];1982 $status = str_replace( 'label_', '', $key );1983 $defaults = $this->get_default_label_texts();1984 $placeholder = isset( $defaults[ $status ] ) ? $defaults[ $status ] : '';1985 printf(1986 '<input type="text" class="regular-text" maxlength="160" name="%1$s[%2$s]" value="%3$s" placeholder="%4$s">',1987 esc_attr( self::OPTION_SETTINGS ),1988 esc_attr( $key ),1989 esc_attr( $settings[ $key ] ),1990 esc_attr( $placeholder )1991 );1992 echo '<p class="description">' . esc_html__( 'Leave empty to use the translated default for the active site language.', 'oznaczai-image-labeling' ) . '</p>';1993 }1994 1995 /**1996 * Render position field.1997 *1998 * @return void1999 */2000 public function render_position_field() {2001 $settings = $this->get_settings();2002 $options = array(2003 'bottom-left' => __( 'Bottom left', 'oznaczai-image-labeling' ),2004 'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),2005 'top-left' => __( 'Top left', 'oznaczai-image-labeling' ),2006 'top-right' => __( 'Top right', 'oznaczai-image-labeling' ),2007 );2008 2009 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[position]">';2010 foreach ( $options as $value => $label ) {2011 printf(2012 '<option value="%1$s"%2$s>%3$s</option>',2013 esc_attr( $value ),2014 selected( $settings['position'], $value, false ),2015 esc_html( $label )2016 );2017 }2018 echo '</select>';2019 echo '<p class="description">' . esc_html__( 'For the Caption below image template, this setting controls left or right caption alignment.', 'oznaczai-image-labeling' ) . '</p>';2020 }2021 2022 /**2023 * Render template field.2024 *2025 * @return void2026 */2027 public function render_template_field() {2028 $settings = $this->get_settings();2029 $options = array(2030 'minimal' => array(2031 'label' => __( 'Minimal', 'oznaczai-image-labeling' ),2032 'description' => __( 'The least prominent option. Very light background, without borders or shadows.', 'oznaczai-image-labeling' ),2033 ),2034 'compact' => array(2035 'label' => __( 'Compact', 'oznaczai-image-labeling' ),2036 'description' => __( 'A small dark label with restrained visual weight.', 'oznaczai-image-labeling' ),2037 ),2038 'subtle' => array(2039 'label' => __( 'Subtle', 'oznaczai-image-labeling' ),2040 'description' => __( 'A semi-transparent label without shadow or bold text.', 'oznaczai-image-labeling' ),2041 ),2042 'light' => array(2043 'label' => __( 'Light', 'oznaczai-image-labeling' ),2044 'description' => __( 'Light, delicate text with an almost invisible background.', 'oznaczai-image-labeling' ),2045 ),2046 'caption' => array(2047 'label' => __( 'Caption below image', 'oznaczai-image-labeling' ),2048 'description' => __( 'A lightweight caption directly below the image. It does not cover the image.', 'oznaczai-image-labeling' ),2049 ),2050 );2051 2052 echo '<div class="oznaczai-template-grid">';2053 foreach ( $options as $value => $option ) {2054 printf(2055 '<label class="oznaczai-template-option"><input type="radio" name="%1$s[template]" value="%2$s"%3$s><span class="oznaczai-template-card"><span class="oznaczai-template-card__title">%4$s</span><span class="oznaczai-template-preview oznaczai-template-preview--%2$s"><span class="oznaczai-template-preview__icon" aria-hidden="true">AI</span><span>%6$s</span></span><span class="oznaczai-template-card__description">%5$s</span></span></label>',2056 esc_attr( self::OPTION_SETTINGS ),2057 esc_attr( $value ),2058 checked( $settings['template'], $value, false ),2059 esc_html( $option['label'] ),2060 esc_html( $option['description'] ),2061 esc_html__( 'AI-generated image', 'oznaczai-image-labeling' )2062 );2063 }2064 echo '</div>';2065 echo '<p class="description">' . esc_html__( 'Minimal is the least prominent template. You can also disable the AI icon for an even lighter result.', 'oznaczai-image-labeling' ) . '</p>';2066 }2067 2068 /**2069 * Render icon checkbox.2070 *2071 * @return void2072 */2073 public function render_icon_field() {2074 $settings = $this->get_settings();2075 printf(2076 '<label><input type="checkbox" name="%1$s[show_icon]" value="1"%2$s> %3$s</label>',2077 esc_attr( self::OPTION_SETTINGS ),2078 checked( ! empty( $settings['show_icon'] ), true, false ),2079 esc_html__( 'Show a small icon next to the text', 'oznaczai-image-labeling' )2080 );2081 }2082 2083 /**2084 * Render small-image symbol setting.2085 *2086 * @return void2087 */2088 public function render_responsive_symbol_field() {2089 $settings = $this->get_settings();2090 printf(2091 '<label><input type="checkbox" name="%1$s[responsive_symbol]" value="1"%2$s> %3$s</label>',2092 esc_attr( self::OPTION_SETTINGS ),2093 checked( ! empty( $settings['responsive_symbol'] ), true, false ),2094 esc_html__( 'Use a compact AI symbol on small images instead of the full text label', 'oznaczai-image-labeling' )2095 );2096 echo '<p class="description">' . esc_html__( 'The symbol expands to the full label on hover or keyboard focus. The accessible label remains available to assistive technology. Caption below image is never collapsed.', 'oznaczai-image-labeling' ) . '</p>';2097 }2098 2099 2100 /**2101 * Render hero/background display field.2102 *2103 * @return void2104 */2105 public function render_bg_display_field() {2106 $settings = $this->get_settings();2107 $options = array(2108 'below' => __( 'Below image / section', 'oznaczai-image-labeling' ),2109 'bar' => __( 'Subtle bar at the bottom of the hero', 'oznaczai-image-labeling' ),2110 'bottom-left' => __( 'Bottom left in hero', 'oznaczai-image-labeling' ),2111 'bottom-right' => __( 'Bottom right in hero', 'oznaczai-image-labeling' ),2112 );2113 2114 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[bg_display]">';2115 foreach ( $options as $value => $label ) {2116 printf(2117 '<option value="%1$s"%2$s>%3$s</option>',2118 esc_attr( $value ),2119 selected( $settings['bg_display'], $value, false ),2120 esc_html( $label )2121 );2122 }2123 echo '</select>';2124 echo '<p class="description">' . esc_html__( 'For a custom section background, add the CSS class oznaczai-bg-generated, oznaczai-bg-modified, or oznaczai-bg-visualization. The Below image / section option is the safest choice for heroes with their own text.', 'oznaczai-image-labeling' ) . '</p>';2125 }2126 2127 2128 /**2129 * Render automatic background detection field.2130 *2131 * @return void2132 */2133 public function render_auto_backgrounds_field() {2134 $settings = $this->get_settings();2135 printf(2136 '<label><input type="checkbox" name="%1$s[auto_backgrounds]" value="1"%2$s> %3$s</label>',2137 esc_attr( self::OPTION_SETTINGS ),2138 checked( ! empty( $settings['auto_backgrounds'] ), true, false ),2139 esc_html__( 'Automatically detect labeled images in hero sections, section backgrounds, and Elementor sliders', 'oznaczai-image-labeling' )2140 );2141 echo '<p class="description">' . esc_html__( 'Mark the file in the Media Library. No CSS classes or shortcode are required for supported layouts.', 'oznaczai-image-labeling' ) . '</p>';2142 }2143 2144 /**2145 * Render upload metadata detection setting.2146 *2147 * @return void2148 */2149 public function render_detect_metadata_field() {2150 $settings = $this->get_settings();2151 printf(2152 '<label><input type="checkbox" name="%1$s[detect_metadata]" value="1"%2$s> %3$s</label>',2153 esc_attr( self::OPTION_SETTINGS ),2154 checked( ! empty( $settings['detect_metadata'] ), true, false ),2155 esc_html__( 'Check newly uploaded images for AI-related IPTC/XMP, Content Credentials markers, and common generator signatures', 'oznaczai-image-labeling' )2156 );2157 echo '<p class="description">' . esc_html__( 'Detection only creates a review hint. It never labels an image automatically and does not validate C2PA signatures.', 'oznaczai-image-labeling' ) . '</p>';2158 }2159 2160 /**2161 * Render Schema.org setting.2162 *2163 * @return void2164 */2165 public function render_schema_markup_field() {2166 $settings = $this->get_settings();2167 printf(2168 '<label><input type="checkbox" name="%1$s[schema_markup]" value="1"%2$s> %3$s</label>',2169 esc_attr( self::OPTION_SETTINGS ),2170 checked( ! empty( $settings['schema_markup'] ), true, false ),2171 esc_html__( 'Add Schema.org digitalSourceType JSON-LD for labeled images found on the page', 'oznaczai-image-labeling' )2172 );2173 echo '<p class="description">' . esc_html__( 'This is machine-readable publisher metadata. It is separate from Content Credentials and does not provide or guarantee legal compliance.', 'oznaczai-image-labeling' ) . '</p>';2174 2022 } 2175 2023 … … 2458 2306 <?php endforeach; ?> 2459 2307 </div> 2308 <div class="oznaczai-symbol-row"> 2309 <label class="oznaczai-field"> 2310 <span><?php esc_html_e( 'Label symbol', 'oznaczai-image-labeling' ); ?></span> 2311 <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[icon_variant]" data-oznaczai-icon-variant> 2312 <?php foreach ( self::get_icon_variants() as $variant_key => $variant ) : ?> 2313 <option value="<?php echo esc_attr( $variant_key ); ?>" <?php selected( $this->sanitize_icon_variant( $settings['icon_variant'] ), $variant_key ); ?>><?php echo esc_html( $variant['label'] ); ?></option> 2314 <?php endforeach; ?> 2315 </select> 2316 </label> 2317 <p class="description"> 2318 <?php esc_html_e( 'The OznaczAI symbol is the default. The EU options use the official European Commission icons for labelling AI-generated content. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning.', 'oznaczai-image-labeling' ); ?> 2319 </p> 2320 <?php $this->render_toggle_control( 'icon_show_text', __( 'Show text next to the symbol', 'oznaczai-image-labeling' ), __( 'Turn this off to show only the symbol when it already contains its own wording.', 'oznaczai-image-labeling' ), $settings ); ?> 2321 </div> 2460 2322 <div class="oznaczai-advanced-grid"> 2461 2323 <?php $this->render_toggle_control( 'show_icon', __( 'Show AI icon', 'oznaczai-image-labeling' ), __( 'Display the small AI mark next to the label text.', 'oznaczai-image-labeling' ), $settings ); ?> 2462 2324 <?php $this->render_toggle_control( 'schema_markup', __( 'Schema.org JSON-LD', 'oznaczai-image-labeling' ), __( 'Add digitalSourceType metadata for labeled images found on the page.', 'oznaczai-image-labeling' ), $settings ); ?> 2463 2325 </div> 2326 </section> 2327 2328 <section class="oznaczai-card" id="oznaczai-transparency"> 2329 <div class="oznaczai-card-head"> 2330 <h2><?php esc_html_e( '7. AI transparency legend', 'oznaczai-image-labeling' ); ?></h2> 2331 <span aria-hidden="true">i</span> 2332 </div> 2333 <p class="oznaczai-card-intro"> 2334 <?php esc_html_e( 'Place this shortcode on any page to explain what your AI labels mean. It follows your current label texts and symbol automatically.', 'oznaczai-image-labeling' ); ?> 2335 </p> 2336 <p><code class="oznaczai-shortcode">[oznaczai_transparency_legend]</code></p> 2337 <?php $transparency_page = $this->get_transparency_page(); ?> 2338 <?php if ( $transparency_page instanceof WP_Post ) : ?> 2339 <p class="description"> 2340 <?php 2341 printf( 2342 /* translators: %s: link to the existing page. */ 2343 esc_html__( 'A page already exists: %s', 'oznaczai-image-labeling' ), 2344 '<a href="' . esc_url( (string) get_edit_post_link( $transparency_page->ID, 'url' ) ) . '">' . esc_html( get_the_title( $transparency_page ) ) . '</a>' 2345 ); 2346 ?> 2347 </p> 2348 <?php elseif ( current_user_can( 'publish_pages' ) ) : ?> 2349 <p class="description"><?php esc_html_e( 'You can also create a draft page containing this legend. It is never published automatically.', 'oznaczai-image-labeling' ); ?></p> 2350 <p> 2351 <a class="button button-secondary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oznaczai_image_labeling_create_transparency_page' ), 'oznaczai_image_labeling_create_transparency_page' ) ); ?>"> 2352 <?php esc_html_e( 'Create AI transparency page', 'oznaczai-image-labeling' ); ?> 2353 </a> 2354 </p> 2355 <?php endif; ?> 2464 2356 </section> 2465 2357 … … 2568 2460 2569 2461 /** 2570 * Render context appearance controls.2571 *2572 * @param string $context Context key.2573 * @param string $label Context label.2574 * @param array $settings Current settings.2575 * @return void2576 */2577 private function render_context_appearance_controls( $context, $label, $settings ) {2578 $prefix = 'context_' . $context;2579 $positions = array(2580 'top-left' => __( 'Top left', 'oznaczai-image-labeling' ),2581 'top-right' => __( 'Top right', 'oznaczai-image-labeling' ),2582 'bottom-left' => __( 'Bottom left', 'oznaczai-image-labeling' ),2583 'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),2584 'below' => __( 'Below image / section', 'oznaczai-image-labeling' ),2585 'bar' => __( 'Subtle bar', 'oznaczai-image-labeling' ),2586 );2587 ?>2588 <div class="oznaczai-context-style-card" data-oznaczai-context-style="<?php echo esc_attr( $context ); ?>">2589 <h3><?php echo esc_html( $label ); ?></h3>2590 <div class="oznaczai-mode-row">2591 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="global" <?php checked( $settings[ $prefix . '_mode' ], 'global' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use global settings', 'oznaczai-image-labeling' ); ?></label>2592 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="preset" <?php checked( $settings[ $prefix . '_mode' ], 'preset' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use separate preset', 'oznaczai-image-labeling' ); ?></label>2593 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="custom" <?php checked( $settings[ $prefix . '_mode' ], 'custom' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Customize manually', 'oznaczai-image-labeling' ); ?></label>2594 </div>2595 <div data-oznaczai-context-preset="<?php echo esc_attr( $context ); ?>">2596 <label class="oznaczai-field">2597 <span><?php esc_html_e( 'Preset', 'oznaczai-image-labeling' ); ?></span>2598 <?php $this->render_preset_select( $prefix . '_preset', $settings[ $prefix . '_preset' ] ); ?>2599 </label>2600 </div>2601 <div class="oznaczai-style-grid" data-oznaczai-context-custom="<?php echo esc_attr( $context ); ?>">2602 <?php2603 $this->render_style_input( $prefix . '_text_color', __( 'Text color', 'oznaczai-image-labeling' ), 'color', $settings );2604 ?>2605 <div class="oznaczai-background-tools">2606 <p class="oznaczai-label"><?php esc_html_e( 'Background', 'oznaczai-image-labeling' ); ?></p>2607 <label class="oznaczai-checkline">2608 <input type="checkbox" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_no_background]" value="1" <?php checked( ! empty( $settings[ $prefix . '_no_background' ] ) ); ?> data-oznaczai-context-input>2609 <?php esc_html_e( 'No background', 'oznaczai-image-labeling' ); ?>2610 </label>2611 </div>2612 <?php2613 $this->render_style_input( $prefix . '_bg_color', __( 'Background color', 'oznaczai-image-labeling' ), 'color', $settings );2614 ?>2615 <label class="oznaczai-field oznaczai-range-field">2616 <span><?php esc_html_e( 'Background opacity', 'oznaczai-image-labeling' ); ?></span>2617 <input type="range" min="0" max="100" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_bg_opacity]" value="<?php echo esc_attr( $settings[ $prefix . '_bg_opacity' ] ); ?>" data-oznaczai-context-input>2618 <output><?php echo esc_html( absint( $settings[ $prefix . '_bg_opacity' ] ) ); ?>%</output>2619 </label>2620 <?php2621 $this->render_style_input( $prefix . '_border_color', __( 'Border color', 'oznaczai-image-labeling' ), 'color', $settings );2622 $this->render_style_input( $prefix . '_border_width', __( 'Border width', 'oznaczai-image-labeling' ), 'number', $settings, 0, 6 );2623 $this->render_style_input( $prefix . '_radius', __( 'Radius', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );2624 $this->render_style_input( $prefix . '_font_size', __( 'Font size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 18 );2625 $this->render_style_input( $prefix . '_font_weight', __( 'Font weight', 'oznaczai-image-labeling' ), 'number', $settings, 300, 800 );2626 $this->render_style_input( $prefix . '_padding_x', __( 'Horizontal padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 24 );2627 $this->render_style_input( $prefix . '_padding_y', __( 'Vertical padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );2628 $this->render_style_input( $prefix . '_icon_color', __( 'AI icon color', 'oznaczai-image-labeling' ), 'color', $settings );2629 $this->render_style_input( $prefix . '_icon_size', __( 'AI icon size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 24 );2630 ?>2631 <?php $this->render_toggle_control( $prefix . '_shadow', __( 'Subtle shadow', 'oznaczai-image-labeling' ), __( 'Add a restrained shadow to this context label.', 'oznaczai-image-labeling' ), $settings ); ?>2632 <?php if ( in_array( $context, array( 'background', 'slider' ), true ) ) : ?>2633 <label class="oznaczai-field">2634 <span><?php esc_html_e( 'Label position', 'oznaczai-image-labeling' ); ?></span>2635 <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_position]" data-oznaczai-context-input>2636 <?php foreach ( $positions as $value => $position_label ) : ?>2637 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $settings[ $prefix . '_position' ], $value ); ?>><?php echo esc_html( $position_label ); ?></option>2638 <?php endforeach; ?>2639 </select>2640 </label>2641 <?php endif; ?>2642 </div>2643 </div>2644 <?php2645 }2646 2647 /**2648 2462 * Render a compact style input. 2649 2463 * … … 2764 2578 2765 2579 /** 2580 * Explanations shown in the transparency legend. 2581 * 2582 * These describe what the site owner stated about an image. They are not 2583 * legal statements and must not be worded as any kind of compliance claim. 2584 * 2585 * @return array<string,string> 2586 */ 2587 private function get_legend_descriptions() { 2588 return array( 2589 'generated' => __( 'The site owner has marked this image as generated using AI.', 'oznaczai-image-labeling' ), 2590 'modified' => __( 'The site owner has marked this image as modified using AI.', 'oznaczai-image-labeling' ), 2591 'visualization' => __( 'The site owner has marked this image as a visualization created using AI.', 'oznaczai-image-labeling' ), 2592 ); 2593 } 2594 2595 /** 2596 * Render the symbol used for a status inside the legend. 2597 * 2598 * @param string $status Image status. 2599 * @param string $variant Active symbol variant. 2600 * @return string 2601 */ 2602 private function render_legend_symbol( $status, $variant ) { 2603 $variants = self::get_icon_variants(); 2604 $symbols = isset( $variants[ $variant ]['symbols'] ) ? $variants[ $variant ]['symbols'] : array(); 2605 $symbol = isset( $symbols[ $status ] ) ? $symbols[ $status ] : array(); 2606 2607 if ( ! empty( $symbol['onLight'] ) ) { 2608 return sprintf( 2609 '<img class="oznaczai-legend__symbol oznaczai-legend__symbol--image" src="%1$s" alt="" width="%2$d" height="20" loading="lazy" decoding="async">', 2610 esc_url( $symbol['onLight'] ), 2611 absint( round( 20 * ( isset( $symbol['ratio'] ) ? (float) $symbol['ratio'] : 1 ) ) ) 2612 ); 2613 } 2614 2615 return sprintf( 2616 '<span class="oznaczai-legend__symbol" aria-hidden="true">%s</span>', 2617 esc_html( isset( $symbol['text'] ) ? $symbol['text'] : 'AI' ) 2618 ); 2619 } 2620 2621 /** 2622 * Render the "AI transparency" legend. 2623 * 2624 * The legend is built from the statuses, label texts and symbol variant that 2625 * are actually in use, so it never drifts from what visitors see on images. 2626 * 2627 * @param array $atts Shortcode attributes. 2628 * @return string 2629 */ 2630 public function render_transparency_legend_shortcode( $atts ) { 2631 $atts = shortcode_atts( 2632 array( 2633 'title' => '', 2634 ), 2635 $atts, 2636 'oznaczai_transparency_legend' 2637 ); 2638 2639 $settings = $this->get_settings(); 2640 $variant = $this->sanitize_icon_variant( $settings['icon_variant'] ); 2641 $descriptions = $this->get_legend_descriptions(); 2642 $title = $this->sanitize_label( $atts['title'] ); 2643 $output = '<div class="oznaczai-legend">'; 2644 2645 if ( '' !== $title ) { 2646 $output .= '<h2 class="oznaczai-legend__title">' . esc_html( $title ) . '</h2>'; 2647 } 2648 2649 $output .= '<ul class="oznaczai-legend__list">'; 2650 2651 foreach ( array( 'generated', 'modified', 'visualization' ) as $status ) { 2652 $output .= sprintf( 2653 '<li class="oznaczai-legend__item oznaczai-legend__item--%1$s">%2$s<span class="oznaczai-legend__body"><span class="oznaczai-legend__name">%3$s</span><span class="oznaczai-legend__description">%4$s</span></span></li>', 2654 esc_attr( $status ), 2655 $this->render_legend_symbol( $status, $variant ), 2656 esc_html( $this->get_configured_label( $status ) ), 2657 esc_html( isset( $descriptions[ $status ] ) ? $descriptions[ $status ] : '' ) 2658 ); 2659 } 2660 2661 $output .= '</ul>'; 2662 $output .= '<p class="oznaczai-legend__note">' . esc_html__( 'Labels are applied by the site owner. Not every image created or edited with AI carries a label, and these markings do not constitute a legal assessment.', 'oznaczai-image-labeling' ) . '</p>'; 2663 $output .= '</div>'; 2664 2665 return $output; 2666 } 2667 2668 /** 2669 * Return the existing transparency page, if one is still usable. 2670 * 2671 * @return WP_Post|null 2672 */ 2673 private function get_transparency_page() { 2674 $page_id = absint( get_option( self::OPTION_TRANSPARENCY_PAGE, 0 ) ); 2675 if ( ! $page_id ) { 2676 return null; 2677 } 2678 2679 $page = get_post( $page_id ); 2680 if ( ! $page instanceof WP_Post || 'page' !== $page->post_type || 'trash' === $page->post_status ) { 2681 return null; 2682 } 2683 2684 return $page; 2685 } 2686 2687 /** 2688 * Create a draft "AI transparency" page containing the legend shortcode. 2689 * 2690 * The page is only ever created on an explicit administrator action and is 2691 * always left as a draft, so nothing is published without review. 2692 * 2693 * @return void 2694 */ 2695 public function handle_create_transparency_page() { 2696 if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'publish_pages' ) ) { 2697 wp_die( 2698 esc_html__( 'You do not have permission to create this page.', 'oznaczai-image-labeling' ), 2699 '', 2700 array( 'response' => 403 ) 2701 ); 2702 } 2703 2704 check_admin_referer( 'oznaczai_image_labeling_create_transparency_page' ); 2705 2706 $settings_url = admin_url( 'admin.php?page=oznaczai-image-labeling' ); 2707 $existing = $this->get_transparency_page(); 2708 2709 if ( $existing instanceof WP_Post ) { 2710 wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'exists', $settings_url ) ); 2711 exit; 2712 } 2713 2714 $page_id = wp_insert_post( 2715 array( 2716 'post_title' => __( 'AI transparency', 'oznaczai-image-labeling' ), 2717 'post_content' => '[oznaczai_transparency_legend]', 2718 'post_status' => 'draft', 2719 'post_type' => 'page', 2720 ), 2721 true 2722 ); 2723 2724 if ( is_wp_error( $page_id ) || ! $page_id ) { 2725 wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'error', $settings_url ) ); 2726 exit; 2727 } 2728 2729 update_option( self::OPTION_TRANSPARENCY_PAGE, absint( $page_id ), false ); 2730 2731 wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'created', $settings_url ) ); 2732 exit; 2733 } 2734 2735 /** 2736 * Show the result of the transparency page action. 2737 * 2738 * @return void 2739 */ 2740 public function show_transparency_page_notice() { 2741 // The notice belongs to the OznaczAI settings screen, which is where the 2742 // action redirects back to. Restricting it here stops the query parameter 2743 // from surfacing the notice on unrelated wp-admin screens. 2744 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; 2745 if ( ! $screen || 'toplevel_page_oznaczai-image-labeling' !== $screen->id ) { 2746 return; 2747 } 2748 2749 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data. 2750 if ( ! isset( $_GET['oznaczai_image_labeling_page'] ) || ! current_user_can( 'manage_options' ) ) { 2751 return; 2752 } 2753 2754 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data. 2755 $result = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_page'] ) ); 2756 $page = $this->get_transparency_page(); 2757 2758 if ( 'error' === $result ) { 2759 printf( 2760 '<div class="notice notice-error is-dismissible"><p>%s</p></div>', 2761 esc_html__( 'The AI transparency page could not be created.', 'oznaczai-image-labeling' ) 2762 ); 2763 return; 2764 } 2765 2766 if ( ! in_array( $result, array( 'created', 'exists' ), true ) || ! $page instanceof WP_Post ) { 2767 return; 2768 } 2769 2770 $message = 'created' === $result 2771 ? __( 'Draft page created. Review the wording and publish it when you are ready.', 'oznaczai-image-labeling' ) 2772 : __( 'The AI transparency page already exists.', 'oznaczai-image-labeling' ); 2773 2774 printf( 2775 '<div class="notice notice-success is-dismissible"><p>%1$s <a href="%2$s">%3$s</a></p></div>', 2776 esc_html( $message ), 2777 esc_url( (string) get_edit_post_link( $page->ID, 'url' ) ), 2778 esc_html__( 'Edit the page', 'oznaczai-image-labeling' ) 2779 ); 2780 } 2781 2782 /** 2766 2783 * Add media list column. 2767 2784 * … … 2836 2853 2837 2854 /** 2855 * Available Media Library filter choices. 2856 * 2857 * @return array<string,string> 2858 */ 2859 private function get_media_filter_choices() { 2860 return array( 2861 'generated' => __( 'AI label: AI-generated', 'oznaczai-image-labeling' ), 2862 'modified' => __( 'AI label: AI-modified', 'oznaczai-image-labeling' ), 2863 'visualization' => __( 'AI label: AI visualization', 'oznaczai-image-labeling' ), 2864 'unlabeled' => __( 'AI label: no label', 'oznaczai-image-labeling' ), 2865 'hint' => __( 'AI label: metadata hint found', 'oznaczai-image-labeling' ), 2866 ); 2867 } 2868 2869 /** 2870 * Read the requested Media Library filter. 2871 * 2872 * @return string Empty string when no supported filter is active. 2873 */ 2874 private function get_requested_media_filter() { 2875 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters. 2876 if ( ! isset( $_GET['oznaczai_image_labeling_filter'] ) ) { 2877 return ''; 2878 } 2879 2880 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters. 2881 $filter = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_filter'] ) ); 2882 2883 return array_key_exists( $filter, $this->get_media_filter_choices() ) ? $filter : ''; 2884 } 2885 2886 /** 2887 * Render the OznaczAI filter next to the native Media Library filters. 2888 * 2889 * @param string $post_type Current list table post type. 2890 * @param string $which Tablenav position. 2891 * @return void 2892 */ 2893 public function render_media_library_filter( $post_type, $which ) { 2894 global $pagenow; 2895 2896 if ( 'attachment' !== $post_type || 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) { 2897 return; 2898 } 2899 2900 // The media list table renders this action twice; only the top bar carries 2901 // the native filter controls and the Filter submit button. 2902 if ( 'bar' !== $which ) { 2903 return; 2904 } 2905 2906 $current = $this->get_requested_media_filter(); 2907 2908 echo '<label class="screen-reader-text" for="oznaczai-image-labeling-filter">'; 2909 esc_html_e( 'Filter by AI label', 'oznaczai-image-labeling' ); 2910 echo '</label>'; 2911 echo '<select name="oznaczai_image_labeling_filter" id="oznaczai-image-labeling-filter">'; 2912 printf( 2913 '<option value=""%1$s>%2$s</option>', 2914 selected( $current, '', false ), 2915 esc_html__( 'All AI labels', 'oznaczai-image-labeling' ) 2916 ); 2917 foreach ( $this->get_media_filter_choices() as $value => $label ) { 2918 printf( 2919 '<option value="%1$s"%2$s>%3$s</option>', 2920 esc_attr( $value ), 2921 selected( $current, $value, false ), 2922 esc_html( $label ) 2923 ); 2924 } 2925 echo '</select>'; 2926 } 2927 2928 /** 2929 * Apply the OznaczAI filter to the Media Library list query. 2930 * 2931 * Filtering happens in the WordPress query so large libraries are never 2932 * transferred to the browser. 2933 * 2934 * @param WP_Query $query Current query. 2935 * @return void 2936 */ 2937 public function filter_media_library_query( $query ) { 2938 global $pagenow; 2939 2940 if ( ! is_admin() || ! $query instanceof WP_Query || ! $query->is_main_query() ) { 2941 return; 2942 } 2943 2944 if ( 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) { 2945 return; 2946 } 2947 2948 $filter = $this->get_requested_media_filter(); 2949 if ( '' === $filter ) { 2950 return; 2951 } 2952 2953 $meta_query = $query->get( 'meta_query' ); 2954 $meta_query = is_array( $meta_query ) ? $meta_query : array(); 2955 2956 if ( 'hint' === $filter ) { 2957 $meta_query[] = array( 2958 'key' => self::META_DETECTION_STATUS, 2959 'compare' => 'EXISTS', 2960 ); 2961 } elseif ( 'unlabeled' === $filter ) { 2962 // Current releases delete the meta row when a label is removed, but an 2963 // installation upgraded from an older version can still hold an empty 2964 // or explicit 'none' value, so both shapes count as unlabeled. 2965 $meta_query[] = array( 2966 'relation' => 'OR', 2967 array( 2968 'key' => self::META_STATUS, 2969 'compare' => 'NOT EXISTS', 2970 ), 2971 array( 2972 'key' => self::META_STATUS, 2973 'value' => array( '', 'none' ), 2974 'compare' => 'IN', 2975 ), 2976 ); 2977 } else { 2978 $meta_query[] = array( 2979 'key' => self::META_STATUS, 2980 'value' => $filter, 2981 'compare' => '=', 2982 ); 2983 } 2984 2985 $query->set( 'meta_query', $meta_query ); 2986 2987 // OznaczAI only labels images. Without this, every PDF and video would 2988 // appear under "no label". An explicit media type choice still wins. 2989 if ( in_array( $filter, array( 'unlabeled', 'hint' ), true ) && ! $query->get( 'post_mime_type' ) ) { 2990 $query->set( 'post_mime_type', 'image' ); 2991 } 2992 } 2993 2994 /** 2838 2995 * Save status changed directly in the Media list. 2839 2996 * … … 2933 3090 2934 3091 unset( $this->status_cache[ $post_id ], $this->label_cache[ $post_id ] ); 2935 $this->update_labeled_media_index( $post_id, $status );3092 $this->update_labeled_media_index( $post_id, $status, true ); 2936 3093 ++$count; 2937 3094 } 2938 3095 2939 3096 if ( $count > 0 ) { 3097 $this->persist_labeled_media_index(); 2940 3098 $this->clear_media_map_cache(); 2941 3099 $this->purge_page_cache(); -
oznaczai-image-labeling/trunk/oznaczai.php
r3641997 r3705284 3 3 * Plugin Name: OznaczAI – AI Image Disclosure for EU AI Act 4 4 * Description: Adds visible labels to AI-generated and AI-modified images to support transparency disclosures, including EU AI Act use cases. 5 * Version: 1. 6.245 * Version: 1.7.0 6 6 * Author: Golden Pixel 7 7 * License: GPL-2.0-or-later … … 9 9 * Text Domain: oznaczai-image-labeling 10 10 * Requires at least: 6.2 11 * Tested up to: 7. 011 * Tested up to: 7.1 12 12 * Requires PHP: 7.4 13 13 */ … … 32 32 } 33 33 34 define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1. 6.24' );34 define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1.7.0' ); 35 35 define( 'OZNACZAI_IMAGE_LABELING_FILE', __FILE__ ); 36 36 define( 'OZNACZAI_IMAGE_LABELING_DIR', plugin_dir_path( __FILE__ ) ); -
oznaczai-image-labeling/trunk/readme.txt
r3641997 r3705284 3 3 Tags: eu ai act, ai disclosure, ai transparency, deepfake, image labeling 4 4 Requires at least: 6.2 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 6.247 Stable tag: 1.7.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 32 32 * No label 33 33 34 35 == Screenshots ==36 37 1. Label AI-generated and AI-modified images directly from the WordPress Media Library.38 2. Configure label presets, custom styling, hero and slider labels, and live preview.39 3. Visible AI disclosure labels on a real website, including hero images and images inside page content.40 4. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens.41 42 43 34 = Main features = 44 35 45 36 * Quick status selection in the Media Library list view 46 37 * Automatic saving without opening each attachment 38 * Filter the Media Library by AI label, including images with no label 47 39 * Bulk labeling for multiple images 48 40 * Custom label wording … … 73 65 The Caption below image preset places the disclosure outside the image so it does not cover visual content. 74 66 67 = Label symbol = 68 69 The label symbol is separate from the label preset, so any symbol can be combined with any preset. 70 71 * OznaczAI symbol (default) 72 * EU AI icon 73 * EU AI icons per label type 74 75 The EU options use the official European Commission icons for labelling AI-generated content. AI-generated uses the Fully AI-Generated icon and AI-modified uses the Partially AI-Modified icon. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning. 76 77 Choosing an EU icon does not change what your labels mean and is not a statement about legal compliance. Existing installations keep the OznaczAI symbol until the setting is changed. 78 79 = AI transparency legend = 80 81 The shortcode below outputs a short, neutral legend that explains the labels used on your site. It follows your current label texts and symbol automatically. 82 83 [oznaczai_transparency_legend] 84 85 You can optionally create a draft page containing this legend from the plugin settings screen. The page is never published automatically. 86 75 87 == Installation == 76 88 … … 117 129 No WordPress plugin can provide or guarantee complete legal compliance. Site owners should assess their own content, role, audience, and applicable legal requirements. 118 130 131 == Third-party assets == 132 133 The optional EU label symbols are the official icons for labelling AI-generated content published by the European Commission at https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content 134 135 The published terms state that the icons "are made publicly available for everyone to use freely, without the need for attribution to the Commission or the AI Office". The files bundled with OznaczAI are unmodified copies of the official SVG assets; only their file names were changed. See assets/icons/eu/README.txt in the plugin folder for the recorded source and terms. 136 137 OznaczAI is not affiliated with, endorsed by, or approved by the European Commission or the AI Office. Displaying an icon does not establish that any legal obligation applies to an image or that it has been met. 138 119 139 == Privacy == 120 140 … … 143 163 It automatically detects many common WordPress and Elementor implementations, including Slider Revolution 6/7 image layers and slide backgrounds. Custom themes or other third-party widgets may require one of the fallback CSS classes or the background label shortcode. 144 164 165 == Screenshots == 166 167 1. Label AI-generated and AI-modified images directly from the WordPress Media Library. 168 2. Configure label presets, custom styling, hero and slider labels, and live preview. 169 3. Visible AI disclosure labels on a real website, including hero images and images inside page content. 170 4. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens. 171 145 172 == Changelog == 173 174 = 1.7.0 = 175 * Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled. 176 * Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly. 177 * Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked. 178 * Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected. 179 * Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label. 180 * Removed a database write that could happen while a public page was being rendered. 181 * Bulk labeling now writes the labeled media index once instead of once per image. 182 * Reduced the size of the labeled media data passed to the browser by removing duplicated entries. 183 * The legacy storage migration now runs only on install and version change instead of on every request. 184 * Fixed a migration from very old versions that could store a label preset that no longer exists. 185 * Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint. 186 * Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it. 187 * Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged. 188 * Removed settings screen code that was no longer used by the current panel. 146 189 147 190 = 1.6.24 = -
oznaczai-image-labeling/trunk/uninstall.php
r3641997 r3705284 14 14 delete_option( 'oznaczai_image_labeling_labeled_media_ids' ); 15 15 delete_option( 'oznaczai_image_labeling_version' ); 16 17 /* 18 * Only the stored reference is removed. Any page created through the 19 * "AI transparency" action is ordinary site content that the administrator 20 * owns and may have edited or published, so it is never deleted here. 21 */ 22 delete_option( 'oznaczai_image_labeling_transparency_page_id' ); 23 16 24 delete_transient( 'oznaczai_image_labeling_media_map_v1' );
Note:
See TracChangeset
for help on using the changeset viewer.