diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..1eecb4c --- /dev/null +++ b/.distignore @@ -0,0 +1,23 @@ +# Directories +/.git/ +/.github/ +/bin/ +/node_modules/ +/tests/ +/vendor/ + +# Files +.distignore +.editorconfig +.gitattributes +.gitignore +.npmrc +.phpcs.xml.dist +.wp-env.json +.wp-env.override.json +CHANGELOG.md +composer.json +composer.lock +package.json +package-lock.json +phpunit.xml.dist diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3b8daef --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# The following teams will get auto-tagged for a review. +# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +* @Automattic/vip-plugins diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 727cd89..d03f389 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,19 +1,42 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# Configuration for Dependabot version updates +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - - # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" + day: "monday" + groups: + actions: + patterns: ["*"] + labels: + - "dependencies" + commit-message: + prefix: "Actions" + include: "scope" + open-pull-requests-limit: 5 - # Maintain dependencies for Composer - package-ecosystem: "composer" directory: "/" schedule: - interval: "daily" + interval: "weekly" + day: "tuesday" + groups: + dev-dependencies: + patterns: + - "automattic/*" + - "dealerdirect/*" + - "php-parallel-lint/*" + - "phpcompatibility/*" + - "phpunit/*" + - "squizlabs/*" + - "yoast/*" + labels: + - "dependencies" + commit-message: + prefix: "Composer" + include: "scope" + open-pull-requests-limit: 5 + versioning-strategy: increase-if-necessary diff --git a/.github/workflows/cs-lint.yml b/.github/workflows/cs-lint.yml index a12f1cf..6a7f1e7 100644 --- a/.github/workflows/cs-lint.yml +++ b/.github/workflows/cs-lint.yml @@ -11,6 +11,9 @@ on: # Allow manually triggering the workflow. workflow_dispatch: +# Disable all permissions by default. Enable specific permissions per job. +permissions: {} + jobs: checkcs: name: Lint checks for PHP ${{ matrix.php }} @@ -32,7 +35,7 @@ jobs: steps: - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ matrix.php }} coverage: none @@ -41,20 +44,22 @@ jobs: # Show PHP lint violations inline in the file diff. # @link https://github.com/marketplace/actions/xmllint-problem-matcher - name: Register PHP lint violations to appear as file diff comments - uses: korelstar/phplint-problem-matcher@v1 + uses: korelstar/phplint-problem-matcher@cb2b753750ec7bf13a7cde0a476df8c5605bdfb1 # v1.2.0 # Show XML violations inline in the file diff. # @link https://github.com/marketplace/actions/xmllint-problem-matcher - name: Register XML violations to appear as file diff comments - uses: korelstar/xmllint-problem-matcher@v1 + uses: korelstar/xmllint-problem-matcher@1bd292d642ddf3d369d02aaa8b262834d61198c0 # v1.2.0 - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false # Install dependencies and handle caching in one go. # @link https://github.com/marketplace/actions/install-php-dependencies-with-composer - name: Install Composer dependencies - uses: ramsey/composer-install@v2 + uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # 3.1.1 # Lint PHP. - name: Lint PHP against parse errors @@ -64,7 +69,7 @@ jobs: # @link https://github.com/marketplace/actions/xml-lint - name: Lint phpunit.xml.dist if: ${{ matrix.php >= 8.0 }} - uses: ChristophWurst/xmllint-action@v1 + uses: ChristophWurst/xmllint-action@7c54ff113fc0f6d4588a15cb4dfe31b6ecca5212 # v1.2.1 with: xml-file: ./phpunit.xml.dist xml-schema-file: ./vendor/phpunit/phpunit/phpunit.xsd diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6d65596..134fced 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,16 +6,26 @@ on: # Allow manually triggering the workflow. workflow_dispatch: +# Disable all permissions by default. Enable specific permissions per job. +permissions: {} + jobs: tag: name: New tag runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Install SVN (Subversion) + run: | + sudo apt-get update + sudo apt-get install subversion - name: WordPress Plugin Deploy - uses: 10up/action-wordpress-plugin-deploy@stable + uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # 2.3.0 env: SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SVN_USERNAME: ${{ secrets.SVN_USERNAME }} diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..d27f69e --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,77 @@ +name: Run PHPUnit + +on: + # Run on all pushes and on all pull requests. + # Prevent the "push" build from running when there are only irrelevant changes. + push: + paths-ignore: + - "**.md" + pull_request: + + # Allow manually triggering the workflow. + workflow_dispatch: + +# Disable all permissions by default. Enable specific permissions per job. +permissions: {} + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: WP ${{ matrix.wordpress }} on PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + env: + WP_VERSION: ${{ matrix.wordpress }} + + strategy: + matrix: + include: + # Check lowest supported WP version, with the lowest supported PHP. + - wordpress: '6.4' + php: '7.4' + # Check latest WP with the latest PHP. + - wordpress: 'master' + php: 'latest' + fail-fast: false + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Install wordpress environment + run: npm install -g @wordpress/env + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${TOOL_CACHE}/php.json" + env: + TOOL_CACHE: ${{ runner.tool_cache }} + + - name: Setup Problem Matchers for PHPUnit + run: echo "::add-matcher::${TOOL_CACHE}/phpunit.json" + env: + TOOL_CACHE: ${{ runner.tool_cache }} + + - name: Install Composer dependencies + uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # 3.1.1 + + - name: Setup wp-env + run: wp-env start + env: + WP_ENV_CORE: WordPress/WordPress#${{ matrix.wordpress }} + + - name: Run integration tests + run: composer test:integration --no-interaction diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml deleted file mode 100644 index 365bdfd..0000000 --- a/.github/workflows/integrations.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Run PHPUnit - -on: - # Run on all pushes and on all pull requests. - # Prevent the "push" build from running when there are only irrelevant changes. - push: - paths-ignore: - - "**.md" - pull_request: - - # Allow manually triggering the workflow. - workflow_dispatch: - -# Cancels all previous workflow runs for the same branch that have not yet completed. -concurrency: - # The concurrency group contains the workflow name and the branch name. - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: WP ${{ matrix.wordpress }} on PHP ${{ matrix.php }} - runs-on: ubuntu-latest - - env: - WP_VERSION: ${{ matrix.wordpress }} - - strategy: - matrix: - php: [ '7.4', '8.2' ] - wordpress: [ '5.7', '6.3' ] - allowed_failure: [false] - coverage: [false] - include: - # Check upcoming WP. - - php: '8.2' - wordpress: 'trunk' - allowed_failure: true - coverage: false - # Check upcoming PHP. - - php: '8.3' - wordpress: 'latest' - allowed_failure: true - coverage: false - # Code coverage on latest PHP and WP. - - php: '8.2' - wordpress: 'latest' - allowed_failure: false - coverage: true - exclude: - # WordPress 5.7 doesn't support PHP 8.2. - - php: '8.2' - wordpress: '5.7' - fail-fast: false - continue-on-error: ${{ matrix.allowed_failure }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: ${{ matrix.coverage && 'xdebug' || 'none' }} - - - name: Setup problem matchers for PHP - run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" - - - name: Setup Problem Matchers for PHPUnit - run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - - name: Install Composer dependencies - uses: ramsey/composer-install@v2 - - - name: Start MySQL Service - run: sudo systemctl start mysql.service - - - name: Setting mysql_native_password for PHP <= 7.3 - if: ${{ matrix.php <= 7.3 }} - run: mysql -u root -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" - - - name: Prepare environment for integration tests - run: composer prepare-ci --no-interaction - - - name: Run integration tests - if: ${{ matrix.coverage == false }} - run: composer test --no-interaction - - - name: Run integration tests with code coverage - if: ${{ matrix.coverage == true }} - run: composer coverage-ci --no-interaction - - - name: Send coverage report to Codecov - if: ${{ success() && matrix.coverage == true }} - uses: codecov/codecov-action@v3 - with: - files: ./clover.xml - fail_ci_if_error: true - verbose: true diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml new file mode 100644 index 0000000..455ddd5 --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,65 @@ +name: Unit Tests + +on: + push: + branches: + - develop + - trunk + paths: + - '**.php' + - 'composer.json' + - 'phpunit.xml.dist' + - '.github/workflows/unit.yml' + pull_request: + paths: + - '**.php' + - 'composer.json' + - 'phpunit.xml.dist' + - '.github/workflows/unit.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + php: + - '7.4' + - '8.1' + - '8.2' + - '8.3' + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install Composer dependencies + uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # 3.1.1 + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Run unit tests + run: composer test:unit diff --git a/.gitignore b/.gitignore index 6356f23..51620d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,16 @@ -/.phpunit.cache -/node_modules -/vendor +# Dependencies +/node_modules/ +/vendor/ -/clover.xml +# Composer /composer.lock -/package-lock.json + +# Tests +/.phpunit.cache/ +/clover.xml + +# Local config overrides +/.phpcs.xml +/phpcs.xml +/phpunit.xml +/.wp-env.override.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d373ccb --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +omit=optional diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 42011f8..fabf828 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -39,7 +39,7 @@ - + @@ -53,6 +53,8 @@ + + diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 0000000..bfe3821 --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,20 @@ +{ + "plugins": [ + "." + ], + "phpVersion": "7.4", + "config": { + "WP_DEBUG": true, + "WP_DEBUG_LOG": true, + "SCRIPT_DEBUG": true + }, + "lifecycleScripts": { + "afterStart": "wp-env run cli wp plugin install query-monitor --activate" + }, + "env": { + "tests": { + "phpVersion": "8.4", + "core": "WordPress/WordPress#trunk" + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 342a108..97552b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2025-12-21 + +This version requires WordPress 6.4 and PHP 7.4 as a minimum. + +### Added +- Dedicated edit page for ad codes, replacing inline edit functionality by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/196 +- Autocomplete for conditional arguments using Select2 (categories, tags, pages, posts) by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/194 +- Wrapper div with CSS classes for improved ad styling and targeting by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/188 +- wp-env configuration for local development by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/178 + +### Fixed +- Validate unique tag IDs for DFP Async provider by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/190 +- Prevent empty widget wrapper output when no ad codes found by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/186 +- Ensure row actions display in first data column by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/189 + +### Changed +- Update minimum WordPress version to 6.4 by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/181 + +### Documentation +- Add PHPDoc documentation for all hooks by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/193 +- Add contextual help for DFP and AdSense provider fields by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/191 + +### Maintenance +- Standardise GitHub Actions, add unit test workflow with Brain Monkey by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/184 +- Migrate integration tests from SVN to wp-env by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/182 +- Migrate dependabot reviewers to CODEOWNERS by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/185 +- Standardise test matrix and update readme by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/187 +- Add .npmrc and update package-lock by @GaryJones in https://github.com/Automattic/ad-code-manager/pull/198 +- Bump actions/checkout from 5 to 6 by @dependabot in https://github.com/Automattic/ad-code-manager/pull/183 +- Bump codecov/codecov-action from 3 to 5 by @dependabot in https://github.com/Automattic/ad-code-manager/pull/174 +- Bump ramsey/composer-install from 2 to 3 by @dependabot in https://github.com/Automattic/ad-code-manager/pull/173 + ## [0.7.1] - 2023-09-09 ### Changed @@ -166,6 +198,7 @@ Bug fix release. Initial release. +[0.8.0]: https://github.com/Automattic/ad-code-manager/compare/0.7.1...0.8.0 [0.7.1]: https://github.com/Automattic/ad-code-manager/compare/0.7.0...0.7.1 [0.7.0]: https://github.com/Automattic/ad-code-manager/compare/0.6.0...0.7.0 [0.6.0]: https://github.com/Automattic/ad-code-manager/compare/0.5...0.6.0 diff --git a/README.md b/README.md index 217262a..5ca169c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Ad Code Manager -Stable tag: 0.7.1 -Requires at least: 5.7 -Tested up to: 5.9 +Stable tag: 0.8.0 +Requires at least: 6.4 +Tested up to: 6.9 Requires PHP: 7.4 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: advertising, ad codes, ads, adsense, dfp, doubleclick for publishers -Contributors: rinatkhaziev, jeremyfelt, danielbachhuber, carldanley, zztimur, automattic, doejo +Contributors: rinatkhaziev, jeremyfelt, danielbachhuber, carldanley, zztimur, automattic, doejo, garyj Manage your ad codes through the WordPress admin safely and easily. @@ -25,7 +25,7 @@ Once this configuration is in place, the Ad Code Manager admin interface will al ## Installation -The plugin requires PHP 7.4 or later. It is also tested WordPress 5.7 and later, though it may run on older versions. +The plugin requires PHP 7.4 or later. It is also tested WordPress 6.4 and later, though it may run on older versions. Since the plugin is in its early stages, there are a couple additional configuration steps: diff --git a/acm-autocomplete.js b/acm-autocomplete.js new file mode 100644 index 0000000..3acc58b --- /dev/null +++ b/acm-autocomplete.js @@ -0,0 +1,495 @@ +/** + * Conditional Autocomplete for Ad Code Manager + * + * Provides Select2-powered autocomplete for conditional arguments + * like categories, tags, pages, etc. + * + * @since 0.9.0 + */ +( function( $, acmAutocomplete ) { + 'use strict'; + + if ( typeof acmAutocomplete === 'undefined' ) { + return; + } + + var ConditionalAutocomplete = { + + /** + * Check if Select2 is available. + * + * @return {boolean} True if Select2 is loaded. + */ + isSelect2Available: function() { + return typeof $.fn.select2 === 'function'; + }, + + /** + * Initialize the autocomplete functionality. + */ + init: function() { + this.bindEvents(); + this.initExistingFields(); + this.updateAddButtonVisibility(); + }, + + /** + * Bind event handlers. + */ + bindEvents: function() { + var self = this; + + // Use event delegation for dynamically added conditional selects (Add and Edit forms). + $( document ).on( 'change', '#add-adcode select[name="acm-conditionals[]"], #edit-adcode select[name="acm-conditionals[]"]', function() { + self.handleConditionalChange( $( this ) ); + self.updateAddButtonVisibility(); + self.updateOperatorVisibility(); + }); + + // Handle adding new conditional rows. + $( document ).on( 'click', '.add-more-conditionals', function( e ) { + e.preventDefault(); + var $button = $( this ); + var $form = $button.closest( 'form' ); + var $container = $form.find( '.form-new-row' ); + + self.addConditionalRow( $container ); + + // Small delay to allow DOM to update, then clean up. + setTimeout( function() { + self.cleanupNewRows(); + self.updateAddButtonVisibility(); + self.updateOperatorVisibility(); + }, 100 ); + }); + + // Handle remove conditional click. + $( document ).on( 'click', '.acm-remove-conditional', function( e ) { + e.preventDefault(); + var $row = $( this ).closest( '.conditional-single-field' ); + $row.remove(); + + setTimeout( function() { + self.updateAddButtonVisibility(); + self.updateOperatorVisibility(); + }, 100 ); + }); + }, + + /** + * Initialize any existing conditional fields on page load. + * Targets both Add and Edit forms. + */ + initExistingFields: function() { + var self = this; + + // Target both Add and Edit forms. + $( '#add-adcode select[name="acm-conditionals[]"], #edit-adcode select[name="acm-conditionals[]"]' ).each( function() { + var $select = $( this ); + var conditional = $select.val(); + var $argumentsContainer = $select.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + + // Hide arguments for empty selection or no-parameter conditionals. + if ( ! conditional || self.hasNoParameters( conditional ) ) { + $argumentsContainer.hide(); + return; + } + + // Show arguments and init autocomplete if applicable. + $argumentsContainer.show(); + + // Only init autocomplete if not already initialized. + var $existingSelect2 = $argumentsContainer.find( 'select.acm-autocomplete-select' ); + if ( self.hasAutocomplete( conditional ) && ! $existingSelect2.length ) { + self.initAutocomplete( $select ); + } + }); + }, + + /** + * Add a new conditional row to the form. + * + * @param {jQuery} $container The container to add the row to. + */ + addConditionalRow: function( $container ) { + var $master = $container.find( '#conditional-single-field-master' ); + + if ( ! $master.length ) { + $master = $container.find( '.conditional-single-field' ).first(); + } + + if ( ! $master.length ) { + return; + } + + // Clone the master row. + var $newRow = $master.clone(); + + // Remove the master ID so only one exists. + $newRow.removeAttr( 'id' ); + + // Reset select to default. + $newRow.find( 'select[name="acm-conditionals[]"]' ).val( '' ); + + // Reset and show the input. + var $input = $newRow.find( 'input[name="acm-arguments[]"]' ); + $input.val( '' ).show(); + + // Remove any Select2 elements that may have been cloned. + $newRow.find( 'select.acm-autocomplete-select' ).remove(); + $newRow.find( '.select2-container' ).remove(); + + // Restore hidden input if present. + var $hiddenInput = $newRow.find( 'input[data-original-name="acm-arguments[]"]' ); + if ( $hiddenInput.length ) { + $hiddenInput + .attr( 'name', 'acm-arguments[]' ) + .removeAttr( 'data-original-name' ) + .val( '' ) + .show(); + } + + // Add remove link if not present. + var $arguments = $newRow.find( '.conditional-arguments' ); + if ( ! $arguments.find( '.acm-remove-conditional' ).length ) { + $arguments.append( ' Remove' ); + } + + // Hide the arguments container initially. + $arguments.hide(); + + // Append to container. + $container.append( $newRow ); + }, + + /** + * Clean up newly added conditional rows. + * + * When rows are cloned from the master template, they may contain + * leftover Select2 markup that needs to be cleaned up. + */ + cleanupNewRows: function() { + var self = this; + + $( 'select[name="acm-conditionals[]"]' ).each( function() { + var $conditionalSelect = $( this ); + var conditional = $conditionalSelect.val(); + var $argumentsContainer = $conditionalSelect.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + + // If no conditional is selected, clean up any Select2 remnants. + if ( ! conditional ) { + // Remove any cloned Select2 elements. + $argumentsContainer.find( 'select.acm-autocomplete-select' ).remove(); + $argumentsContainer.find( '.select2-container' ).remove(); + + // Restore the original input if it was hidden. + var $hiddenInput = $argumentsContainer.find( 'input[data-original-name="acm-arguments[]"]' ); + if ( $hiddenInput.length ) { + $hiddenInput + .attr( 'name', 'acm-arguments[]' ) + .removeAttr( 'data-original-name' ) + .val( '' ) + .show(); + } + + // Ensure input exists and is visible with correct attributes. + var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); + if ( ! $input.length ) { + $argumentsContainer.prepend( '' ); + } else { + $input.val( '' ).attr( 'type', 'text' ).show(); + } + + // Hide the arguments container since no conditional is selected. + $argumentsContainer.hide(); + } + }); + }, + + /** + * Handle when a conditional select changes. + * + * @param {jQuery} $select The conditional select element. + */ + handleConditionalChange: function( $select ) { + var conditional = $select.val(); + var $argumentsContainer = $select.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + + // Check if we have a Select2 select element or the original input. + var $existingSelect = $argumentsContainer.find( 'select.acm-autocomplete-select' ); + var $hiddenInput = $argumentsContainer.find( 'input[data-original-name="acm-arguments[]"]' ); + + // If there's a Select2 select, destroy it and restore the input. + if ( $existingSelect.length ) { + var currentVal = $existingSelect.val(); + $existingSelect.select2( 'destroy' ); + $existingSelect.remove(); + + // Restore the original input's name and visibility. + $hiddenInput + .attr( 'name', 'acm-arguments[]' ) + .removeAttr( 'data-original-name' ) + .val( currentVal || '' ) + .show(); + } + + // Get the input (either restored or original). + var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); + + // Reset the input value. + $input.val( '' ); + + // Hide arguments container when no conditional selected or for conditionals that take no parameters. + if ( ! conditional || this.hasNoParameters( conditional ) ) { + $argumentsContainer.hide(); + return; + } + + // Show arguments container for conditionals that take parameters. + $argumentsContainer.show(); + + // If this conditional supports autocomplete, initialize it. + if ( conditional && this.hasAutocomplete( conditional ) ) { + this.initAutocomplete( $select ); + } + }, + + /** + * Check if a conditional has autocomplete configuration. + * + * @param {string} conditional The conditional function name. + * @return {boolean} True if autocomplete is available. + */ + hasAutocomplete: function( conditional ) { + return acmAutocomplete.conditionals.hasOwnProperty( conditional ); + }, + + /** + * Get the autocomplete configuration for a conditional. + * + * @param {string} conditional The conditional function name. + * @return {Object|null} The configuration or null. + */ + getConfig: function( conditional ) { + return acmAutocomplete.conditionals[ conditional ] || null; + }, + + /** + * Initialize Select2 autocomplete on an arguments input. + * + * @param {jQuery} $conditionalSelect The conditional select element. + */ + initAutocomplete: function( $conditionalSelect ) { + var self = this; + var conditional = $conditionalSelect.val(); + var config = this.getConfig( conditional ); + + if ( ! config ) { + return; + } + + // Skip if Select2 is not available (CDN failed to load). + if ( ! this.isSelect2Available() ) { + return; + } + + var $argumentsContainer = $conditionalSelect.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); + var currentValue = $input.val(); + + // Hide the original input. + $input.hide(); + + // Create a select element for Select2 (it requires a ' ) + .attr( 'name', 'acm-arguments[]' ) + .addClass( 'acm-autocomplete-select' ); + + // If there's an existing value, add it as an option. + if ( currentValue ) { + $select.append( new Option( currentValue, currentValue, true, true ) ); + } + + // Insert the select after the hidden input. + $input.after( $select ); + + // Remove the name from the original input to avoid duplicate submission. + $input.removeAttr( 'name' ).attr( 'data-original-name', 'acm-arguments[]' ); + + // Determine the dropdown parent - use body for inline edit to avoid z-index issues. + var $dropdownParent = $argumentsContainer.closest( '.acm-editor-row' ).length + ? $( 'body' ) + : $argumentsContainer; + + // Initialize Select2 with AJAX. + $select.select2({ + dropdownParent: $dropdownParent, + ajax: { + url: acmAutocomplete.ajaxUrl, + dataType: 'json', + delay: 250, + data: function( params ) { + return { + action: 'acm_search_terms', + nonce: acmAutocomplete.nonce, + search: params.term, + conditional: conditional, + type: config.type, + taxonomy: config.taxonomy || '', + post_type: config.post_type || '' + }; + }, + processResults: function( response ) { + if ( response.success && response.data.results ) { + return { + results: response.data.results + }; + } + return { results: [] }; + }, + cache: true + }, + minimumInputLength: acmAutocomplete.minChars, + placeholder: self.getPlaceholder( conditional ), + allowClear: true, + tags: true, // Allow custom values (user can type their own). + createTag: function( params ) { + var term = $.trim( params.term ); + if ( term === '' ) { + return null; + } + return { + id: term, + text: term, + newTag: true + }; + }, + language: { + inputTooShort: function() { + return acmAutocomplete.i18n.inputTooShort; + }, + searching: function() { + return acmAutocomplete.i18n.searching; + }, + noResults: function() { + return acmAutocomplete.i18n.noResults; + }, + errorLoading: function() { + return acmAutocomplete.i18n.errorLoading; + } + }, + width: '100%' + }); + }, + + /** + * Get placeholder text for a conditional. + * + * @param {string} conditional The conditional function name. + * @return {string} The placeholder text. + */ + getPlaceholder: function( conditional ) { + var placeholders = { + 'is_category': acmAutocomplete.i18n.searchCategories || 'Search categories...', + 'has_category': acmAutocomplete.i18n.searchCategories || 'Search categories...', + 'is_tag': acmAutocomplete.i18n.searchTags || 'Search tags...', + 'has_tag': acmAutocomplete.i18n.searchTags || 'Search tags...', + 'is_page': acmAutocomplete.i18n.searchPages || 'Search pages...', + 'is_single': acmAutocomplete.i18n.searchPosts || 'Search posts...' + }; + + return placeholders[ conditional ] || 'Search...'; + }, + + /** + * Check if a conditional takes no parameters. + * + * @param {string} conditional The conditional function name. + * @return {boolean} True if the conditional takes no parameters. + */ + hasNoParameters: function( conditional ) { + var noParamConditionals = [ + 'is_home', + 'is_front_page', + 'is_archive', + 'is_search', + 'is_404', + 'is_date', + 'is_year', + 'is_month', + 'is_day', + 'is_time', + 'is_feed', + 'is_comment_feed', + 'is_trackback', + 'is_preview', + 'is_paged', + 'is_admin' + ]; + + return noParamConditionals.indexOf( conditional ) !== -1; + }, + + /** + * Update visibility of the "Add another condition" button. + * + * Shows the button only when at least one condition is selected. + */ + updateAddButtonVisibility: function() { + var $forms = $( '#add-adcode, #edit-adcode' ); + + $forms.each( function() { + var $form = $( this ); + var $addButton = $form.find( '.form-add-more' ); + var hasSelectedCondition = false; + + // Check if any conditional select has a value. + $form.find( 'select[name="acm-conditionals[]"]' ).each( function() { + if ( $( this ).val() ) { + hasSelectedCondition = true; + return false; // Break the loop. + } + }); + + if ( hasSelectedCondition ) { + $addButton.addClass( 'visible' ); + } else { + $addButton.removeClass( 'visible' ); + } + }); + }, + + /** + * Update visibility of the Logical Operator row. + * + * Shows the operator row only when 2+ conditions are selected. + */ + updateOperatorVisibility: function() { + var $operatorRow = $( '#operator-row' ); + + if ( ! $operatorRow.length ) { + return; + } + + // Count selected conditionals. + var selectedCount = 0; + $( '#edit-adcode select[name="acm-conditionals[]"]' ).each( function() { + if ( $( this ).val() ) { + selectedCount++; + } + }); + + if ( selectedCount >= 2 ) { + $operatorRow.show(); + } else { + $operatorRow.hide(); + } + } + }; + + // Initialize when document is ready. + $( document ).ready( function() { + ConditionalAutocomplete.init(); + }); + +} )( jQuery, window.acmAutocomplete ); diff --git a/acm.css b/acm.css index 3f1303d..86ba15c 100644 --- a/acm.css +++ b/acm.css @@ -37,137 +37,146 @@ tr:hover .row-actions { margin-bottom: 5px; } -.inline-edit-col .acm-section-label { - margin-bottom: 5px; - text-transform: none; +#add-adcode .form-add-more { + clear:left; + width: 90%; } -.inline-edit-col .acm-float-left { - float: left; +.acm-global-options { + clear: both; + margin-bottom: 20px; } -.inline-edit-col .acm-column-fields, -.inline-edit-col .acm-priority-field { - width: 250px; +.acm-global-options .acm-config-form p { + display: flex; + align-items: center; + gap: 10px; + margin: 0; } -.inline-edit-col .acm-column-fields label { - float: left; - width: 75px; +.acm-global-options .acm-config-form label { + font-weight: 600; } -.inline-edit-col .acm-column-fields input { - margin-bottom: 5px; +.acm-global-options .acm-config-form .button { + margin-left: 10px; +} + +.acm-global-options input[type="text"] { + min-width: 200px; + width: 30%; } -.inline-edit-col .acm-conditional-fields { +.acm-global-options #provider-field label { float: left; - min-width: 270px; + width: 125px; } -.inline-edit-col .acm-conditional-fields .conditional-arguments { - margin-left: 140px; +/* Ensure conditional row has consistent height even when arguments hidden */ +#add-adcode .conditional-single-field { + min-height: 36px; + margin-bottom: 8px; } -.inline-edit-col .acm-conditional-fields .conditional-arguments input { - width: 70%; - margin-bottom: 5px; +/* Select2 styling for conditional autocomplete */ +#add-adcode .conditional-arguments .select2-container { + width: 100% !important; } -.inline-edit-col .acm-conditional-fields .form-add-more { - margin-top: 10px; - clear: both; +#add-adcode .conditional-arguments .select2-container--default .select2-selection--single { + height: 30px; + border-color: #8c8f94; + border-radius: 4px; +} + +#add-adcode .conditional-arguments .select2-container--default .select2-selection--single .select2-selection__rendered { + line-height: 28px; + padding-left: 8px; +} + +#add-adcode .conditional-arguments .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 28px; } +/* Hide Add button initially until first condition is selected */ #add-adcode .form-add-more { - clear:left; - width: 90%; + display: none; } -.acm-edit-field { - position: relative; - max-width: 200px; - float: left; - margin: 15px 0 0 15px; +#add-adcode .form-add-more.visible { + display: block; } -.acm-edit-field label { - position: relative; - float: left; - font-weight: 700; - color: #333; +/* Edit page styles */ +#edit-adcode .form-table th { + width: 200px; + padding: 20px 10px 20px 0; } -.acm-edit-cond, -.acm-cancel-button, -.acm-edit-field input, -.acm-conditional-label { - position: relative; - float: left; - clear: left; +#edit-adcode .regular-text { + width: 25em; } -.acm-edit-cond { - margin: 5px 0 0 15px; +#edit-adcode .conditional-single-field { + margin-bottom: 10px; + display: flex; + align-items: flex-start; + gap: 10px; } -.acm-edit-cond input { - margin-left: 15px; +#edit-adcode .conditional-function { + flex: 0 0 200px; } -.acm-conditional-label { +#edit-adcode .conditional-function select { width: 100%; - font-weight: 700; - color: #333; - margin: 10px 0 0 15px; } -.acm-cancel-button { - margin: 10px 0 30px 15px; +#edit-adcode .conditional-arguments { + flex: 1; + display: flex; + align-items: center; + gap: 10px; } -.acm-edit-button { - float: left; - margin: 10px 0 30px 15px; +#edit-adcode .conditional-arguments input[type="text"] { + width: 200px; } -.acm-x-cond { - font-size: 14px; - padding-left: 5px; - color: #888; - cursor: pointer; - font-family: sans-serif +#edit-adcode .form-add-more { + display: none; + margin-top: 10px; } -.acm-x-cond:hover { - color: #333; - text-decoration: underline; +#edit-adcode .form-add-more.visible { + display: block; } -#acm-add-inline-cond { - display: block; - float: left; - clear: left; - padding-top: 5px; - width: 295px; - text-align: right; - cursor:pointer; +#edit-adcode .acm-remove-conditional { + color: #b32d2e; + text-decoration: none; } -#acm-add-inline-cond:hover { - text-decoration: underline; +#edit-adcode .acm-remove-conditional:hover { + color: #a00; } -.acm-global-options { - clear: both; +/* Select2 styling for edit page */ +#edit-adcode .conditional-arguments .select2-container { + width: 200px !important; } -.acm-global-options input[type="text"] { - min-width: 200px; - width: 30%; +#edit-adcode .conditional-arguments .select2-container--default .select2-selection--single { + height: 30px; + border-color: #8c8f94; + border-radius: 4px; } -.acm-global-options #provider-field label { - float: left; - width: 125px; +#edit-adcode .conditional-arguments .select2-container--default .select2-selection--single .select2-selection__rendered { + line-height: 28px; + padding-left: 8px; +} + +#edit-adcode .conditional-arguments .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 28px; } diff --git a/acm.js b/acm.js deleted file mode 100644 index 58c1777..0000000 --- a/acm.js +++ /dev/null @@ -1,396 +0,0 @@ -( function( window, $, undefined ) { - var document = window.document; - - var AdCodeManager = function() { - /** - * A reference to the AdCodeManager object so we avoid confusion with `this` later on - * - * @type {*} - */ - var SELF = this; - - /** - * Container for cached UI elements - * - * @type {Object} - */ - var UI = {}; - - /** - * Used for storing the currently edited ACM ID - * @type {Boolean} - */ - var EDIT_ID = false; - - /** - * Initializes the AdCodeManager when the object is instantiated. This script must always run from the footer - * after the DOM elements are rendered - * - * @private - */ - var _init = function() { - _cacheElements(); - _bindEvents(); - }; - - /** - * Caches useful DOM elements so we can easily reference them later without the extra lookup - * - * @private - */ - var _cacheElements = function() { - UI.addMoreButton = document.getElementById( 'conditional-tpl' ).querySelector( '.add-more-conditionals' ); - UI.theList = document.getElementById( 'the-list' ); - UI.theNew = document.getElementById( 'add-adcode' ); - }; - - /** - * Handles binding our events for the page to work - * - * @private - */ - var _bindEvents = function() { - _addEvent( 'click', UI.addMoreButton, _addConditional ); - _addEvent( 'click', UI.theList, _delegateListClicks ); - _addEvent( 'click', UI.theNew, _delegateNewAdClicks ); - _addEvent( 'keydown', UI.theList, _delegateListKeyEvents ); - }; - - /** - * Registers a DOM event for the specified element - * - * @param event The event we're hooking to - * @param element The element we want to monitor for the event - * @param callback The callback to be fired when the event is triggered - * @private - */ - var _addEvent = function( event, element, callback ) { - if( window.addEventListener ) { - element.addEventListener( event, callback, false ); - } - else { - element.attachEvent( 'on' + event, callback ); - } - }; - - /** - * Handles adding a new conditional row to the UI for the user - * - * @param e The event object - * @private - */ - var _addConditional = function( e ) { - e = e || window.event; - var target = e.srcElement || e.target; - var parent = target.parentNode.parentNode.querySelector( '.form-new-row' ); - _addInlineConditionalRow( parent ); - - _killEvent( e ); - }; - - /** - * Kills the passed event and prevents it from bubbling up the DOM - * - * @param e The event we're killing - * @private - */ - var _killEvent = function( e ) { - e.returnValue = false; - e.cancelBubble = true; - if( e.stopPropagation ) { - e.stopPropagation(); - } - if( e.preventDefault ) { - e.preventDefault(); - } - }; - - /** - * Handles checking delegated events for the add ad code area - * - * @param e The event object - * @private - */ - var _delegateNewAdClicks = function( e ) { - e = e || window.event; - var target = e.srcElement || e.target; - - // check for remove conditional call - if( _hasClass( target, 'acm-remove-conditional' ) === true ) { - _removeInlineConditionalRow( target ); - _killEvent( e ); - } - }; - - /** - * Handles checking delegated key events for the inline editor and table rows - * @param e - * @private - */ - var _delegateListKeyEvents = function( e ) { - e = e || window.event; - var key = e.which || e.keyCode; - - // 13 is Enter, which avoids the default form on the page from saving - if ( key === 13 ) { - _saveInlineEditorChanges(); - _killEvent( e ); - } - }; - - /** - * Handles checking delegated events for the inline editor and table rows - * - * @param e The event object - * @private - */ - var _delegateListClicks = function( e ) { - e = e || window.event; - var target = e.srcElement || e.target; - - // check for ajax edit click - if( _hasClass( target, 'acm-ajax-edit' ) === true ) { - // close other editors - if( EDIT_ID !== false ) { - if( confirm( 'Are you sure you want to do this? Any unsaved data will be lost.' ) === false ) { - _killEvent( e ); - return; - } - _toggleInlineEdit( false ); - } - - EDIT_ID = parseInt( target.id.replace( 'acm-edit-', '' ), 10 ); - _toggleInlineEdit( true ); - _killEvent( e ); - } - - // check for cancel button - else if( _hasClass( target, 'cancel' ) === true && EDIT_ID !== false ) { - _toggleInlineEdit( false ); - EDIT_ID = false; - } - - // check for remove conditional call - else if( _hasClass( target, 'acm-remove-conditional' ) === true ) { - _removeInlineConditionalRow( target ); - _killEvent( e ); - } - - // check for save button - else if( _hasClass( target, 'save' ) === true ) { - _toggleLoader( true ); - _saveInlineEditorChanges(); - _killEvent( e ); - } - - // check for add more conditionals - else if( _hasClass( target, 'add-more-conditionals' ) === true ) { - _addInlineConditionalRow( UI.theList.querySelector( '#ad-code-' + EDIT_ID + ' .acm-editor-row .acm-conditional-fields .form-new-row' ) ); - _killEvent( e ); - } - }; - - /** - * Saves any inline editor changes that occurred - * - * @private - */ - var _saveInlineEditorChanges = function() { - $.post( window.ajaxurl, _getFormData(), function( result ) { - if( result ) { - if( result.indexOf( ' -1 ) { - $( document.getElementById( 'ad-code-' + EDIT_ID ) ).before( result).remove(); - EDIT_ID = false; - } - else { - _showError( result ); - } - } - else { - _showError( inlineEditL10n.error ); - } - } ); - }; - - /** - * Shows the error for this ad code if it exists - * - * @param html - * @private - */ - var _showError = function( html ) { - var errorContainer = document.getElementById( 'ad-code-' + EDIT_ID ).querySelector( '.acm-editor-row .inline-edit-save .error' ); - errorContainer.innerHTML = html; - errorContainer.style.display = 'block'; - }; - - /** - * Custom serialization function based off of $.serializeArray() - slimmed down to exactly what we need - * - * @return {Array} - * @private - */ - var _getFormData = function() { - var data = []; - var fields = document.getElementById( 'ad-code-' + EDIT_ID ).querySelector( '.acm-editor-row fieldset' ); - var elements = fields.querySelectorAll( 'input, select, textarea' ), element, name; - - for( var i = 0, len = elements.length; i < len; i++ ) { - element = elements[ i ]; - name = element.name.replace( /^\s+|\s+$/i, '' ); - if( name === '' ) { - continue; - } - - data.push( { name : name, value : element.value } ); - } - - return data; - }; - - /** - * Removes the conditional row from the perspective of the button clicked - * - * @param target The `remove` button that was clicked. - * @private - */ - var _removeInlineConditionalRow = function( target ) { - var row = target.parentNode.parentNode; - var parent = row.parentNode; - parent.removeChild( row ); - }; - - /** - * Add a new inline editor conditional row for the current ad-code - * - * @private - */ - var _addInlineConditionalRow = function( parent ) { - // create a new element - var newConditional = document.createElement( 'div' ); - newConditional.className = 'conditional-single-field'; - newConditional.innerHTML = document.getElementById( 'conditional-single-field-master' ).innerHTML; - newConditional.querySelector( '.conditional-arguments' ).innerHTML += 'Remove'; - - parent.appendChild( newConditional ); - }; - - /** - * Toggles the loader for the form. This should only be used when the save button is clicked and we have a current - * EDIT_ID available - * - * @param showing Indicates whether the loader should be showing or not - * @private - */ - var _toggleLoader = function( showing ) { - var loader = document.querySelector( '#ad-code-' + EDIT_ID + ' .acm-editor-row .inline-edit-save .waiting' ); - loader.style.display = ( showing === true ) ? 'block' : 'none'; - }; - - /** - * Lightweight utility function that handles checking an element to see if it contains a class - * - * @param element The element we're checking against - * @param className The class name we're looking for - * @return {Boolean} - * @private - */ - var _hasClass = function( element, className ) { - return ( ' ' + element.className + ' ' ).indexOf( ' ' + className + ' ' ) > -1; - }; - - /** - * Handles toggling the inline editor. We assume EDIT_ID is being handled correctly for this to work. - * - * @param visible Indicates whether we are hiding/showing the inline editor. - * @private - */ - var _toggleInlineEdit = function( visible ) { - var row = document.getElementById( 'ad-code-' + EDIT_ID ); - - if( visible === true ) { - _toggleTableChildrenDisplay( row, false ); - _createNewInlineRow( EDIT_ID, row ); - } - else { - _toggleTableChildrenDisplay( row, true ); - _removeEditInlineRow( row ); - } - }; - - /** - * Toggles all the table children of the parent. - * - * @param parent The parent table row we're toggling td's for - * @param display Indicates whether or not the row children should be shown or not - * @private - */ - var _toggleTableChildrenDisplay = function( parent, display ) { - display = ( display === true ) ? 'table-cell' : 'none'; - var children = parent.children; - for( var i = 0, len = children.length; i < len; i++ ) { - children[ i ].style.display = display; - } - }; - - /** - * Handles creating the new inline editor row with all of the necessary UI controls. Notice that we do not rebind - * events because they are already handled by the delegation technique in `_delegateListClicks` - * - * @param id The ID of the ad-code you are editing - * @param parentToBe The DOM element that the newRow will be inserted into - * @private - */ - var _createNewInlineRow = function( id, parentToBe ) { - var newRow = document.createElement( 'td' ); - newRow.setAttribute( 'colspan', ( parentToBe.children.length - 1 ) ); - newRow.className = 'acm-editor-row'; - newRow.innerHTML = document.getElementById( 'inline-edit' ).innerHTML; - - // fill in the rows with existing HTML here - var data = _getDataFromRow( id ); - newRow.querySelector( 'input[name="id"]' ).value = id; - newRow.querySelector( '.acm-conditional-fields' ).innerHTML = data.conditionalFields; - newRow.querySelector( '.acm-column-fields' ).innerHTML = data.columnFields; - newRow.querySelector( '.acm-priority-field' ).innerHTML = data.priority; - newRow.querySelector( '.acm-operator-field' ).innerHTML = data.operator; - - parentToBe.appendChild( newRow ); - }; - - /** - * Removes the editor inline row if it exists - * - * @param parent The parent DOM element where we are removing the oldRow from - * @private - */ - var _removeEditInlineRow = function( parent ) { - var oldRow = parent.querySelector( '.acm-editor-row' ); - parent.removeChild( oldRow ); - parent.querySelector( '.column-id' ).style.display = 'none'; - }; - - /** - * Builds an object literal containing HTML for the new Row based off of existing DOM elements and their HTML - * - * @param id The ID of the ad-code we're retrieving information from. - * @return {Object} - * @private - */ - var _getDataFromRow = function( id ) { - var dataParent = document.getElementById( 'inline_' + id ); - return { - conditionalFields : dataParent.querySelector( '.acm-conditional-fields' ).innerHTML, - columnFields : dataParent.querySelector( '.acm-column-fields' ).innerHTML, - priority : dataParent.querySelector( '.acm-priority-field' ).innerHTML, - operator : dataParent.querySelector( '.acm-operator-field' ).innerHTML - }; - }; - - // fire our initialization method - _init(); - }; - - window.AdCodeManager = new AdCodeManager(); - -} )( window, jQuery ); diff --git a/ad-code-manager.php b/ad-code-manager.php index 9b56dcc..2dc00a3 100644 --- a/ad-code-manager.php +++ b/ad-code-manager.php @@ -11,7 +11,7 @@ * Plugin Name: Ad Code Manager * Plugin URI: https://wordpress.org/plugins/ad-code-manager/ * Description: Easy ad code management. - * Version: 0.7.1 + * Version: 0.8.0 * Author: Automattic and contributors * Author URI: https://github.com/Automattic/ad-code-manager/graphs/contributors * Text Domain: ad-code-manager @@ -19,7 +19,7 @@ * License URI: http://www.gnu.org/licenses/gpl-2.0.txt * GitHub Plugin URI: https://github.com/Automattic/ad-code-manager/ * Requires PHP: 7.4 - * Requires WP: 5.7 + * Requires WP: 6.4 */ declare(strict_types=1); @@ -27,16 +27,18 @@ namespace Automattic\AdCodeManager; use Ad_Code_Manager; +use Automattic\AdCodeManager\UI\Conditional_Autocomplete; use Automattic\AdCodeManager\UI\Contextual_Help; use Automattic\AdCodeManager\UI\Plugin_Actions; -const AD_CODE_MANAGER_VERSION = '0.7.1'; +const AD_CODE_MANAGER_VERSION = '0.8.0'; const AD_CODE_MANAGER_FILE = __FILE__; require_once __DIR__ . '/src/class-acm-provider.php'; require_once __DIR__ . '/src/class-acm-wp-list-table.php'; require_once __DIR__ . '/src/class-acm-widget.php'; require_once __DIR__ . '/src/class-ad-code-manager.php'; +require_once __DIR__ . '/src/UI/class-conditional-autocomplete.php'; require_once __DIR__ . '/src/UI/class-contextual-help.php'; require_once __DIR__ . '/src/UI/class-plugin-actions.php'; @@ -51,6 +53,9 @@ function () { add_action( 'admin_init', function () { + $conditional_autocomplete = new Conditional_Autocomplete(); + $conditional_autocomplete->run(); + $contextual_help = new Contextual_Help(); $contextual_help->run(); diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh deleted file mode 100755 index ee05775..0000000 --- a/bin/install-wp-tests.sh +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env bash - -if [ $# -lt 3 ]; then - echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" - exit 1 -fi - -DB_NAME=$1 -DB_USER=$2 -DB_PASS=$3 -DB_HOST=${4-localhost} -WP_VERSION=${5-latest} -SKIP_DB_CREATE=${6-false} - -TMPDIR=${TMPDIR-/tmp} -TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") -WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} -WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress} - -download() { - if [ `which curl` ]; then - curl -s "$1" > "$2"; - elif [ `which wget` ]; then - wget -nv -O "$2" "$1" - fi -} - -if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then - WP_BRANCH=${WP_VERSION%\-*} - WP_TESTS_TAG="branches/$WP_BRANCH" - -elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then - WP_TESTS_TAG="branches/$WP_VERSION" -elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then - if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then - # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x - WP_TESTS_TAG="tags/${WP_VERSION%??}" - else - WP_TESTS_TAG="tags/$WP_VERSION" - fi -elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then - WP_TESTS_TAG="trunk" -else - # http serves a single offer, whereas https serves multiple. we only want one - download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json - grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json - LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') - if [[ -z "$LATEST_VERSION" ]]; then - echo "Latest WordPress version could not be found" - exit 1 - fi - WP_TESTS_TAG="tags/$LATEST_VERSION" -fi -set -ex - -install_wp() { - - if [ -d $WP_CORE_DIR ]; then - return; - fi - - mkdir -p $WP_CORE_DIR - - if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then - mkdir -p $TMPDIR/wordpress-trunk - rm -rf $TMPDIR/wordpress-trunk/* - svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress - mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR - else - if [ $WP_VERSION == 'latest' ]; then - local ARCHIVE_NAME='latest' - elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then - # https serves multiple offers, whereas http serves single. - download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json - if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then - # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x - LATEST_VERSION=${WP_VERSION%??} - else - # otherwise, scan the releases and get the most up to date minor version of the major release - local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` - LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) - fi - if [[ -z "$LATEST_VERSION" ]]; then - local ARCHIVE_NAME="wordpress-$WP_VERSION" - else - local ARCHIVE_NAME="wordpress-$LATEST_VERSION" - fi - else - local ARCHIVE_NAME="wordpress-$WP_VERSION" - fi - download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz - tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR - fi - - download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php -} - -install_test_suite() { - # portable in-place argument for both GNU sed and Mac OSX sed - if [[ $(uname -s) == 'Darwin' ]]; then - local ioption='-i.bak' - else - local ioption='-i' - fi - - # set up testing suite if it doesn't yet exist - if [ ! -d $WP_TESTS_DIR ]; then - # set up testing suite - mkdir -p $WP_TESTS_DIR - rm -rf $WP_TESTS_DIR/{includes,data} - svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes - svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data - fi - - if [ ! -f wp-tests-config.php ]; then - download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php - # remove all forward slashes in the end - WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") - sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php - sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php - sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php - sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php - sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php - sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php - fi - -} - -recreate_db() { - shopt -s nocasematch - if [[ $1 =~ ^(y|yes)$ ]] - then - mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA - create_db - echo "Recreated the database ($DB_NAME)." - else - echo "Leaving the existing database ($DB_NAME) in place." - fi - shopt -u nocasematch -} - -create_db() { - mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA -} - -install_db() { - - if [ ${SKIP_DB_CREATE} = "true" ]; then - return 0 - fi - - # parse DB_HOST for port or socket references - local PARTS=(${DB_HOST//\:/ }) - local DB_HOSTNAME=${PARTS[0]}; - local DB_SOCK_OR_PORT=${PARTS[1]}; - local EXTRA="" - - if ! [ -z $DB_HOSTNAME ] ; then - if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then - EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" - elif ! [ -z $DB_SOCK_OR_PORT ] ; then - EXTRA=" --socket=$DB_SOCK_OR_PORT" - elif ! [ -z $DB_HOSTNAME ] ; then - EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" - fi - fi - - # create database - if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] - then - echo "Reinstalling will delete the existing test database ($DB_NAME)" - read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB - recreate_db $DELETE_EXISTING_DB - else - create_db - fi -} - -install_wp -install_test_suite -install_db diff --git a/composer.json b/composer.json index 75d6011..4e6541f 100644 --- a/composer.json +++ b/composer.json @@ -1,15 +1,19 @@ { "name": "automattic/ad-code-manager", - "type": "wordpress-plugin", "description": "Easy ad code management", - "homepage": "https://github.com/Automattic/ad-code-manager/", "license": "GPL-2.0-or-later", + "type": "wordpress-plugin", "authors": [ { "name": "Automattic", "homepage": "https://automattic.com/" } ], + "homepage": "https://github.com/Automattic/ad-code-manager/", + "support": { + "issues": "https://github.com/Automattic/ad-code-manager/issues", + "source": "https://github.com/Automattic/ad-code-manager" + }, "require": { "php": ">=7.4", "composer/installers": "^1.0 || ^2.0" @@ -18,46 +22,48 @@ "automattic/vipwpcs": "^3", "php-parallel-lint/php-parallel-lint": "^1.0", "phpcompatibility/phpcompatibility-wp": "^2.1", - "yoast/wp-test-utils": "^1" + "phpunit/phpunit": "^9.6", + "yoast/wp-test-utils": "^1.2" }, "config": { "allow-plugins": { "composer/installers": true, "dealerdirect/phpcodesniffer-composer-installer": true - } + }, + "sort-packages": true }, "scripts": { - "cbf": [ - "@php ./vendor/bin/phpcbf" - ], "coverage": [ "@php ./vendor/bin/phpunit --coverage-html ./.phpunit.cache/coverage-html" ], - "coverage-ci": [ - "@php ./vendor/bin/phpunit" - ], + "coverage-ci": "wp-env run tests-cli --env-cwd=wp-content/plugins/ad-code-manager ./vendor/bin/phpunit", "cs": [ - "@php ./vendor/bin/phpcs" + "@php ./vendor/bin/phpcs -q" ], + "cs-fix": [ + "@php ./vendor/bin/phpcbf -q" + ], + "i18n": "@php wp i18n make-pot . ./languages/ad-code-manager.pot", "lint": [ "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git" ], "lint-ci": [ "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git --checkstyle" ], - "prepare-ci": [ - "bash bin/install-wp-tests.sh wordpress_test root root localhost" - ], - "test": [ - "@php ./vendor/bin/phpunit --no-coverage --order-by=random" - ], - "test-ms": [ - "@putenv WP_MULTISITE=1", - "@composer test" - ] + "test:unit": "@php ./vendor/bin/phpunit --testsuite Unit", + "test:integration": "wp-env run tests-cli --env-cwd=wp-content/plugins/ad-code-manager ./vendor/bin/phpunit --testsuite integration --no-coverage --order-by=random", + "test:integration-ms": "wp-env run tests-cli --env-cwd=wp-content/plugins/ad-code-manager /bin/bash -c 'WP_MULTISITE=1 ./vendor/bin/phpunit --testsuite integration --no-coverage --order-by=random'" }, - "support": { - "issues": "https://github.com/Automattic/ad-code-manager/issues", - "source": "https://github.com/Automattic/ad-code-manager" + "scripts-descriptions": { + "coverage": "Run tests with code coverage reporting", + "coverage-ci": "Run integration tests with code coverage reporting in wp-env", + "cs": "Run PHP Code Sniffer", + "cs-fix": "Run PHP Code Sniffer and fix violations", + "i18n": "Generate a POT file for translation", + "lint": "Run PHP linting", + "lint-ci": "Run PHP linting and send results to stdout", + "test:unit": "Run unit tests (no WordPress required)", + "test:integration": "Run integration tests in wp-env", + "test:integration-ms": "Run integration tests in multisite mode in wp-env" } } diff --git a/languages/ad-code-manager.pot b/languages/ad-code-manager.pot index cce69bb..9ccf27a 100644 --- a/languages/ad-code-manager.pot +++ b/languages/ad-code-manager.pot @@ -1,213 +1,488 @@ -# Copyright (C) 2013 Ad Code Manager -# This file is distributed under the same license as the Ad Code Manager package. +# Copyright (C) 2025 Automattic and contributors +# This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: Ad Code Manager 0.4\n" -"Report-Msgid-Bugs-To: http://wordpress.org/tag/ad-code-manager\n" -"POT-Creation-Date: 2013-04-16 05:02:49+00:00\n" +"Project-Id-Version: Ad Code Manager 0.8.0\n" +"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/ad-code-manager\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2013-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" +"POT-Creation-Date: 2025-12-21T23:37:35+00:00\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"X-Generator: WP-CLI 2.12.0\n" +"X-Domain: ad-code-manager\n" + +#. Plugin Name of the plugin +#: ad-code-manager.php +#: src/class-ad-code-manager.php:694 +#: src/class-ad-code-manager.php:695 +msgid "Ad Code Manager" +msgstr "" -#: ad-code-manager.php:154 -msgid "Ad Codes" +#. Plugin URI of the plugin +#: ad-code-manager.php +msgid "https://wordpress.org/plugins/ad-code-manager/" msgstr "" -#: ad-code-manager.php:155 -msgid "Ad Code" +#. Description of the plugin +#: ad-code-manager.php +msgid "Easy ad code management." msgstr "" -#: ad-code-manager.php:276 -msgid "Doing something fishy, eh?" +#. Author of the plugin +#: ad-code-manager.php +msgid "Automattic and contributors" msgstr "" -#: ad-code-manager.php:279 -msgid "You do not have the necessary permissions to perform this action" +#. Author URI of the plugin +#: ad-code-manager.php +msgid "https://github.com/Automattic/ad-code-manager/graphs/contributors" msgstr "" -#: common/lib/acm-provider.php:27 common/lib/acm-wp-list-table.php:31 +#: src/class-acm-provider.php:29 +#: src/class-acm-wp-list-table.php:44 msgid "Name" msgstr "" -#: common/lib/acm-widget.php:14 +#: src/class-acm-widget.php:14 msgid "Display an Ad Code Manager ad zone within a widget area" msgstr "" -#: common/lib/acm-widget.php:16 +#: src/class-acm-widget.php:16 msgid "Ad Code Manager Ad Zone" msgstr "" -#: common/lib/acm-widget.php:46 +#. translators: %s is the URL to the Ad Code Manager settings page. +#: src/class-acm-widget.php:48 +#, php-format msgid "No ad codes have been added yet. Please create one." msgstr "" -#: common/lib/acm-wp-list-table.php:30 common/lib/acm-wp-list-table.php:39 -#: providers/doubleclick-for-publishers-async.php:189 -#: providers/doubleclick-for-publishers.php:101 -#: providers/google-adsense.php:168 +#: src/class-acm-wp-list-table.php:43 +#: src/class-acm-wp-list-table.php:52 +#: src/Providers/class-doubleclick-for-publishers-async.php:326 +#: src/Providers/class-doubleclick-for-publishers.php:104 +#: src/Providers/class-google-adsense-async.php:172 +#: src/Providers/class-google-adsense.php:171 msgid "ID" msgstr "" -#: common/lib/acm-wp-list-table.php:32 common/lib/acm-wp-list-table.php:43 -#: common/lib/acm-wp-list-table.php:243 -#: providers/doubleclick-for-publishers-async.php:194 -#: providers/doubleclick-for-publishers.php:104 -#: providers/google-adsense.php:172 +#: src/class-acm-wp-list-table.php:45 +#: src/class-acm-wp-list-table.php:56 +#: src/Providers/class-doubleclick-for-publishers-async.php:331 +#: src/Providers/class-doubleclick-for-publishers.php:107 +#: src/Providers/class-google-adsense-async.php:176 +#: src/Providers/class-google-adsense.php:175 +#: views/edit-ad-code.tpl.php:108 msgid "Priority" msgstr "" -#: common/lib/acm-wp-list-table.php:33 common/lib/acm-wp-list-table.php:44 -#: common/lib/acm-wp-list-table.php:248 -#: providers/doubleclick-for-publishers-async.php:195 -#: providers/doubleclick-for-publishers.php:105 -#: providers/google-adsense.php:173 +#: src/class-acm-wp-list-table.php:46 +#: src/class-acm-wp-list-table.php:57 +#: src/Providers/class-doubleclick-for-publishers-async.php:332 +#: src/Providers/class-doubleclick-for-publishers.php:108 +#: src/Providers/class-google-adsense-async.php:177 +#: src/Providers/class-google-adsense.php:176 +#: views/edit-ad-code.tpl.php:191 msgid "Logical Operator" msgstr "" -#: common/lib/acm-wp-list-table.php:34 common/lib/acm-wp-list-table.php:45 -#: common/lib/acm-wp-list-table.php:198 -#: common/views/ad-code-manager.tpl.php:125 -#: providers/doubleclick-for-publishers-async.php:196 -#: providers/doubleclick-for-publishers.php:106 -#: providers/google-adsense.php:174 +#: src/class-acm-wp-list-table.php:47 +#: src/class-acm-wp-list-table.php:58 +#: src/Providers/class-doubleclick-for-publishers-async.php:333 +#: src/Providers/class-doubleclick-for-publishers.php:109 +#: src/Providers/class-google-adsense-async.php:178 +#: src/Providers/class-google-adsense.php:177 +#: src/UI/class-contextual-help.php:171 +#: views/ad-code-manager.tpl.php:151 +#: views/edit-ad-code.tpl.php:124 msgid "Conditionals" msgstr "" -#: common/lib/acm-wp-list-table.php:60 common/lib/acm-wp-list-table.php:307 +#: src/class-acm-wp-list-table.php:73 +#: src/class-acm-wp-list-table.php:302 msgid "Delete" msgstr "" -#: common/lib/acm-wp-list-table.php:128 +#: src/class-acm-wp-list-table.php:152 msgid "No ad codes have been configured." msgstr "" -#: common/lib/acm-wp-list-table.php:205 -#: common/views/ad-code-manager.tpl.php:129 -msgid "Select conditional" +#: src/class-acm-wp-list-table.php:265 +msgid "None" msgstr "" -#: common/lib/acm-wp-list-table.php:217 -msgid "Add more" +#: src/class-acm-wp-list-table.php:293 +msgid "Edit" +msgstr "" + +#: src/class-ad-code-manager.php:258 +msgctxt "Post Type General Name" +msgid "Ad Codes" msgstr "" -#: common/lib/acm-wp-list-table.php:221 -msgid "URL Variables" +#: src/class-ad-code-manager.php:259 +msgctxt "Post Type Singular Name" +msgid "Ad Code" msgstr "" -#: common/lib/acm-wp-list-table.php:251 -msgid "OR" +#: src/class-ad-code-manager.php:326 +msgid "Doing something fishy, eh?" msgstr "" -#: common/lib/acm-wp-list-table.php:252 -msgid "AND" +#: src/class-ad-code-manager.php:330 +msgid "You do not have the necessary permissions to perform this action" msgstr "" -#: common/lib/acm-wp-list-table.php:280 -msgid "None" +#: src/class-ad-code-manager.php:753 +msgid "Invalid ad code ID." msgstr "" -#: common/lib/acm-wp-list-table.php:298 -msgid "Edit Ad Code" +#: src/class-ad-code-manager.php:754 +msgid "Error" +msgstr "" + +#: src/Providers/class-doubleclick-for-publishers-async.php:73 +#: src/Providers/class-doubleclick-for-publishers-async.php:327 +#: src/Providers/class-google-adsense-async.php:96 +#: src/Providers/class-google-adsense-async.php:173 +#: src/Providers/class-google-adsense.php:95 +#: src/Providers/class-google-adsense.php:172 +#: src/UI/class-contextual-help.php:117 +msgid "Tag" +msgstr "" + +#: src/Providers/class-doubleclick-for-publishers-async.php:83 +#: src/Providers/class-doubleclick-for-publishers-async.php:328 +#: src/Providers/class-google-adsense-async.php:106 +#: src/Providers/class-google-adsense-async.php:174 +#: src/Providers/class-google-adsense.php:105 +#: src/Providers/class-google-adsense.php:173 +#: src/UI/class-contextual-help.php:120 +msgid "Tag ID" +msgstr "" + +#: src/Providers/class-doubleclick-for-publishers-async.php:89 +#: src/Providers/class-doubleclick-for-publishers-async.php:329 +#: src/UI/class-contextual-help.php:123 +msgid "DFP ID" +msgstr "" + +#: src/Providers/class-doubleclick-for-publishers-async.php:95 +#: src/Providers/class-doubleclick-for-publishers-async.php:330 +#: src/UI/class-contextual-help.php:132 +msgid "Tag Name" +msgstr "" + +#. translators: %s: the duplicate tag ID +#: src/Providers/class-doubleclick-for-publishers-async.php:279 +#, php-format +msgid "The Tag ID \"%s\" is already in use. Each Tag ID must be unique to prevent conflicts with DFP Async." +msgstr "" + +#: src/Providers/class-doubleclick-for-publishers.php:69 +#: src/Providers/class-doubleclick-for-publishers.php:105 +msgid "Site Name" +msgstr "" + +#: src/Providers/class-doubleclick-for-publishers.php:75 +msgid "zone1" +msgstr "" + +#: src/Providers/class-doubleclick-for-publishers.php:106 +msgid "Zone1" +msgstr "" + +#: src/Providers/class-google-adsense-async.php:112 +#: src/Providers/class-google-adsense-async.php:175 +#: src/Providers/class-google-adsense.php:111 +#: src/Providers/class-google-adsense.php:174 +#: src/UI/class-contextual-help.php:142 +msgid "Publisher ID" +msgstr "" + +#: src/UI/class-conditional-autocomplete.php:107 +msgid "Searching..." +msgstr "" + +#: src/UI/class-conditional-autocomplete.php:108 +msgid "No results found" +msgstr "" + +#. translators: %d: minimum number of characters +#: src/UI/class-conditional-autocomplete.php:111 +#, php-format +msgid "Please enter %d or more characters" +msgstr "" + +#: src/UI/class-conditional-autocomplete.php:114 +msgid "Error loading results" +msgstr "" + +#: src/UI/class-conditional-autocomplete.php:182 +msgid "Permission denied." +msgstr "" + +#: src/UI/class-conditional-autocomplete.php:192 +msgid "Search term too short." +msgstr "" + +#: src/UI/class-contextual-help.php:46 +msgid "Ad Code Manager gives non-developers an interface in the WordPress admin for configuring your complex set of ad codes. Generally an \"Ad Code\" is a set of parameters you need to pass to an ad server, so it can serve the proper ad." +msgstr "" + +#: src/UI/class-contextual-help.php:47 +msgid "Some code-level configuration may be necessary to display the ads. See the GitHub repository for developer information." +msgstr "" + +#: src/UI/class-contextual-help.php:53 +msgid "Choose your ad network, and you will see a set of required fields to fill in, such as IDs. You can also set conditionals for each ad tag, which restricts the contexts for displaying the adverts. Priorities work pretty much the same way they work in WordPress. Lower numbers correspond with higher priority." +msgstr "" + +#: src/UI/class-contextual-help.php:54 +msgid "Once you've finished creating the ad codes, you can display them in your theme using:" +msgstr "" + +#: src/UI/class-contextual-help.php:56 +msgid "a template tag in your theme: <?php do_action( 'acm_tag', $tag_id ); ?>" +msgstr "" + +#: src/UI/class-contextual-help.php:57 +msgid "a shortcode: [acm-tag id=\"tag_id\"]" +msgstr "" + +#: src/UI/class-contextual-help.php:58 +msgid "or using a widget." +msgstr "" + +#: src/UI/class-contextual-help.php:65 +msgid "In the fields below, you can choose which conditionals you want. Some can take a value (i.e. define a specific category) in the second field." +msgstr "" + +#: src/UI/class-contextual-help.php:66 +msgid "Here's an overview of the conditionals; they work the same as the functions of the same name in WordPress." +msgstr "" + +#: src/UI/class-contextual-help.php:69 +msgid "When the main blog page is being displayed. This is the page which shows the time-based blog content of your site, so if you've set a static Page for the Front Page (see below), then this will only be true on the Page which you set as the \"Posts page\" in Settings > Reading." +msgstr "" + +#: src/UI/class-contextual-help.php:72 +msgid "When the front of the site is displayed, whether it is posts or a Page. Returns true when the main blog page is being displayed and the Settings > Reading > Front page displays is set to \"Your latest posts\", or when Settings > Reading > Front page displays is set to \"A static page\" and the \"Front Page\" value is the current page displayed." msgstr "" -#: common/lib/acm-wp-list-table.php:337 -msgid "Cancel" +#: src/UI/class-contextual-help.php:75 +msgid "When any Category archive page is being displayed." msgstr "" -#: common/lib/acm-wp-list-table.php:339 -msgid "Update" +#: src/UI/class-contextual-help.php:78 +msgid "When the archive page for Category 9 is being displayed." msgstr "" -#: common/views/ad-code-manager.tpl.php:11 +#: src/UI/class-contextual-help.php:80 +msgid "Stinky Cheeses" +msgstr "" + +#: src/UI/class-contextual-help.php:81 +msgid "When the archive page for the Category with Name \"Stinky Cheeses\" is being displayed." +msgstr "" + +#: src/UI/class-contextual-help.php:83 +msgid "blue-cheese" +msgstr "" + +#: src/UI/class-contextual-help.php:84 +msgid "When the archive page for the Category with Category Slug \"blue-cheese\" is being displayed." +msgstr "" + +#: src/UI/class-contextual-help.php:86 +msgid "array( 9, 'blue-cheese', 'Stinky Cheeses' )" +msgstr "" + +#: src/UI/class-contextual-help.php:87 +msgid "Returns true when the category of posts being displayed is either term_ID 9, or slug \"blue-cheese\", or name \"Stinky Cheeses\"." +msgstr "" + +#: src/UI/class-contextual-help.php:90 +msgid "Returns true if the current post is in the specified category id." +msgstr "" + +#: src/UI/class-contextual-help.php:93 +msgid "When any Tag archive page is being displayed." +msgstr "" + +#: src/UI/class-contextual-help.php:96 +msgid "When the archive page for tag with the slug of \"mild\" is being displayed." +msgstr "" + +#: src/UI/class-contextual-help.php:99 +msgid "Returns true when the tag archive being displayed has a slug of either \"sharp\", \"mild\", or \"extreme\"." +msgstr "" + +#: src/UI/class-contextual-help.php:102 +msgid "When the current post has a tag. Must be used inside The Loop." +msgstr "" + +#: src/UI/class-contextual-help.php:105 +msgid "When the current post has the tag \"mild\"." +msgstr "" + +#: src/UI/class-contextual-help.php:108 +msgid "When the current post has any of the tags in the array." +msgstr "" + +#: src/UI/class-contextual-help.php:115 +msgid "When using the DFP Async (DoubleClick for Publishers / Google Ad Manager) provider, you need to configure the following fields:" +msgstr "" + +#: src/UI/class-contextual-help.php:118 +msgid "The ad size/placement type (e.g., 728x90, 300x250). This determines which ad tag definition is used for this ad code." +msgstr "" + +#: src/UI/class-contextual-help.php:121 +msgid "A unique identifier for this specific ad unit placement. This is used as the HTML div ID where the ad will be rendered. Each Tag ID must be unique across all ad codes to prevent conflicts. Example: \"homepage-leaderboard\" or \"sidebar-mpu-1\"." +msgstr "" + +#: src/UI/class-contextual-help.php:125 +msgid "Your Google Ad Manager (formerly DFP) Network Code. This is a numeric identifier for your Ad Manager account." +msgstr "" + +#: src/UI/class-contextual-help.php:127 +msgid "Find it in Google Ad Manager: Admin > Global Settings > Network Code." +msgstr "" + +#: src/UI/class-contextual-help.php:129 +msgid "Example: 12345678" +msgstr "" + +#: src/UI/class-contextual-help.php:134 +msgid "The ad unit name/path as configured in Google Ad Manager. This is the hierarchical path to your ad unit." +msgstr "" + +#: src/UI/class-contextual-help.php:136 +msgid "Example: \"MySite/Homepage/Leaderboard\" or just \"Leaderboard\" for top-level units." +msgstr "" + +#: src/UI/class-contextual-help.php:139 +msgid "Google AdSense Provider" +msgstr "" + +#: src/UI/class-contextual-help.php:140 +msgid "When using the Google AdSense provider:" +msgstr "" + +#: src/UI/class-contextual-help.php:144 +msgid "Your AdSense Publisher ID. This starts with \"ca-pub-\" followed by a 16-digit number." +msgstr "" + +#: src/UI/class-contextual-help.php:146 +msgid "Example: ca-pub-1234567890123456" +msgstr "" + +#: src/UI/class-contextual-help.php:148 +msgid "Find it in your AdSense account: Account > Account Information > Publisher ID." +msgstr "" + +#: src/UI/class-contextual-help.php:157 +msgid "Overview" +msgstr "" + +#: src/UI/class-contextual-help.php:164 +#: views/ad-code-manager.tpl.php:52 +msgid "Configuration" +msgstr "" + +#: src/UI/class-contextual-help.php:178 +msgid "Provider Fields" +msgstr "" + +#: src/UI/class-plugin-actions.php:43 +msgid "Settings" +msgstr "" + +#: views/ad-code-manager.tpl.php:15 msgid "Ad code created." msgstr "" -#: common/views/ad-code-manager.tpl.php:14 +#: views/ad-code-manager.tpl.php:18 +#: views/edit-ad-code.tpl.php:33 +msgid "Ad code updated." +msgstr "" + +#: views/ad-code-manager.tpl.php:21 msgid "Ad code deleted." msgstr "" -#: common/views/ad-code-manager.tpl.php:17 +#: views/ad-code-manager.tpl.php:24 msgid "Ad codes deleted." msgstr "" -#: common/views/ad-code-manager.tpl.php:20 +#: views/ad-code-manager.tpl.php:27 msgid "Options saved." msgstr "" -#: common/views/ad-code-manager.tpl.php:57 -msgid "Configuration" +#: views/ad-code-manager.tpl.php:46 +msgid "Refer to help section for more information." msgstr "" -#: common/views/ad-code-manager.tpl.php:61 -msgid "Select a provider:" +#: views/ad-code-manager.tpl.php:55 +msgid "Provider:" msgstr "" -#: common/views/ad-code-manager.tpl.php:78 +#: views/ad-code-manager.tpl.php:79 msgid "Save Options" msgstr "" -#: common/views/ad-code-manager.tpl.php:83 -#: common/views/ad-code-manager.tpl.php:147 -msgid "Add New Ad Code" -msgstr "" - -#: providers/doubleclick-for-publishers-async.php:40 -#: providers/doubleclick-for-publishers-async.php:190 -#: providers/google-adsense.php:94 providers/google-adsense.php:169 -msgid "Tag" +#: views/ad-code-manager.tpl.php:91 +msgid "Existing Ad Codes" msgstr "" -#: providers/doubleclick-for-publishers-async.php:50 -#: providers/doubleclick-for-publishers-async.php:191 -#: providers/google-adsense.php:104 providers/google-adsense.php:170 -msgid "Tag ID" +#: views/ad-code-manager.tpl.php:108 +#: views/ad-code-manager.tpl.php:173 +msgid "Add New Ad Code" msgstr "" -#: providers/doubleclick-for-publishers-async.php:56 -#: providers/doubleclick-for-publishers-async.php:192 -msgid "DFP ID" +#: views/ad-code-manager.tpl.php:155 +#: views/edit-ad-code.tpl.php:134 +#: views/edit-ad-code.tpl.php:163 +msgid "Select conditional" msgstr "" -#: providers/doubleclick-for-publishers-async.php:62 -#: providers/doubleclick-for-publishers-async.php:193 -msgid "Tag Name" +#: views/ad-code-manager.tpl.php:169 +#: views/edit-ad-code.tpl.php:180 +msgid "Add another condition" msgstr "" -#: providers/doubleclick-for-publishers.php:67 -#: providers/doubleclick-for-publishers.php:102 -msgid "Site Name" +#: views/edit-ad-code.tpl.php:18 +msgid "Edit Ad Code" msgstr "" -#: providers/doubleclick-for-publishers.php:73 -msgid "zone1" +#: views/edit-ad-code.tpl.php:20 +msgid "Back to Ad Codes" msgstr "" -#: providers/doubleclick-for-publishers.php:103 -msgid "Zone1" +#: views/edit-ad-code.tpl.php:117 +msgid "Lower numbers have higher priority." msgstr "" -#: providers/google-adsense.php:110 providers/google-adsense.php:171 -msgid "Publisher ID" +#: views/edit-ad-code.tpl.php:151 +msgid "Remove" msgstr "" -#. Plugin Name of the plugin/theme -msgid "Ad Code Manager" +#: views/edit-ad-code.tpl.php:196 +msgid "OR (any condition)" msgstr "" -#. #-#-#-#-# plugin.pot (Ad Code Manager 0.4) #-#-#-#-# -#. Plugin URI of the plugin/theme -#. #-#-#-#-# plugin.pot (Ad Code Manager 0.4) #-#-#-#-# -#. Author URI of the plugin/theme -msgid "http://automattic.com" +#: views/edit-ad-code.tpl.php:199 +msgid "AND (all conditions)" msgstr "" -#. Description of the plugin/theme -msgid "Easy ad code management" +#: views/edit-ad-code.tpl.php:203 +msgid "How multiple conditionals are evaluated." msgstr "" -#. Author of the plugin/theme -msgid "Rinat Khaziev, Jeremy Felt, Daniel Bachhuber, Automattic, doejo" +#: views/edit-ad-code.tpl.php:210 +msgid "Update Ad Code" msgstr "" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..788c90a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1002 @@ +{ + "name": "ad-code-manager", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ad-code-manager", + "version": "0.8.0", + "license": "GPL-2.0-or-later", + "devDependencies": { + "version-bump-prompt": "^6.1.0" + } + }, + "node_modules/@jsdevtools/ez-spawn": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@jsdevtools/ez-spawn/-/ez-spawn-3.0.4.tgz", + "integrity": "sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-me-maybe": "^1.0.1", + "cross-spawn": "^7.0.3", + "string-argv": "^0.3.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jsdevtools/version-bump-prompt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@jsdevtools/version-bump-prompt/-/version-bump-prompt-6.1.0.tgz", + "integrity": "sha512-NJFLJRiD3LLFBgSxAb6B255xhWCGgdtzmh6UjHK2b7SRGX2DDKJH5O4BJ0GTStBu4NnaNgMbkr1TLW3pLOBkOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ez-spawn": "^3.0.4", + "command-line-args": "^5.1.1", + "detect-indent": "^6.0.0", + "detect-newline": "^3.1.0", + "globby": "^11.0.1", + "inquirer": "^7.3.3", + "log-symbols": "^4.0.0", + "semver": "^7.3.2" + }, + "bin": { + "bump": "bin/bump.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/inquirer": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.19", + "mute-stream": "0.0.8", + "run-async": "^2.4.0", + "rxjs": "^6.6.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/version-bump-prompt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/version-bump-prompt/-/version-bump-prompt-6.1.0.tgz", + "integrity": "sha512-GYC83GP8QOunWueKf2mbtZkdmisXhnBZPhIHWUmN/Yi4XXAQlIi9avM/IGWdI7KkJLfMENzGN1Xee+Zl3VJ5jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/version-bump-prompt": "6.1.0" + }, + "bin": { + "bump": "bump.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/package.json b/package.json index 2bad730..6b591c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ad-code-manager", - "version": "0.7.1", + "version": "0.8.0", "description": "Easy ad code management.", "license": "GPL-2.0-or-later", "author": "Automattic", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 98803cd..c7776ae 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ + + tests/Unit + - tests/Integration + tests/Integration @@ -27,7 +30,7 @@ - + diff --git a/src/Providers/class-doubleclick-for-publishers-async.php b/src/Providers/class-doubleclick-for-publishers-async.php index 24e873a..41f51f0 100644 --- a/src/Providers/class-doubleclick-for-publishers-async.php +++ b/src/Providers/class-doubleclick-for-publishers-async.php @@ -100,6 +100,7 @@ public function __construct() { add_filter( 'acm_ad_code_args', array( $this, 'filter_ad_code_args' ) ); add_filter( 'acm_output_html', array( $this, 'filter_output_html' ), 10, 2 ); + add_filter( 'acm_validate_ad_code', array( $this, 'validate_unique_tag_id' ), 10, 4 ); add_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); @@ -167,11 +168,7 @@ public function filter_output_html( $output_html, $tag_id ) { $tt = $tag['url_vars']; $matching_ad_code = $ad_code_manager->get_matching_ad_code( $tag['tag'] ); if ( ! empty( $matching_ad_code ) ) { - // @todo There might be a case when there are two tags registered with the same dimensions - // and the same tag id ( which is just a div id ). This confuses DFP Async, so we need to make sure - // that tags are unique - - // Parse ad tags to output flexible unit dimensions + // Parse ad tags to output flexible unit dimensions. $unit_sizes = $this->parse_ad_tag_sizes( $tt ); ?> @@ -235,6 +232,78 @@ public function parse_ad_tag_sizes( $url_vars ) { return $unit_sizes_output; } + /** + * Validate that the tag_id is unique for DFP Async. + * + * DFP Async uses the tag_id as a div ID, so each tag_id must be unique + * to prevent conflicts when multiple ads are rendered on the same page. + * + * @since 0.8.0 + * + * @param true|WP_Error $valid Current validation state. + * @param array $ad_code_vals The ad code values being saved. + * @param int $id The ad code ID (0 for new ad codes). + * @param string $method The method being performed ('add' or 'edit'). + * @return true|WP_Error True if valid, WP_Error if tag_id is not unique. + */ + public function validate_unique_tag_id( $valid, $ad_code_vals, $id, $method ) { + // If already invalid, don't override the error. + if ( is_wp_error( $valid ) ) { + return $valid; + } + + // Check if tag_id is provided. + if ( empty( $ad_code_vals['tag_id'] ) ) { + return $valid; + } + + global $ad_code_manager; + + $tag_id = $ad_code_vals['tag_id']; + $existing_id = $this->find_ad_code_by_tag_id( $tag_id ); + + // If no existing ad code with this tag_id, it's valid. + if ( ! $existing_id ) { + return true; + } + + // If editing and the existing ad code is the same one we're editing, it's valid. + if ( 'edit' === $method && $existing_id === $id ) { + return true; + } + + return new WP_Error( + 'duplicate-tag-id', + sprintf( + /* translators: %s: the duplicate tag ID */ + __( 'The Tag ID "%s" is already in use. Each Tag ID must be unique to prevent conflicts with DFP Async.', 'ad-code-manager' ), + esc_html( $tag_id ) + ) + ); + } + + /** + * Find an ad code by its tag_id. + * + * @since 0.8.0 + * + * @param string $tag_id The tag_id to search for. + * @return int|false The ad code post ID if found, false otherwise. + */ + protected function find_ad_code_by_tag_id( $tag_id ) { + global $ad_code_manager; + + $ad_codes = $ad_code_manager->get_ad_codes(); + + foreach ( $ad_codes as $ad_code ) { + if ( isset( $ad_code['url_vars']['tag_id'] ) && $ad_code['url_vars']['tag_id'] === $tag_id ) { + return $ad_code['post_id']; + } + } + + return false; + } + } class Doubleclick_For_Publishers_Async_ACM_WP_List_Table extends ACM_WP_List_Table { diff --git a/src/UI/class-conditional-autocomplete.php b/src/UI/class-conditional-autocomplete.php new file mode 100644 index 0000000..400a63a --- /dev/null +++ b/src/UI/class-conditional-autocomplete.php @@ -0,0 +1,286 @@ + admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'acm-search-terms' ), + 'minChars' => self::MIN_CHARS, + 'conditionals' => $this->get_autocomplete_conditionals(), + 'i18n' => array( + 'searching' => __( 'Searching...', 'ad-code-manager' ), + 'noResults' => __( 'No results found', 'ad-code-manager' ), + 'inputTooShort' => sprintf( + /* translators: %d: minimum number of characters */ + __( 'Please enter %d or more characters', 'ad-code-manager' ), + self::MIN_CHARS + ), + 'errorLoading' => __( 'Error loading results', 'ad-code-manager' ), + ), + ) + ); + } + + /** + * Get the conditionals that support autocomplete. + * + * Returns a mapping of conditional function names to their search configuration. + * + * @return array + */ + private function get_autocomplete_conditionals(): array { + $conditionals = array( + 'is_category' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'category', + ), + 'has_category' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'category', + ), + 'is_tag' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'post_tag', + ), + 'has_tag' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'post_tag', + ), + 'is_page' => array( + 'type' => 'post_type', + 'post_type' => 'page', + ), + 'is_single' => array( + 'type' => 'post_type', + 'post_type' => 'post', + ), + ); + + /** + * Filters the conditionals that support autocomplete. + * + * Allows themes and plugins to add or modify which conditionals + * get autocomplete functionality and how they search. + * + * @since 0.9.0 + * + * @param array $conditionals Associative array of conditional configurations. + * Each key is a conditional function name. + * Each value is an array with: + * - 'type': 'taxonomy' or 'post_type' + * - 'taxonomy': (for taxonomy type) The taxonomy to search + * - 'post_type': (for post_type type) The post type to search + */ + return apply_filters( 'acm_autocomplete_conditionals', $conditionals ); + } + + /** + * AJAX handler for searching terms. + * + * @return void + */ + public function ajax_search_terms(): void { + check_ajax_referer( 'acm-search-terms', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ad-code-manager' ) ) ); + } + + $search = isset( $_GET['search'] ) ? sanitize_text_field( wp_unslash( $_GET['search'] ) ) : ''; + $conditional = isset( $_GET['conditional'] ) ? sanitize_key( $_GET['conditional'] ) : ''; + $type = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : ''; + $taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_key( $_GET['taxonomy'] ) : ''; + $post_type = isset( $_GET['post_type'] ) ? sanitize_key( $_GET['post_type'] ) : ''; + + if ( strlen( $search ) < self::MIN_CHARS ) { + wp_send_json_error( array( 'message' => __( 'Search term too short.', 'ad-code-manager' ) ) ); + } + + $results = array(); + + if ( 'taxonomy' === $type && ! empty( $taxonomy ) ) { + $results = $this->search_taxonomy_terms( $search, $taxonomy ); + } elseif ( 'post_type' === $type && ! empty( $post_type ) ) { + $results = $this->search_posts( $search, $post_type ); + } + + /** + * Filters the autocomplete search results. + * + * @since 0.9.0 + * + * @param array $results The search results. + * @param string $search The search term. + * @param string $conditional The conditional function name. + * @param string $type The search type ('taxonomy' or 'post_type'). + */ + $results = apply_filters( 'acm_autocomplete_results', $results, $search, $conditional, $type ); + + wp_send_json_success( array( 'results' => $results ) ); + } + + /** + * Search for taxonomy terms. + * + * @param string $search The search term. + * @param string $taxonomy The taxonomy to search. + * @return array + */ + private function search_taxonomy_terms( string $search, string $taxonomy ): array { + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'search' => $search, + 'number' => self::MAX_RESULTS, + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return array(); + } + + $results = array(); + foreach ( $terms as $term ) { + $results[] = array( + 'id' => $term->slug, + 'text' => sprintf( '%s (%s)', $term->name, $term->slug ), + ); + } + + return $results; + } + + /** + * Search for posts. + * + * @param string $search The search term. + * @param string $post_type The post type to search. + * @return array + */ + private function search_posts( string $search, string $post_type ): array { + $posts = get_posts( + array( + 'post_type' => $post_type, + 's' => $search, + 'posts_per_page' => self::MAX_RESULTS, + 'post_status' => 'publish', + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + if ( empty( $posts ) ) { + return array(); + } + + $results = array(); + foreach ( $posts as $post ) { + // For is_page/is_single, we can use ID, slug, or title. + $results[] = array( + 'id' => (string) $post->ID, + 'text' => sprintf( '%s (ID: %d)', $post->post_title, $post->ID ), + ); + } + + return $results; + } +} diff --git a/src/UI/class-contextual-help.php b/src/UI/class-contextual-help.php index f8f1c30..d6cac64 100644 --- a/src/UI/class-contextual-help.php +++ b/src/UI/class-contextual-help.php @@ -110,6 +110,47 @@ public function render( WP_Screen $screen ): void { +

+
+
+
+ +
+
+ +
+
+ +
+ Admin > Global Settings > Network Code.', 'ad-code-manager' ) ); ?> +
+ +
+ +
+
+ +
+ +
+
+

+

+
+
+
+ +
+ +
+ Account > Account Information > Publisher ID.', 'ad-code-manager' ) ); ?> +
+
+ add_help_tab( array( 'id' => 'acm-overview', @@ -127,9 +168,16 @@ public function render( WP_Screen $screen ): void { get_current_screen()->add_help_tab( array( 'id' => 'acm-conditionals', - 'title' => 'Conditionals', + 'title' => __( 'Conditionals', 'ad-code-manager' ), 'content' => $conditionals, ) ); + get_current_screen()->add_help_tab( + array( + 'id' => 'acm-provider-fields', + 'title' => __( 'Provider Fields', 'ad-code-manager' ), + 'content' => $provider_fields, + ) + ); } } diff --git a/src/class-acm-provider.php b/src/class-acm-provider.php index 0cc5ebd..b57e46a 100644 --- a/src/class-acm-provider.php +++ b/src/class-acm-provider.php @@ -33,7 +33,15 @@ public function __construct() { ); } /** - * Configuration filter: acm_ad_code_args + * Filters the ad code arguments/fields for a provider. + * + * This filter is also applied in the main Ad_Code_Manager class after + * provider instantiation. It allows modification of the fields displayed + * in the admin UI for this provider's ad codes. + * + * @since 0.1 + * + * @param array $ad_code_args Array of field configurations. */ $this->ad_code_args = apply_filters( 'acm_ad_code_args', $this->ad_code_args ); @@ -44,6 +52,17 @@ public function __construct() { } if ( ! empty( $this->crawler_user_agent ) ) { + /** + * Filters whether to add robots.txt rules for ad crawlers. + * + * When a provider has a crawler_user_agent defined, this filter + * controls whether rules are added to robots.txt for that crawler. + * + * @since 0.1 + * + * @param bool $should_do Whether to add robots.txt rules. Default true. + * @param ACM_Provider $provider The provider instance. + */ $should_do_robotstxt = apply_filters( 'acm_should_do_robotstxt', true, $this ); if ( true === $should_do_robotstxt ) { @@ -63,6 +82,18 @@ public function action_do_robotstxt() { $disallowed[] = ''; } + /** + * Filters the disallowed paths for ad crawlers in robots.txt. + * + * Allows modification of which paths should be disallowed for the + * ad network's crawler in the robots.txt file. + * + * @since 0.1 + * + * @param array $disallowed Array of paths to disallow. Default array('') + * or array('/') if blog is not public. + * @param ACM_Provider $provider The provider instance. + */ $disallowed = apply_filters( 'acm_robotstxt_disallow', $disallowed, $this ); // If we have no disallows to add, don't add anything (including User-agent). diff --git a/src/class-acm-widget.php b/src/class-acm-widget.php index b3e5d20..c31e993 100644 --- a/src/class-acm-widget.php +++ b/src/class-acm-widget.php @@ -43,7 +43,10 @@ function form( $instance ) { plugin_slug, admin_url( 'options-general.php' ) ); ?> - Please create one.", 'ad-code-manager' ), esc_url( $create_url ) ); ?> + Please create one.", 'ad-code-manager' ), esc_url( $create_url ) ); + ?>

@@ -60,13 +63,35 @@ function update( $new_instance, $old_instance ) { // Display the widget function widget( $args, $instance ) { + // Capture the ad content to check if we have anything to display. + // This fixes issue #72: don't output empty widget wrapper when no ad code found. + ob_start(); + do_action( 'acm_tag', $instance['ad_zone'] ); + $ad_content = ob_get_clean(); + + /** + * Filters whether to display the widget when no ad content is found. + * + * @since 0.8.0 + * + * @param bool $display Whether to display the widget. Default false. + * @param string $ad_zone The ad zone ID. + * @param array $args Widget display arguments. + * @param array $instance Widget instance settings. + */ + if ( empty( $ad_content ) && ! apply_filters( 'acm_display_empty_widget', false, $instance['ad_zone'], $args, $instance ) ) { + return; + } + echo $args['before_widget']; $title = apply_filters( 'widget_title', $instance['title'] ); if ( ! empty( $title ) ) { echo $args['before_title'] . esc_html( $title ) . $args['after_title']; } - do_action( 'acm_tag', $instance['ad_zone'] ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ad content is escaped during token replacement in get_acm_tag(). + echo $ad_content; echo $args['after_widget']; } } diff --git a/src/class-acm-wp-list-table.php b/src/class-acm-wp-list-table.php index 5e88aa7..cb9d1ac 100644 --- a/src/class-acm-wp-list-table.php +++ b/src/class-acm-wp-list-table.php @@ -25,6 +25,17 @@ function __construct( $params = array() ) { * @return array $columns, the array of columns to use with the table */ function get_columns() { + /** + * Filters the columns displayed in the ad codes list table. + * + * Allows providers to customise which columns appear in the admin list table. + * Note: 'cb', 'id', 'priority', 'operator', and 'conditionals' are required + * and will be added automatically if missing. + * + * @since 0.1.3 + * + * @param array $columns Associative array of column IDs and labels. + */ $columns = apply_filters( 'acm_list_table_columns', array( @@ -34,7 +45,7 @@ function get_columns() { 'priority' => __( 'Priority', 'ad-code-manager' ), 'operator' => __( 'Logical Operator', 'ad-code-manager' ), 'conditionals' => __( 'Conditionals', 'ad-code-manager' ), - ) + ) ); // Fail-safe for misconfiguration $required_before = array( @@ -83,7 +94,13 @@ function prepare_items() { // Number of elements in your table? $totalitems = count( $this->items ); // return the total number of affected rows - // How many to display per page? + /** + * Filters the number of ad codes displayed per page in the list table. + * + * @since 0.1.3 + * + * @param int $per_page Number of ad codes per page. Default 25. + */ $perpage = apply_filters( 'acm_list_table_per_page', 25 ); // Which page is this? @@ -169,15 +186,50 @@ function column_default( $item, $column_name ) { case 'operator': return ( ! empty( $item['operator'] ) ) ? $item['operator'] : $ad_code_manager->logical_operator; default: - // @todo need to make the first column (whatever it is filtered) to show row actions - // Handle custom columns, if any + // Handle custom columns, if any. if ( isset( $item['url_vars'][ $column_name ] ) ) { - return esc_html( $item['url_vars'][ $column_name ] ); + $output = esc_html( $item['url_vars'][ $column_name ] ); + + // Add row actions to the first data column (after cb and id). + if ( $this->is_first_data_column( $column_name ) ) { + $output .= $this->row_actions_output( $item ); + } + + return $output; } break; } } + /** + * Check if the given column is the first data column. + * + * The first data column is the first column after 'cb' (checkbox) and 'id' (hidden). + * This column should display the row actions (edit/delete links). + * + * @since 0.8.0 + * + * @param string $column_name The column name to check. + * @return bool True if this is the first data column, false otherwise. + */ + protected function is_first_data_column( $column_name ) { + $columns = $this->get_columns(); + + // Skip 'cb' and 'id' columns to find the first data column. + $skip_columns = array( 'cb', 'id' ); + + foreach ( $columns as $key => $label ) { + if ( in_array( $key, $skip_columns, true ) ) { + continue; + } + + // The first column we encounter after skipping is the first data column. + return $key === $column_name; + } + + return false; + } + /** * Column with a checkbox * Used for bulk actions @@ -193,80 +245,6 @@ function column_cb( $item ) { return $output; } - /** - * Display hidden information we need for inline editing - */ - function column_id( $item ) { - global $ad_code_manager; - $output = ''; - return $output; - } /** * @@ -301,8 +279,18 @@ function column_conditionals( $item ) { */ function row_actions_output( $item ) { $output = ''; - // $row_actions['preview-ad-code'] = '' . __( 'Preview Ad Code', 'ad-code-manager' ) . ''; - $row_actions['edit'] = '' . __( 'Edit Ad Code', 'ad-code-manager' ) . ''; + + // Build edit URL for dedicated edit page. + $edit_url = add_query_arg( + array( + 'page' => 'ad-code-manager', + 'action' => 'edit', + 'id' => $item['post_id'], + ), + admin_url( 'options-general.php' ) + ); + + $row_actions['edit'] = '' . __( 'Edit', 'ad-code-manager' ) . ''; $args = array( 'action' => 'acm_admin_action', @@ -317,41 +305,5 @@ function row_actions_output( $item ) { return $output; } - /** - * Hidden form used for inline editing functionality - * - * @since 0.2 - */ - function inline_edit() { - ?> -
- -
- providers = apply_filters( 'acm_register_provider_slug', $this->providers ); /** - * Configuration filter: acm_provider_slug + * Filters the current ad provider slug. + * + * By default, the provider selected in the admin UI is used. + * Use this filter to programmatically switch to a different ad provider. * - * By default we use doubleclick-for-publishers provider - * To switch to a different ad provider use this filter + * @since 0.1.3 + * + * @param string|false $provider The provider slug (e.g., 'doubleclick_for_publishers', + * 'google_adsense'). False if not set. */ - $this->current_provider_slug = apply_filters( 'acm_provider_slug', $this->get_option( 'provider' ) ); // Instantiate one that we need @@ -114,8 +123,14 @@ function action_load_providers() { return; } /** - * Configuration filter: acm_whitelisted_script_urls - * A security filter to whitelist which ad code script URLs can be added in the admin + * Filters the whitelisted script URLs for ad codes. + * + * A security filter to control which ad code script URLs can be added in the admin. + * URLs not matching domains in this list will be rejected. + * + * @since 0.1 + * + * @param array $whitelisted_urls Array of whitelisted domain names (e.g., 'googleads.g.doubleclick.net'). */ $this->current_provider->whitelisted_script_urls = apply_filters( 'acm_whitelisted_script_urls', $this->current_provider->whitelisted_script_urls ); } @@ -138,29 +153,74 @@ function action_init() { 'has_tag', ); /** - * Configuration filter: acm_whitelisted_conditionals - * Extend the list of usable conditional functions with your own awesome ones. + * Filters the whitelisted conditional functions. + * + * Extend the list of usable conditional functions with your own. + * These functions determine when an ad code should be displayed. + * + * @since 0.1 + * + * @param array $conditionals Array of whitelisted function names + * (e.g., 'is_home', 'is_single', 'is_category'). */ $this->whitelisted_conditionals = apply_filters( 'acm_whitelisted_conditionals', $this->whitelisted_conditionals ); - // Allow users to filter default logical operator + + /** + * Filters the default logical operator for conditionals. + * + * Determines how multiple conditionals are evaluated together. + * 'OR' means any conditional can match; 'AND' means all must match. + * + * @since 0.1 + * + * @param string $operator The logical operator. Default 'OR'. Accepts 'OR' or 'AND'. + */ $this->logical_operator = apply_filters( 'acm_logical_operator', 'OR' ); - // Allow the ad management cap to be filtered if need be + /** + * Filters the capability required to manage ads. + * + * Controls which users can access the Ad Code Manager admin interface. + * + * @since 0.1 + * + * @param string $capability The capability required. Default 'manage_options'. + */ $this->manage_ads_cap = apply_filters( 'acm_manage_ads_cap', $this->manage_ads_cap ); // Load default ad tags for provider $this->ad_tag_ids = $this->current_provider->ad_tag_ids; + /** - * Configuration filter: acm_ad_tag_ids - * Extend set of default tag ids. Ad tag ids are used as a parameter - * for your template tag (e.g. do_action( 'acm_tag', 'my_top_leaderboard' )) + * Filters the registered ad tag IDs. + * + * Extend the set of default tag IDs. Ad tag IDs are used as parameters + * for template tags, e.g., `do_action( 'acm_tag', 'my_top_leaderboard' )`. + * + * @since 0.1 + * + * @param array $ad_tag_ids Array of ad tag configurations. Each tag has: + * - 'tag': (string) The tag identifier. + * - 'url_vars': (array) Variables for URL token replacement. + * - 'enable_ui_mapping': (bool) Optional. Whether tag can be mapped in admin UI. */ $this->ad_tag_ids = apply_filters( 'acm_ad_tag_ids', $this->ad_tag_ids ); /** - * Configuration filter: acm_ad_code_args - * Allow the ad code arguments to be filtered - * Useful if we need to dynamically change these arguments based on the above + * Filters the ad code arguments/fields. + * + * Allows customisation of the fields displayed in the admin UI for ad codes. + * Useful for adding custom fields or modifying existing ones. + * + * @since 0.1 + * + * @param array $ad_code_args Array of field configurations. Each field has: + * - 'key': (string) Field identifier. + * - 'label': (string) Display label. + * - 'editable': (bool) Whether field is editable. + * - 'required': (bool) Whether field is required. + * - 'type': (string) Optional. Field type (e.g., 'select'). + * - 'options': (array) Optional. Options for select fields. */ $this->current_provider->ad_code_args = apply_filters( 'acm_ad_code_args', $this->current_provider->ad_code_args ); @@ -168,6 +228,18 @@ function action_init() { // Ad tags are only run on the frontend if ( ! is_admin() ) { + /** + * Action: acm_tag + * + * Displays an ad tag on the frontend. Use this action in your theme + * templates to output ads. + * + * Example: do_action( 'acm_tag', 'my_leaderboard' ); + * + * @since 0.1 + * + * @param string $tag_id The unique identifier for the ad tag to display. + */ add_action( 'acm_tag', array( $this, 'action_acm_tag' ) ); add_filter( 'acm_output_tokens', array( $this, 'filter_output_tokens' ), 5, 3 ); } @@ -275,6 +347,32 @@ function handle_admin_action() { foreach ( $this->current_provider->ad_code_args as $arg ) { $ad_code_vals[ $arg['key'] ] = sanitize_text_field( $_REQUEST['acm-column'][ $arg['key'] ] ?? '' ); } + + /** + * Filter to validate ad code data before saving. + * + * Providers can hook into this filter to perform custom validation. + * Return a WP_Error to prevent saving and display an error message. + * + * @since 0.8.0 + * + * @param true|WP_Error $valid True if valid, WP_Error if validation fails. + * @param array $ad_code_vals The ad code values being saved. + * @param int $id The ad code ID (0 for new ad codes). + * @param string $method The method being performed ('add' or 'edit'). + */ + $validation_result = apply_filters( 'acm_validate_ad_code', true, $ad_code_vals, $id, $method ); + + if ( is_wp_error( $validation_result ) ) { + if ( isset( $_REQUEST['doing_ajax'] ) && sanitize_text_field( $_REQUEST['doing_ajax'] ) ) { + die( '
' . esc_html( $validation_result->get_error_message() ) . '
' ); + } + // Store the error message in a transient for display after redirect. + set_transient( 'acm_validation_error_' . get_current_user_id(), $validation_result->get_error_message(), 30 ); + $message = 'validation-error'; + break; + } + if ( 'add' === $method ) { $id = $this->create_ad_code( $ad_code_vals ); } else { @@ -353,14 +451,26 @@ function handle_admin_action() { * Get the ad codes stored in our custom post type */ function get_ad_codes( $query_args = array() ) { + /** + * Filters the allowed query parameters for retrieving ad codes. + * + * Controls which query parameters can be passed to modify the ad codes query. + * + * @since 0.1 + * + * @param array $allowed_params Array of allowed query parameter names. Default array( 'offset' ). + */ $allowed_query_params = apply_filters( 'acm_allowed_get_posts_args', array( 'offset' ) ); - /** - * Configuration filter: acm_ad_code_count + * Filters the maximum number of ad codes to retrieve. + * + * By default, queries are limited to 50 ad codes. + * Use this filter to increase or decrease the limit. + * + * @since 0.1 * - * By default we limit query to 50 ad codes - * Use this filter to change limit + * @param int $count Maximum number of ad codes to retrieve. Default 50. */ $args = array( 'post_type' => $this->post_type, @@ -617,9 +727,38 @@ function action_load_ad_code_manager() { * Print the admin interface for managing the ad codes. */ function admin_view_controller() { + // Check for edit action. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in render_edit_page. + if ( isset( $_GET['action'] ) && 'edit' === $_GET['action'] && isset( $_GET['id'] ) ) { + $this->render_edit_page(); + return; + } + require_once dirname( AD_CODE_MANAGER_FILE ) . '/views/ad-code-manager.tpl.php'; } + /** + * Render the dedicated edit page for an ad code. + * + * @since 0.10.0 + */ + function render_edit_page() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a view, nonce verified on form submission. + $ad_code_id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; + $ad_code = $this->get_ad_code( $ad_code_id ); + + // Handle invalid ID gracefully. + if ( ! $ad_code ) { + wp_die( + esc_html__( 'Invalid ad code ID.', 'ad-code-manager' ), + esc_html__( 'Error', 'ad-code-manager' ), + array( 'back_link' => true ) + ); + } + + require_once dirname( AD_CODE_MANAGER_FILE ) . '/views/edit-ad-code.tpl.php'; + } + /** * Register a custom widget to display ad zones */ @@ -639,7 +778,6 @@ function register_scripts_and_styles() { } wp_enqueue_style( 'acm-style', plugins_url( '/', AD_CODE_MANAGER_FILE ) . '/acm.css', array(), AD_CODE_MANAGER_VERSION ); - wp_enqueue_script( 'acm', plugins_url( '/', AD_CODE_MANAGER_FILE ) . '/acm.js', array( 'jquery' ), AD_CODE_MANAGER_VERSION, true ); } /** @@ -722,12 +860,6 @@ function register_ad_codes( $ad_codes = array() ) { continue; } - /** - * Configuration filter: acm_default_url - * If you don't specify a URL for your ad code when registering it in - * the WordPress admin or at a code level, you can simply apply it with - * a custom filter defined. - */ $ad_code['priority'] = $ad_code['priority'] === '' ? 10 : (int) $ad_code['priority']; // make sure priority is int, if it's unset, we set it to 10 // Make sure our operator is 'OR' or 'AND' @@ -735,6 +867,16 @@ function register_ad_codes( $ad_codes = array() ) { $operator = $this->logical_operator; } + /** + * Filters the default URL for an ad code. + * + * If you don't specify a URL for your ad code when registering it in + * the WordPress admin or at a code level, you can apply it with this filter. + * + * @since 0.1 + * + * @param string $url The ad code URL. May be empty. + */ $this->register_ad_code( $default_tag['tag'], apply_filters( 'acm_default_url', $ad_code['url'] ), $ad_code['conditionals'], array_merge( $default_tag['url_vars'], $ad_code['url_vars'] ), $ad_code['priority'], $ad_code['operator'] ); } } @@ -750,10 +892,13 @@ function register_ad_codes( $ad_codes = array() ) { */ function get_acm_tag( $tag_id ): string { /** - * See http://adcodemanager.wordpress.com/2013/04/10/hi-all-on-a-dotcom-site-that-uses/ + * Filters whether to disable ad rendering. + * + * By default, ads are disabled on post previews to prevent ad tracking issues. * - * Configuration filter: acm_disable_ad_rendering - * Should be boolean, defaulting to disabling ads on previews + * @since 0.1 + * + * @param bool $disable Whether to disable ad rendering. Default is the result of is_preview(). */ if ( apply_filters( 'acm_disable_ad_rendering', is_preview() ) ) { return ''; @@ -772,9 +917,15 @@ function get_acm_tag( $tag_id ): string { } /** - * Configuration filter: acm_output_html - * Support multiple ad formats ( e.g. Javascript ad tags, or simple HTML tags ) + * Filters the output HTML template for an ad tag. + * + * Support multiple ad formats (e.g., JavaScript ad tags, or simple HTML tags) * by adjusting the HTML rendered for a given ad tag. + * + * @since 0.1 + * + * @param string $output_html The HTML template with tokens (e.g., '%url%', '%width%'). + * @param string $tag_id The ad tag ID being rendered. */ $output_html = apply_filters( 'acm_output_html', $this->current_provider->output_html, $tag_id ); @@ -782,9 +933,17 @@ function get_acm_tag( $tag_id ): string { $output_html = str_replace( '%url%', $code_to_display['url'], $output_html ); /** - * Configuration filter: acm_output_tokens - * Register output tokens depending on the needs of your setup. Tokens are the - * keys to be replaced in your script URL. + * Filters the output tokens for an ad tag. + * + * Tokens are placeholders in the output HTML that get replaced with actual values. + * Register custom tokens depending on your setup. + * + * @since 0.1 + * + * @param array $output_tokens Associative array of tokens and their replacement values. + * Keys should include % (e.g., '%width%' => '300'). + * @param string $tag_id The ad tag ID being rendered. + * @param array $code_to_display The matching ad code configuration. */ $output_tokens = apply_filters( 'acm_output_tokens', $this->current_provider->output_tokens, $tag_id, $code_to_display ); foreach ( (array) $output_tokens as $token => $val ) { @@ -792,11 +951,42 @@ function get_acm_tag( $tag_id ): string { } /** - * Configuration filter: acm_output_html_after_tokens_processed - * In some rare cases you might want to filter html after the tokens are processed + * Filters the output HTML after token replacement. + * + * Use this filter for final modifications to the ad HTML after all tokens + * have been processed. Useful for adding wrappers or final adjustments. + * + * @since 0.2 + * + * @param string $output_html The processed HTML with all tokens replaced. + * @param string $tag_id The ad tag ID being rendered. */ $output_html = apply_filters( 'acm_output_html_after_tokens_processed', $output_html, $tag_id ); + /** + * Configuration filter: acm_wrapper_classes + * Filter the CSS classes applied to the ad wrapper div. + * + * @since 0.8.0 + * + * @param array $classes Array of CSS class names. Default: 'acm-wrapper', 'acm-tag-{tag_id}'. + * @param string $tag_id The ad tag ID being rendered. + */ + $wrapper_classes = apply_filters( + 'acm_wrapper_classes', + array( 'acm-wrapper', 'acm-tag-' . sanitize_html_class( $tag_id ) ), + $tag_id + ); + + // Allow disabling the wrapper by returning an empty array. + if ( ! empty( $wrapper_classes ) && is_array( $wrapper_classes ) ) { + $output_html = sprintf( + '
%s
', + esc_attr( implode( ' ', array_map( 'sanitize_html_class', $wrapper_classes ) ) ), + $output_html + ); + } + return $output_html; } @@ -832,13 +1022,16 @@ public function get_matching_ad_code( $tag_id ) { $cache_key = "acm:{$tag_id}:" . md5( serialize( $wp_query->query_vars ) ); /** - * Filters the amount of time to cache the matching ad code. + * Filters the cache expiration time for matching ad codes. * - * Returning false to this filter will cause the ad code to be cached - * only for the duration of the request, not within the object cache. + * Controls how long a matched ad code is cached to improve performance. + * Return false to disable object caching (request-level caching only). * - * @param int $cache_expiration The amount of time, in seconds, to cache the matching ad code. Default 10 minutes. - * @param string $tag_id The tag ID. + * @since 0.4 + * + * @param int|false $cache_expiration Cache time in seconds. Default 600 (10 minutes). + * Return false to disable object caching. + * @param string $tag_id The tag ID being matched. */ $cache_expiration = apply_filters( 'acm_matching_ad_code_cache_expiration', 600, $tag_id ); @@ -853,12 +1046,15 @@ public function get_matching_ad_code( $tag_id ) { } /** - * Prevent $post polution if ad code is getting rendered inside a loop: + * Filters whether to reset post data before matching ad codes. + * + * Most conditionals check against the global $post. When matching ad codes + * inside a loop, this can result in incorrect matches. Enable this filter + * to call wp_reset_postdata() before evaluation. * - * Most of conditionals are getting checked against global $post, - * Getting matched ad code inside the loop might result in wrong ad code matched. + * @since 0.4 * - * Filter is for back compat since not thoroughly tested + * @param bool $reset Whether to reset post data. Default false for backwards compatibility. */ if ( apply_filters( 'acm_reset_postdata_before_match', false ) ) { wp_reset_postdata(); @@ -869,6 +1065,17 @@ public function get_matching_ad_code( $tag_id ) { $display_codes = array(); foreach ( (array) $this->ad_codes[ $tag_id ] as $ad_code ) { + /** + * Filters whether to display ad codes without conditionals. + * + * By default, ad codes without conditionals are not displayed. + * Enable this filter to show ad codes on all pages when they have + * no conditional restrictions. + * + * @since 0.1 + * + * @param bool $display Whether to display ad codes without conditionals. Default false. + */ // If the ad code doesn't have any conditionals // we should add it to the display list if ( empty( $ad_code['conditionals'] ) && apply_filters( 'acm_display_ad_codes_without_conditionals', false ) ) { @@ -916,9 +1123,15 @@ public function get_matching_ad_code( $tag_id ) { // Run our conditional and use any arguments that were passed if ( ! empty( $cond_args ) ) { /** - * Configuration filter: acm_conditional_args - * For certain conditionals (has_tag, has_category), you might need to - * pass additional arguments. + * Filters the arguments passed to conditional functions. + * + * For certain conditionals like has_tag or has_category, you might need + * to modify the arguments passed to the function. + * + * @since 0.1 + * + * @param array $cond_args Array of arguments to pass to the conditional function. + * @param string $cond_func The conditional function name being called. */ $result = call_user_func_array( $cond_func, apply_filters( 'acm_conditional_args', $cond_args, $cond_func ) ); } else { diff --git a/tests/Integration/AdCodeManagerTest.php b/tests/Integration/AdCodeManagerTest.php index 6271986..42ec5f9 100644 --- a/tests/Integration/AdCodeManagerTest.php +++ b/tests/Integration/AdCodeManagerTest.php @@ -66,4 +66,115 @@ private function mock_ad_code() { private function create_ad_code_and_return() { return $this->acm->create_ad_code( $this->mock_ad_code() ); } + + /** + * Test that ad output includes wrapper div with default classes. + * + * @covers Ad_Code_Manager::get_acm_tag + */ + public function test_get_acm_tag_includes_wrapper_with_default_classes(): void { + // Create an ad code and register it. + $ad_code_id = $this->create_ad_code_and_return(); + $this->acm->flush_cache(); + $this->acm->register_ad_codes( $this->acm->get_ad_codes() ); + + // Get the first available tag. + $test_tag = null; + foreach ( $this->acm->ad_tag_ids as $tag ) { + $test_tag = $tag['tag']; + break; + } + + if ( ! $test_tag ) { + $this->markTestSkipped( 'No ad tags available for testing.' ); + } + + // Enable display of ad codes without conditionals. + add_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + + $output = $this->acm->get_acm_tag( $test_tag ); + + remove_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + $this->acm->delete_ad_code( $ad_code_id ); + + $this->assertStringContainsString( 'class="acm-wrapper', $output, 'Output should contain acm-wrapper class.' ); + $this->assertStringContainsString( 'acm-tag-' . sanitize_html_class( $test_tag ), $output, 'Output should contain tag-specific class.' ); + } + + /** + * Test that acm_wrapper_classes filter can modify wrapper classes. + * + * @covers Ad_Code_Manager::get_acm_tag + */ + public function test_acm_wrapper_classes_filter_modifies_classes(): void { + // Create an ad code and register it. + $ad_code_id = $this->create_ad_code_and_return(); + $this->acm->flush_cache(); + $this->acm->register_ad_codes( $this->acm->get_ad_codes() ); + + // Get the first available tag. + $test_tag = null; + foreach ( $this->acm->ad_tag_ids as $tag ) { + $test_tag = $tag['tag']; + break; + } + + if ( ! $test_tag ) { + $this->markTestSkipped( 'No ad tags available for testing.' ); + } + + // Filter to add custom classes. + $filter_callback = function ( $classes, $tag_id ) { + $classes[] = 'custom-ad-class'; + $classes[] = 'another-class'; + return $classes; + }; + + add_filter( 'acm_wrapper_classes', $filter_callback, 10, 2 ); + add_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + + $output = $this->acm->get_acm_tag( $test_tag ); + + remove_filter( 'acm_wrapper_classes', $filter_callback, 10 ); + remove_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + $this->acm->delete_ad_code( $ad_code_id ); + + $this->assertStringContainsString( 'custom-ad-class', $output, 'Output should contain custom class.' ); + $this->assertStringContainsString( 'another-class', $output, 'Output should contain another custom class.' ); + } + + /** + * Test that acm_wrapper_classes filter can disable wrapper. + * + * @covers Ad_Code_Manager::get_acm_tag + */ + public function test_acm_wrapper_classes_filter_can_disable_wrapper(): void { + // Create an ad code and register it. + $ad_code_id = $this->create_ad_code_and_return(); + $this->acm->flush_cache(); + $this->acm->register_ad_codes( $this->acm->get_ad_codes() ); + + // Get the first available tag. + $test_tag = null; + foreach ( $this->acm->ad_tag_ids as $tag ) { + $test_tag = $tag['tag']; + break; + } + + if ( ! $test_tag ) { + $this->markTestSkipped( 'No ad tags available for testing.' ); + } + + // Filter to return empty array (disables wrapper). + add_filter( 'acm_wrapper_classes', '__return_empty_array' ); + add_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + + $output = $this->acm->get_acm_tag( $test_tag ); + + remove_filter( 'acm_wrapper_classes', '__return_empty_array' ); + remove_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + $this->acm->delete_ad_code( $ad_code_id ); + + $this->assertStringNotContainsString( 'acm-wrapper', $output, 'Output should not contain wrapper when filter returns empty array.' ); + } } diff --git a/tests/Integration/UI/ConditionalAutocompleteTest.php b/tests/Integration/UI/ConditionalAutocompleteTest.php new file mode 100644 index 0000000..7bfcbb6 --- /dev/null +++ b/tests/Integration/UI/ConditionalAutocompleteTest.php @@ -0,0 +1,245 @@ +autocomplete = new Conditional_Autocomplete(); + } + + /** + * Test that the run method registers the necessary hooks. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete::run + */ + public function test_run_registers_hooks(): void { + $this->autocomplete->run(); + + self::assertNotFalse( + has_action( 'admin_enqueue_scripts', array( $this->autocomplete, 'enqueue_scripts' ) ) + ); + self::assertNotFalse( + has_action( 'wp_ajax_acm_search_terms', array( $this->autocomplete, 'ajax_search_terms' ) ) + ); + } + + /** + * Test that scripts are not enqueued on non-ACM pages. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete::enqueue_scripts + */ + public function test_scripts_not_enqueued_on_other_pages(): void { + $this->autocomplete->enqueue_scripts( 'edit.php' ); + + self::assertFalse( wp_script_is( 'acm-conditional-autocomplete', 'enqueued' ) ); + } + + /** + * Test that the acm_autocomplete_conditionals filter is applied. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_autocomplete_conditionals_filter(): void { + $filter_called = false; + $custom_conditionals = array( + 'custom_conditional' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'custom_tax', + ), + ); + + add_filter( + 'acm_autocomplete_conditionals', + function ( $conditionals ) use ( &$filter_called, $custom_conditionals ) { + $filter_called = true; + return array_merge( $conditionals, $custom_conditionals ); + } + ); + + // Trigger script enqueue to get the conditionals. + set_current_screen( 'settings_page_ad-code-manager' ); + $this->autocomplete->enqueue_scripts( 'settings_page_ad-code-manager' ); + + self::assertTrue( $filter_called ); + } + + /** + * Test default autocomplete conditionals include expected entries. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_default_autocomplete_conditionals(): void { + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'get_autocomplete_conditionals' ); + $method->setAccessible( true ); + + $conditionals = $method->invoke( $this->autocomplete ); + + // Verify expected conditionals are present. + self::assertArrayHasKey( 'is_category', $conditionals ); + self::assertArrayHasKey( 'has_category', $conditionals ); + self::assertArrayHasKey( 'is_tag', $conditionals ); + self::assertArrayHasKey( 'has_tag', $conditionals ); + self::assertArrayHasKey( 'is_page', $conditionals ); + self::assertArrayHasKey( 'is_single', $conditionals ); + + // Verify structure of a taxonomy conditional. + self::assertEquals( 'taxonomy', $conditionals['is_category']['type'] ); + self::assertEquals( 'category', $conditionals['is_category']['taxonomy'] ); + + // Verify structure of a post_type conditional. + self::assertEquals( 'post_type', $conditionals['is_page']['type'] ); + self::assertEquals( 'page', $conditionals['is_page']['post_type'] ); + } + + /** + * Test taxonomy term search. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_taxonomy_terms(): void { + // Create test categories. + self::factory()->category->create( array( 'name' => 'Technology News' ) ); + self::factory()->category->create( array( 'name' => 'Tech Reviews' ) ); + self::factory()->category->create( array( 'name' => 'Sports' ) ); + + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_taxonomy_terms' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'Tech', 'category' ); + + self::assertCount( 2, $results ); + self::assertArrayHasKey( 'id', $results[0] ); + self::assertArrayHasKey( 'text', $results[0] ); + } + + /** + * Test post search. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_posts(): void { + // Create test pages. + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => 'About Us', + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => 'About Our Team', + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => 'Contact', + 'post_status' => 'publish', + ) + ); + + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_posts' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'About', 'page' ); + + self::assertCount( 2, $results ); + self::assertArrayHasKey( 'id', $results[0] ); + self::assertArrayHasKey( 'text', $results[0] ); + } + + /** + * Test empty search returns empty array. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_no_results(): void { + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_taxonomy_terms' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'NonexistentTerm', 'category' ); + + self::assertIsArray( $results ); + self::assertEmpty( $results ); + } + + /** + * Test search for tags returns correct structure. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_tags(): void { + // Create test tags. + self::factory()->tag->create( array( 'name' => 'WordPress Tips' ) ); + self::factory()->tag->create( array( 'name' => 'WordPress Plugins' ) ); + + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_taxonomy_terms' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'WordPress', 'post_tag' ); + + self::assertCount( 2, $results ); + // Results should use slug as ID. + self::assertStringContainsString( 'wordpress', $results[0]['id'] ); + } + + /** + * Clean up after each test. + */ + public function tear_down(): void { + unset( $_GET['search'], $_GET['conditional'], $_GET['type'], $_GET['taxonomy'], $_GET['post_type'], $_GET['nonce'] ); + + // Dequeue scripts to prevent test pollution. + wp_dequeue_script( 'acm-conditional-autocomplete' ); + wp_dequeue_script( 'selectWoo' ); + wp_dequeue_style( 'select2' ); + + parent::tear_down(); + } +} diff --git a/tests/Integration/WidgetTest.php b/tests/Integration/WidgetTest.php new file mode 100644 index 0000000..3c3ac05 --- /dev/null +++ b/tests/Integration/WidgetTest.php @@ -0,0 +1,297 @@ +widget = new ACM_Ad_Zones(); + } + + /** + * Test widget outputs nothing when no ad code is found. + * + * This is the main fix for issue #72 - the widget should not output + * empty wrapper HTML when there's no ad content to display. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_nothing_when_no_ad_code_found(): void { + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Test Ad', + 'ad_zone' => 'nonexistent_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + $this->assertEmpty( $output, 'Widget should output nothing when no ad code is found.' ); + } + + /** + * Test widget outputs nothing for empty ad zone. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_nothing_for_empty_ad_zone(): void { + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => '', + 'ad_zone' => '', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + $this->assertEmpty( $output, 'Widget should output nothing for empty ad zone.' ); + } + + /** + * Test widget outputs wrapper when filter returns true for empty content. + * + * The acm_display_empty_widget filter allows themes to force display + * of the widget wrapper even when no ad content is found. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_wrapper_when_filter_returns_true(): void { + add_filter( 'acm_display_empty_widget', '__return_true' ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => '', + 'ad_zone' => 'nonexistent_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + remove_filter( 'acm_display_empty_widget', '__return_true' ); + + $this->assertStringContainsString( '
', $output, 'Widget should output wrapper when filter returns true.' ); + $this->assertStringContainsString( '
', $output, 'Widget should output closing wrapper when filter returns true.' ); + } + + /** + * Test widget outputs title when filter allows empty content. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_title_when_filter_allows_empty_content(): void { + add_filter( 'acm_display_empty_widget', '__return_true' ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Advertisement', + 'ad_zone' => 'nonexistent_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + remove_filter( 'acm_display_empty_widget', '__return_true' ); + + $this->assertStringContainsString( '

Advertisement

', $output, 'Widget should output title when filter allows.' ); + } + + /** + * Test acm_display_empty_widget filter receives correct arguments. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_filter_receives_correct_arguments(): void { + $received_args = array(); + + $filter_callback = function ( $display, $ad_zone, $args, $instance ) use ( &$received_args ) { + $received_args = array( + 'display' => $display, + 'ad_zone' => $ad_zone, + 'args' => $args, + 'instance' => $instance, + ); + return false; + }; + + add_filter( 'acm_display_empty_widget', $filter_callback, 10, 4 ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Test', + 'ad_zone' => 'test_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + ob_get_clean(); + + remove_filter( 'acm_display_empty_widget', $filter_callback, 10 ); + + $this->assertFalse( $received_args['display'], 'First argument should be false.' ); + $this->assertSame( 'test_zone', $received_args['ad_zone'], 'Second argument should be the ad zone.' ); + $this->assertSame( $args, $received_args['args'], 'Third argument should be widget args.' ); + $this->assertSame( $instance, $received_args['instance'], 'Fourth argument should be widget instance.' ); + } + + /** + * Test widget with valid ad code outputs content. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_content_with_valid_ad_code(): void { + // Create a valid ad code. + $ad_code_data = array(); + foreach ( $this->acm->current_provider->ad_code_args as $arg ) { + $ad_code_data[ $arg['key'] ] = 'test_value_' . $arg['key']; + } + $ad_code_data['priority'] = 10; + $ad_code_data['operator'] = 'AND'; + + // Need to set up a tag that's registered. + // First, let's check what tags are available. + $ad_tag_ids = $this->acm->ad_tag_ids; + if ( empty( $ad_tag_ids ) ) { + $this->markTestSkipped( 'No ad tag IDs available for testing.' ); + } + + // Get the first tag with enable_ui_mapping. + $test_tag = null; + foreach ( $ad_tag_ids as $tag ) { + if ( isset( $tag['enable_ui_mapping'] ) && $tag['enable_ui_mapping'] ) { + $test_tag = $tag['tag']; + break; + } + } + + if ( ! $test_tag ) { + $this->markTestSkipped( 'No tags with enable_ui_mapping available for testing.' ); + } + + // Set the tag in ad code data. + $ad_code_data['tag'] = $test_tag; + + // Create the ad code. + $ad_code_id = $this->acm->create_ad_code( $ad_code_data ); + $this->assertIsInt( $ad_code_id, 'Ad code should be created successfully.' ); + + // Register ad codes to make them available. + $this->acm->register_ad_codes( $this->acm->get_ad_codes() ); + + // Enable display of ad codes without conditionals. + add_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Ad Widget', + 'ad_zone' => $test_tag, + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + remove_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + + // Clean up. + $this->acm->delete_ad_code( $ad_code_id ); + + $this->assertStringContainsString( '
', $output, 'Widget should output wrapper with valid ad code.' ); + $this->assertStringContainsString( '
', $output, 'Widget should output closing wrapper.' ); + } + + /** + * Test that acm_tag action is still fired (backwards compatibility). + * + * @covers ACM_Ad_Zones::widget + */ + public function test_acm_tag_action_is_fired(): void { + $action_fired = false; + $action_tag = null; + + $action_callback = function ( $tag_id ) use ( &$action_fired, &$action_tag ) { + $action_fired = true; + $action_tag = $tag_id; + }; + + add_action( 'acm_tag', $action_callback, 5 ); // Priority 5 to run before the default handler. + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => '', + 'ad_zone' => 'test_action_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + ob_get_clean(); + + remove_action( 'acm_tag', $action_callback, 5 ); + + $this->assertTrue( $action_fired, 'acm_tag action should be fired for backwards compatibility.' ); + $this->assertSame( 'test_action_zone', $action_tag, 'acm_tag action should receive the correct tag ID.' ); + } +} diff --git a/tests/Unit/AdCodeManagerTest.php b/tests/Unit/AdCodeManagerTest.php new file mode 100644 index 0000000..c059873 --- /dev/null +++ b/tests/Unit/AdCodeManagerTest.php @@ -0,0 +1,242 @@ + null ) ); + + $this->ad_code_manager = new Ad_Code_Manager(); + + // Create a mock provider with whitelisted URLs. + $this->ad_code_manager->current_provider = new stdClass(); + $this->ad_code_manager->current_provider->whitelisted_script_urls = array( + 'example.com', + 'ads.google.com', + 'secure.pagead2.googlesyndication.com', + ); + } + + /** + * Test validate_script_url with empty URL returns true. + */ + public function testValidateScriptUrlEmptyReturnsTrue(): void { + $this->assertTrue( $this->ad_code_manager->validate_script_url( '' ) ); + } + + /** + * Test validate_script_url with exact domain match. + */ + public function testValidateScriptUrlExactDomainMatch(): void { + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $this->assertTrue( + $this->ad_code_manager->validate_script_url( 'https://example.com/script.js' ) + ); + } + + /** + * Test validate_script_url with subdomain match. + */ + public function testValidateScriptUrlSubdomainMatch(): void { + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $this->assertTrue( + $this->ad_code_manager->validate_script_url( 'https://cdn.example.com/ads/script.js' ) + ); + } + + /** + * Test validate_script_url with non-whitelisted domain. + */ + public function testValidateScriptUrlNonWhitelistedDomain(): void { + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $this->assertFalse( + $this->ad_code_manager->validate_script_url( 'https://malicious-site.com/script.js' ) + ); + } + + /** + * Test validate_script_url prevents domain spoofing. + * + * Ensures that 'evilexample.com' doesn't match 'example.com'. + */ + public function testValidateScriptUrlPreventsDomainSpoofing(): void { + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $this->assertFalse( + $this->ad_code_manager->validate_script_url( 'https://evilexample.com/script.js' ) + ); + } + + /** + * Test validate_script_url with Google Ads URL. + */ + public function testValidateScriptUrlGoogleAds(): void { + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $this->assertTrue( + $this->ad_code_manager->validate_script_url( 'https://ads.google.com/ad-manager/tag.js' ) + ); + } + + /** + * Test validate_script_url with deep subdomain. + */ + public function testValidateScriptUrlDeepSubdomain(): void { + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $this->assertTrue( + $this->ad_code_manager->validate_script_url( 'https://a.b.c.example.com/script.js' ) + ); + } + + /** + * Test filter_output_tokens adds URL vars as tokens. + */ + public function testFilterOutputTokensAddsUrlVars(): void { + $code_to_display = array( + 'url_vars' => array( + 'site_id' => '12345', + 'zone' => 'header', + 'width' => '728', + 'height' => '90', + ), + ); + + $output_tokens = $this->ad_code_manager->filter_output_tokens( array(), 'test_tag', $code_to_display ); + + $this->assertArrayHasKey( '%site_id%', $output_tokens ); + $this->assertArrayHasKey( '%zone%', $output_tokens ); + $this->assertArrayHasKey( '%width%', $output_tokens ); + $this->assertArrayHasKey( '%height%', $output_tokens ); + $this->assertSame( '12345', $output_tokens['%site_id%'] ); + $this->assertSame( 'header', $output_tokens['%zone%'] ); + } + + /** + * Test filter_output_tokens returns original tokens when no URL vars. + */ + public function testFilterOutputTokensReturnsOriginalWhenNoUrlVars(): void { + $code_to_display = array(); + $original_tokens = array( '%existing%' => 'value' ); + + $output_tokens = $this->ad_code_manager->filter_output_tokens( + $original_tokens, + 'test_tag', + $code_to_display + ); + + $this->assertSame( $original_tokens, $output_tokens ); + } + + /** + * Test filter_output_tokens preserves existing tokens. + */ + public function testFilterOutputTokensPreservesExistingTokens(): void { + $code_to_display = array( + 'url_vars' => array( + 'new_var' => 'new_value', + ), + ); + $original_tokens = array( '%existing%' => 'value' ); + + $output_tokens = $this->ad_code_manager->filter_output_tokens( + $original_tokens, + 'test_tag', + $code_to_display + ); + + $this->assertArrayHasKey( '%existing%', $output_tokens ); + $this->assertArrayHasKey( '%new_var%', $output_tokens ); + } + + /** + * Test get_acm_tag returns empty string when ad rendering is disabled. + * + * @covers Ad_Code_Manager::get_acm_tag + */ + public function testGetAcmTagReturnsEmptyWhenRenderingDisabled(): void { + Functions\when( 'is_preview' )->justReturn( false ); + Functions\when( 'apply_filters' )->alias( + function ( $filter_name, $value ) { + if ( 'acm_disable_ad_rendering' === $filter_name ) { + return true; // Disable rendering. + } + return $value; + } + ); + + $result = $this->ad_code_manager->get_acm_tag( 'test_tag' ); + + $this->assertSame( '', $result ); + } + + /** + * Test get_acm_tag returns empty string when no ad codes registered for tag. + * + * This verifies the fix for issue #72 - no broken HTML output when no ad codes found. + * + * @covers Ad_Code_Manager::get_acm_tag + */ + public function testGetAcmTagReturnsEmptyWhenNoAdCodesRegistered(): void { + Functions\when( 'apply_filters' )->alias( + function ( $filter_name, $value ) { + if ( 'acm_disable_ad_rendering' === $filter_name ) { + return false; // Rendering enabled. + } + return $value; + } + ); + Functions\when( 'is_preview' )->justReturn( false ); + Functions\when( 'wp_cache_get' )->justReturn( false ); + Functions\when( 'wp_cache_add' )->justReturn( true ); + + // Ensure no ad codes are registered for this tag. + $this->assertArrayNotHasKey( 'nonexistent_tag', $this->ad_code_manager->ad_codes ); + + $result = $this->ad_code_manager->get_acm_tag( 'nonexistent_tag' ); + + $this->assertSame( '', $result ); + } + + /** + * Test get_matching_ad_code returns null when tag has no registered ad codes. + * + * @covers Ad_Code_Manager::get_matching_ad_code + */ + public function testGetMatchingAdCodeReturnsNullWhenNoAdCodes(): void { + $result = $this->ad_code_manager->get_matching_ad_code( 'nonexistent_tag' ); + + $this->assertNull( $result ); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php new file mode 100644 index 0000000..3ee4d52 --- /dev/null +++ b/tests/Unit/TestCase.php @@ -0,0 +1,19 @@ +

' . esc_html( $message_text ) . '

'; + echo '

' . esc_html( $message_text ) . '

'; } } ?>

+ +
+

+
+

+ + + + + + + +

+
+
+
@@ -37,6 +88,7 @@
+

- -
-

-
- -
- - -
- - - - - - -
-
-

@@ -143,7 +166,7 @@
- +

@@ -153,7 +176,5 @@ -wp_list_table->inline_edit(); ?> - diff --git a/views/edit-ad-code.tpl.php b/views/edit-ad-code.tpl.php new file mode 100644 index 0000000..0b4767d --- /dev/null +++ b/views/edit-ad-code.tpl.php @@ -0,0 +1,212 @@ + +
+

+ + + + +

+ +

' . esc_html( $message_text ) . '

'; + } + } + ?> + + + + + + + + + + current_provider->ad_code_args as $arg ) : + if ( ! $arg['editable'] ) { + continue; + } + $column_id = 'acm-column[' . $arg['key'] . ']'; + $current_value = isset( $ad_code['url_vars'][ $arg['key'] ] ) + ? $ad_code['url_vars'][ $arg['key'] ] + : ''; + ?> + + + + + + + + + + + + + + + + + + + + = 2; ?> + > + + + + + + + + +