Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81324903a6 | |||
| 3e96357459 | |||
| 80343ec282 | |||
| 33addd1e49 | |||
| bed3f906da | |||
| 7ad65eab95 | |||
| 4b34851316 | |||
| b30baeddf0 | |||
| 13c4a1bcb9 | |||
| 692b23aed9 | |||
| 8ee3dce0fe | |||
| 2aa9058ee9 | |||
| 66db9e4c56 | |||
| 2ed48c3ba3 | |||
| eac0955b76 | |||
| b2725b219d | |||
| f929165b7b | |||
| 947e130386 | |||
| ca2cbdab65 | |||
| 7851f150fa | |||
| 1835c8776a | |||
| 1d70d13f45 | |||
| e26aadc4ec | |||
| 29adb05567 | |||
| 0aec4b9bb6 | |||
| eb1c8b78f4 | |||
| 06a3594417 | |||
| e18030b206 | |||
| 5968bcd95d | |||
| 303781e68b | |||
| d46913de41 | |||
| 1b50df7cd0 | |||
| a268470b60 | |||
| 1bdd51d9a3 | |||
| 22b46ad175 | |||
| b187c1c4d3 | |||
| ec8d69a6f4 | |||
| 28ac6e3cad | |||
| 582ff13a11 | |||
| edf9e6bc93 | |||
| fab07655a2 | |||
| 07955192d4 | |||
| 84f6e32913 | |||
| ad7acc40b7 | |||
| f48df1a33f | |||
| f15473527c | |||
| 9d1a7c5591 | |||
| a26500206a | |||
| 1b21ee9add | |||
| 6c782282c7 | |||
| ddefdc16d4 | |||
| 3a347269c8 | |||
| 02045c6f07 | |||
| 382786cb9d | |||
| 2022cf1d08 | |||
| 7807eae957 | |||
| 13ea341ef8 | |||
| 957f5c4ab0 | |||
| 336664473b | |||
| abbdb39acc | |||
| e8731c5119 | |||
| dc9a8e9886 | |||
| a7134bfb80 | |||
| 4fc59b757e | |||
| aa2da274dd | |||
| ec75721976 | |||
| f2e261c522 | |||
| b338925605 | |||
| 8c049f0cf2 | |||
| 77e338f9c3 | |||
| 781f171770 | |||
| b84252d787 | |||
| 08f9e313e0 | |||
| 20bd59ddae | |||
| 79a6c4d724 | |||
| f72a315986 | |||
| 2540d7c0e2 | |||
| b917e45c8c | |||
| 08492dd795 | |||
| ecf4ff4938 | |||
| 44a188b7ee | |||
| c70dc99bf9 | |||
| 10671e7b78 | |||
| 754f904e3b | |||
| aeca12b41d | |||
| f4eee2338b | |||
| 65c75dfc8d | |||
| d6b2e417f4 | |||
| d86c0a7bac | |||
| c84d2409b7 | |||
| 3d650eb848 | |||
| 0603537f0a | |||
| 8a1c9c0353 | |||
| 1eedaa9b3c | |||
| e0683a19b1 | |||
| 3f4fe52e1b | |||
| 1653011f4f | |||
| e9d8696c93 | |||
| 07db7734cf | |||
| 9dc4516ca0 | |||
| b407f235ac | |||
| fc0f018a94 | |||
| 151aadd661 | |||
| d3b72bd1af | |||
| 8fc1dc1f1c | |||
| 9fa84845be | |||
| 9f587c39dc | |||
| c5027b0fce | |||
| e33ae4e626 | |||
| d52d604d7a | |||
| 6751c69bc2 | |||
| b63ab46f54 | |||
| 2203bc471c | |||
| fa356a63b1 | |||
| fbe41e2130 | |||
| bc4fad3b86 | |||
| c712bf6e0a | |||
| 5e11c882aa | |||
| c300d56aef | |||
| e246c9539e | |||
| 77b4501682 | |||
| cd03d66151 | |||
| d8e7a7190a | |||
| ae99820bbc | |||
| 5385d72346 | |||
| f524ea3f16 | |||
| 76c01e44df |
@@ -0,0 +1,61 @@
|
|||||||
|
name: Anti-EAV Lint + Quality Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
anti-eav-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Composer install (no-dev)
|
||||||
|
run: |
|
||||||
|
if [ -f composer.json ]; then
|
||||||
|
composer install --no-dev --optimize-autoloader --no-interaction
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PHP syntax lint
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
FAILED=$(find . -name '*.php' \
|
||||||
|
-not -path './vendor/*' \
|
||||||
|
-not -path './node_modules/*' \
|
||||||
|
-not -path './e2e/*' \
|
||||||
|
-not -path './tests/*' \
|
||||||
|
-exec php -l {} \; 2>&1 | grep -v 'No syntax errors' || true)
|
||||||
|
if [ -n "$FAILED" ]; then
|
||||||
|
echo "PHP syntax errors detected:"
|
||||||
|
echo "$FAILED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Anti-EAV strict lint
|
||||||
|
run: |
|
||||||
|
if ! command -v wp >/dev/null 2>&1; then
|
||||||
|
echo "wp-cli not available; skipping anti-eav lint"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# dev30 hosts the plugin family this repo belongs to.
|
||||||
|
TMDO_HOST="/var/www/Studio/wp-local-dev30"
|
||||||
|
if [ -d "$TMDO_HOST" ]; then
|
||||||
|
wp --path="$TMDO_HOST" tmdo lint --plugin="${GITHUB_WORKSPACE}" --strict
|
||||||
|
else
|
||||||
|
echo "TMDO_HOST not found; skipping anti-eav lint"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NOTE: no packaging audit here on purpose.
|
||||||
|
# The shared package-plugin.sh resolves the plugin by SLUG to a fixed path
|
||||||
|
# under wp-content/plugins — it does NOT package the CI checkout. Running it
|
||||||
|
# from a job therefore rebuilds (and `composer install --no-dev`s) the live
|
||||||
|
# working copy, wiping the developer's vendor/bin. Packaging belongs to the
|
||||||
|
# release workflow / a manual run, not to every push.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-gate:
|
||||||
|
name: Test Gate (lint + phpcs + phpstan + unit + integration)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Host-mode runner: no service containers. Uses the host MariaDB, same as
|
||||||
|
# the Tests workflow.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
- name: PHP Lint
|
||||||
|
run: |
|
||||||
|
find . -name "*.php" \
|
||||||
|
! -path "./vendor/*" \
|
||||||
|
! -path "./tests/*" \
|
||||||
|
-print0 | xargs -0 -n1 php -l
|
||||||
|
echo "PHP syntax OK"
|
||||||
|
|
||||||
|
- name: PHPCS
|
||||||
|
run: vendor/bin/phpcs --standard=phpcs.xml --report=checkstyle -q .
|
||||||
|
|
||||||
|
- name: PHPStan
|
||||||
|
run: vendor/bin/phpstan analyse --no-progress --error-format=github
|
||||||
|
|
||||||
|
- name: Unit Tests
|
||||||
|
run: php vendor/bin/phpunit --configuration phpunit.xml --testdox
|
||||||
|
|
||||||
|
- name: Integration Tests
|
||||||
|
env:
|
||||||
|
TMDO_TEST_DB_HOST: 127.0.0.1
|
||||||
|
TMDO_TEST_DB_USER: dbo
|
||||||
|
TMDO_TEST_DB_PASS: ${{ secrets.TMDO_TEST_DB_PASS }}
|
||||||
|
TMDO_TEST_DB_NAME: wp_wpdo_test
|
||||||
|
run: php vendor/bin/phpunit --configuration phpunit-integration.xml --testdox
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [test-gate]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build ZIP
|
||||||
|
run: bash scripts/ci-package.sh
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GITEA_URL: ${{ vars.RELEASE_GITEA_URL }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ gitea.ref_name }}"
|
||||||
|
ZIP=$(ls dist/*.zip | head -1)
|
||||||
|
ZIP_NAME=$(basename "$ZIP")
|
||||||
|
MD5=$(md5sum "$ZIP" | awk '{print $1}')
|
||||||
|
|
||||||
|
# Create the release
|
||||||
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${VERSION}\",
|
||||||
|
\"name\": \"${VERSION}\",
|
||||||
|
\"body\": \"MD5: \`${MD5}\`\",
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
|
# Upload ZIP asset
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||||
|
--data-binary "@${ZIP}"
|
||||||
|
|
||||||
|
echo "Released ${VERSION} — asset: ${ZIP_NAME} (MD5: ${MD5})"
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master, develop ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Host-mode gitea runner (label `ubuntu-latest:host`) uses the host PHP
|
||||||
|
# toolchain (currently 8.3). `shivammathur/setup-php` cannot provision
|
||||||
|
# alternate PHP versions in host mode, so there is no 8.1/8.2/8.3 matrix.
|
||||||
|
# (Restore one if the runner ever moves to container mode.)
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: php vendor/bin/phpunit --configuration phpunit.xml --testdox
|
||||||
|
|
||||||
|
integration:
|
||||||
|
name: Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Host-mode runner: service containers are NOT started, so connect to the
|
||||||
|
# host MariaDB at 127.0.0.1:3306 directly. The password comes from the repo
|
||||||
|
# secret TMDO_TEST_DB_PASS (test DB `wp_wpdo_test` and user `dbo` already
|
||||||
|
# exist on the host). The bootstrap also accepts the legacy WPDO_TEST_DB_*
|
||||||
|
# names, so an existing secret of either name works.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
# Preflight: a DB outage or a rotated secret would otherwise surface as an
|
||||||
|
# opaque PHPUnit bootstrap error. Fail fast with an INFRA-vs-test
|
||||||
|
# distinction so a red integration job is diagnosable.
|
||||||
|
- name: DB preflight (host MariaDB reachable?)
|
||||||
|
env:
|
||||||
|
TMDO_TEST_DB_HOST: 127.0.0.1
|
||||||
|
TMDO_TEST_DB_USER: dbo
|
||||||
|
TMDO_TEST_DB_PASS: ${{ secrets.TMDO_TEST_DB_PASS }}
|
||||||
|
TMDO_TEST_DB_NAME: wp_wpdo_test
|
||||||
|
run: |
|
||||||
|
php -r '
|
||||||
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
$c = @mysqli_connect(getenv("TMDO_TEST_DB_HOST"), getenv("TMDO_TEST_DB_USER"), getenv("TMDO_TEST_DB_PASS"), getenv("TMDO_TEST_DB_NAME"), 3306);
|
||||||
|
if (!$c) {
|
||||||
|
fwrite(STDERR, "::error::Integration DB unreachable at " . getenv("TMDO_TEST_DB_HOST") . ":3306 db=" . getenv("TMDO_TEST_DB_NAME") . " user=" . getenv("TMDO_TEST_DB_USER") . " — host MariaDB down or TMDO_TEST_DB_PASS secret stale/rotated. This is an INFRA failure, not a test failure: " . mysqli_connect_error() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo "DB preflight OK: connected to " . getenv("TMDO_TEST_DB_NAME") . " on " . getenv("TMDO_TEST_DB_HOST") . "\n";
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
env:
|
||||||
|
TMDO_TEST_DB_HOST: 127.0.0.1
|
||||||
|
TMDO_TEST_DB_USER: dbo
|
||||||
|
TMDO_TEST_DB_PASS: ${{ secrets.TMDO_TEST_DB_PASS }}
|
||||||
|
TMDO_TEST_DB_NAME: wp_wpdo_test
|
||||||
|
run: php vendor/bin/phpunit --configuration phpunit-integration.xml --testdox
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: PHP Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check PHP syntax
|
||||||
|
run: |
|
||||||
|
find . -name "*.php" \
|
||||||
|
! -path "./vendor/*" \
|
||||||
|
! -path "./tests/*" \
|
||||||
|
-print0 | xargs -0 -n1 php -l
|
||||||
|
echo "PHP syntax OK"
|
||||||
|
|
||||||
|
phpcs:
|
||||||
|
name: PHPCS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
- name: Run PHPCS
|
||||||
|
run: vendor/bin/phpcs --standard=phpcs.xml --report=checkstyle -q .
|
||||||
|
|
||||||
|
phpstan:
|
||||||
|
name: PHPStan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# A never ran static analysis in CI; the baseline is committed, so new code
|
||||||
|
# is checked at level 6 while existing debt stays silent.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
- name: Run PHPStan (level 6, baselined)
|
||||||
|
run: vendor/bin/phpstan analyse --no-progress --error-format=github
|
||||||
@@ -14,3 +14,4 @@ Thumbs.db
|
|||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.claude/
|
.claude/
|
||||||
dist/
|
dist/
|
||||||
|
.phpcs-cache
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* PHPStan bootstrap stubs — plugin constants defined at runtime.
|
||||||
|
*
|
||||||
|
* PHPStan cannot see define()'d constants, so it reports "undefined constant"
|
||||||
|
* for every one of them. Stubbing them here keeps level 6 signal-to-noise high.
|
||||||
|
*
|
||||||
|
* TMDO_IS_SQLITE / TMDO_IS_MYSQL (and their WPDO_ aliases) are intentionally
|
||||||
|
* NOT stubbed to avoid literal-narrowing "always true/false" false positives
|
||||||
|
* on runtime-dynamic values.
|
||||||
|
*
|
||||||
|
* @package TMDO
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define( 'TMDO_VERSION', '0.0.0' );
|
||||||
|
define( 'TMDO_DB_VERSION', '0.0.0' );
|
||||||
|
define( 'TMDO_PATH', '/' );
|
||||||
|
define( 'TMDO_URL', 'https://example.test/wp-content/plugins/2meet-data-optimizer/' );
|
||||||
|
define( 'TMDO_FILE', '/2meet-data-optimizer.php' );
|
||||||
|
define( 'TMDO_TABLE_PREFIX', 'wpdo_' );
|
||||||
|
define( 'TMDO_CACHE_GROUP', 'wpdo' );
|
||||||
|
define( 'TMDO_MIN_PHP', '8.1' );
|
||||||
|
define( 'TMDO_MIN_WP', '6.0' );
|
||||||
|
|
||||||
|
// Back-compat aliases exposed by class-tmdo-back-compat.php.
|
||||||
|
define( 'WPDO_VERSION', '0.0.0' );
|
||||||
|
define( 'WPDO_DB_VERSION', '0.0.0' );
|
||||||
|
define( 'WPDO_PLUGIN_DIR', '/' );
|
||||||
|
define( 'WPDO_PLUGIN_URL', 'https://example.test/wp-content/plugins/2meet-data-optimizer/' );
|
||||||
|
define( 'WPDO_PLUGIN_FILE', '/2meet-data-optimizer.php' );
|
||||||
|
define( 'WPDO_TABLE_PREFIX', 'wpdo_' );
|
||||||
|
define( 'WPDO_CACHE_GROUP', 'wpdo' );
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: 2meet Data Optimizer
|
* Plugin Name: 2meet Data Optimizer
|
||||||
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
||||||
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
||||||
* Version: 0.1.0
|
* Version: 1.4.1
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Tested up to: 6.9.4
|
* Tested up to: 6.9.4
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
@@ -19,12 +19,14 @@
|
|||||||
* @package TMDO
|
* @package TMDO
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
define( 'TMDO_VERSION', '0.1.0' );
|
define( 'TMDO_VERSION', '1.4.1' );
|
||||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
@@ -50,12 +52,12 @@ define(
|
|||||||
define( 'TMDO_IS_MYSQL', ! TMDO_IS_SQLITE );
|
define( 'TMDO_IS_MYSQL', ! TMDO_IS_SQLITE );
|
||||||
|
|
||||||
// ── 跨外掛偵測訊號(dual-fire 對外保留 wp-data-optimizer 訊號)────────────
|
// ── 跨外掛偵測訊號(dual-fire 對外保留 wp-data-optimizer 訊號)────────────
|
||||||
const TWO_MEET_DATA_OPTIMIZER_VERSION = '0.1.0';
|
const TWO_MEET_DATA_OPTIMIZER_VERSION = '1.0.0';
|
||||||
const TWO_MEET_DATA_OPTIMIZER_FILE = __FILE__;
|
const TWO_MEET_DATA_OPTIMIZER_FILE = __FILE__;
|
||||||
const TWO_MEET_DATA_OPTIMIZER_DB_VERSION = '1.2.0';
|
const TWO_MEET_DATA_OPTIMIZER_DB_VERSION = '1.2.0';
|
||||||
// 向後相容:對外 sister plugins 仍以 WP_DATA_OPTIMIZER_VERSION 偵測。
|
// 向後相容:對外 sister plugins 仍以 WP_DATA_OPTIMIZER_VERSION 偵測。
|
||||||
if ( ! defined( 'WP_DATA_OPTIMIZER_VERSION' ) ) {
|
if ( ! defined( 'WP_DATA_OPTIMIZER_VERSION' ) ) {
|
||||||
define( 'WP_DATA_OPTIMIZER_VERSION', '0.1.0' );
|
define( 'WP_DATA_OPTIMIZER_VERSION', '1.0.0' );
|
||||||
}
|
}
|
||||||
if ( ! defined( 'WP_DATA_OPTIMIZER_FILE' ) ) {
|
if ( ! defined( 'WP_DATA_OPTIMIZER_FILE' ) ) {
|
||||||
define( 'WP_DATA_OPTIMIZER_FILE', __FILE__ );
|
define( 'WP_DATA_OPTIMIZER_FILE', __FILE__ );
|
||||||
@@ -86,7 +88,11 @@ if ( version_compare( PHP_VERSION, TMDO_MIN_PHP, '<' ) ) {
|
|||||||
add_action(
|
add_action(
|
||||||
'admin_notices',
|
'admin_notices',
|
||||||
static function () {
|
static function () {
|
||||||
if ( defined( 'WPDO_VERSION' ) && WPDO_VERSION !== TMDO_VERSION ) {
|
// Deliberately not a WPDO_VERSION comparison: this plugin defines that
|
||||||
|
// constant itself (includes/class-tmdo-back-compat.php), so the two
|
||||||
|
// would always match and the notice could never fire.
|
||||||
|
$tmdo_legacy = 'wp-data-optimizer/wp-data-optimizer.php';
|
||||||
|
if ( in_array( $tmdo_legacy, (array) get_option( 'active_plugins', array() ), true ) ) {
|
||||||
echo '<div class="notice notice-error"><p>';
|
echo '<div class="notice notice-error"><p>';
|
||||||
esc_html_e( '⚠ 偵測到舊版 wp-data-optimizer 已啟用。請停用舊外掛以避免 hook 雙觸發。', '2meet-data-optimizer' );
|
esc_html_e( '⚠ 偵測到舊版 wp-data-optimizer 已啟用。請停用舊外掛以避免 hook 雙觸發。', '2meet-data-optimizer' );
|
||||||
echo '</p></div>';
|
echo '</p></div>';
|
||||||
@@ -228,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
|
|||||||
// ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
|
// ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
|
||||||
@@ -252,14 +259,19 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
|
|||||||
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
|
||||||
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
|
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
|
||||||
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
||||||
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
||||||
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
|
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
|
||||||
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
|
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
|
||||||
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
|
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
|
||||||
|
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
||||||
@@ -279,6 +291,12 @@ if ( is_admin() ) {
|
|||||||
TMDO_Setup_Wizard::register();
|
TMDO_Setup_Wizard::register();
|
||||||
require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php';
|
require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php';
|
||||||
TMDO_Dashboard_Widget::register();
|
TMDO_Dashboard_Widget::register();
|
||||||
|
require_once TMDO_PATH . 'admin/class-tmdo-deprecated-addons-notice.php';
|
||||||
|
TMDO_Deprecated_Addons_Notice::register();
|
||||||
|
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
|
||||||
|
TMDO_Missing_Addons_Notice::register();
|
||||||
|
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
|
||||||
|
TMDO_Promotion_Guard_Notice::register();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WP-CLI ─────────────────────────────────────────────────────────────────
|
// ── WP-CLI ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+451
@@ -7,6 +7,457 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.4.1] — 2026-09-28 — 2.4 驗收修正
|
||||||
|
|
||||||
|
**性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **meta_key 的排序不再被清掉**:查詢帶 `meta_key`,且 orderby 在多個排序(陣列或
|
||||||
|
`'meta_value date'`)中仍有 `meta_value`、`meta_value_num` 或該 key 名稱時,1.4.0 會把
|
||||||
|
meta_key 當 EXISTS 路由並清空,WordPress 因此丟掉價格排序。現在只在 orderby 完全沒有引用它時
|
||||||
|
才路由;`meta_compare_key` 不是 `=`(例如 LIKE 會比對到其他 key)時維持原生;排序形狀的
|
||||||
|
group 只有在 orderby 沒有引用它、`meta_value(_num)` 或它的 key 時才當成未使用而略過(沒有
|
||||||
|
meta_key 時它是 WordPress 的主要 clause)。(PR-130)
|
||||||
|
- **ORDER BY 只改寫單獨的 `posts.ID`**:flat-first 與 copy route 共用 `ties_on_post_id()`;
|
||||||
|
外掛加上的 `FIELD(wp_posts.ID, …)`、`(wp_posts.ID IN (…))` 等運算式保持原樣。1.4.0 會在
|
||||||
|
運算式內插入方向,SQL 語法錯誤、頁面沒有結果。正則失敗(PCRE 回溯或 JIT 上限)時回傳原
|
||||||
|
SQL,不再變成空字串。(PR-131)
|
||||||
|
- 呼叫端以 `new WP_Query( array( … ) )` 預設的 router 內部 query var(`wpdo_post_clauses`、
|
||||||
|
`wpdo_post_sort`、`wpdo_post_copy_route`、`wpdo_post_count_sql`)一律清除;copy route 的
|
||||||
|
排序方向改用白名單。無法從網址觸發。(PR-132)
|
||||||
|
- 新文章建立的空 flat 列一併寫入 `post_status` / `post_date` 副本(`wp_insert_post` 在
|
||||||
|
`transition_post_status` 之後才執行,原本留下 NULL,會讓整個 group 關閉 copy route);
|
||||||
|
`wp tmdo post-status-sync` 完成後遞增路由計數版本(dry run 不遞增)。(PR-133)
|
||||||
|
|
||||||
|
### 已知、未修改
|
||||||
|
|
||||||
|
- 1.4.0 的 flat-first 排序同值時依 ID 與排序同方向(原生為 ID DESC),集合與總數相同(已接受)。
|
||||||
|
- 若日後有 group 註冊名為 `post_status` / `post_date` 的欄位,遷移引擎寫副本時 `$data` 與
|
||||||
|
`$formats` 會錯位;目前沒有這樣的 group。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4)
|
||||||
|
|
||||||
|
**性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(見「升級後」)。
|
||||||
|
搭配 HivePress AddOn 0.5.0。來源:dev36 第 3 階壓測(130 萬 listing、121 萬 product、6.2 萬 vendor)。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **flat-first 排序讀 flat 的 post_id 作 tie-break**:`posts.ID` 的 tie-break(ours 與外掛追加的)
|
||||||
|
改讀相等的 `flat.post_id`,方向與排序一致且只出現一次;group 宣告的索引可以 adapter 的
|
||||||
|
entity id 欄結尾,`(欄, post_id)` 索引可直接依序讀取、不需 filesort(主查詢 850 ms → 2 ms)。
|
||||||
|
同值的順序改為依 ID 與排序同方向(原生為 ID DESC);集合、總數不變,翻頁穩定。(PR-118)
|
||||||
|
- **沒有 value 的 meta 條件以 EXISTS 路由**:clause 沒有 `value` 時(HivePress 的精選 listing
|
||||||
|
查詢)視同 EXISTS;只帶 `meta_key`、沒有 `meta_value` 也不以 meta_value 排序的查詢亦同;
|
||||||
|
沒有被 orderby 引用的排序形狀 group 直接移除。HivePress 每個搜尋頁都會跑的精選 listing
|
||||||
|
查詢(`orderby rand`)改走扁平表:2.26 s → 0.2 s。(PR-122)
|
||||||
|
- **status/date copy route 在 LIMIT 停下**:只有扁平表一個 JOIN 時去掉 WordPress 的
|
||||||
|
`GROUP BY posts.ID`,`posts.ID` tie-break 改讀 `flat.post_id`(各自保留方向、重複合併);副本索引改為
|
||||||
|
`idx_post_status_date_id (post_status, post_date, post_id)`,取代兩欄的 `idx_post_status_date`。
|
||||||
|
dev36 價格區間(130 萬中 23.5 萬筆符合)主查詢 1.7 s → 3 ms,頁面 2.7 s → 0.34 s。(PR-128)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 遷移引擎(backfill、`drift-check --fix`)以 REPLACE 重建 post 列時保留 `post_status` /
|
||||||
|
`post_date` 副本;原本重建後為 NULL,一筆 NULL 就讓整個 group 關閉 copy route。(PR-127)
|
||||||
|
- 遷移引擎重建 post 列後遞增路由計數版本(每次呼叫/每批一次,dry run 不遞增),
|
||||||
|
修正後的 found rows 不再沿用舊的快取計數。(PR-129)
|
||||||
|
|
||||||
|
### Dev
|
||||||
|
|
||||||
|
- live 測試工具(`mut.php`、`qbench.php`)不再清空物件快取(Redis Object Cache 以伺服器端腳本
|
||||||
|
掃描,會卡住共用 Redis 5–20 s)。(PR-123)
|
||||||
|
|
||||||
|
### 升級後
|
||||||
|
|
||||||
|
- 只需替換索引:對 `tmdo_post_status_columns` 列出的每個 group 表執行
|
||||||
|
`ALTER TABLE <表> ADD INDEX idx_post_status_date_id (post_status, post_date, post_id), DROP INDEX idx_post_status_date`
|
||||||
|
(空表瞬間完成,dev36 130 萬列約 5 s)。未替換前 copy route 照常運作,只是維持 1.3.0 的速度。
|
||||||
|
- **不要為了換索引執行 `wp tmdo post-status-sync`**:它除了替換索引,還會替**所有**尚未有副本的
|
||||||
|
post group 加上 `post_status` / `post_date` 欄位並逐列回填(大表 ALTER + 全表 UPDATE;dev36
|
||||||
|
130 萬列約 75 s,上千萬列的站更久)。只有要讓更多 group 啟用 copy route 時才執行,並先在
|
||||||
|
離峰時段以 `--dry-run` 確認列數。
|
||||||
|
- 效能數字(dev36 前台,routed):價格區間 0.34 s、價格排序 0.37 s、時薪排序 0.35 s、
|
||||||
|
評分排序 0.51 s、關鍵字 + 價格 0.59 s(原生 6–43 s)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3)
|
||||||
|
|
||||||
|
**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。
|
||||||
|
搭配 HivePress AddOn 0.4.0、WooCommerce AddOn 0.3.0。來源:dev36 規模壓測(90 萬 listing)
|
||||||
|
與系列稽核(2026-09-27)。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **group 宣告的複合索引只在 WP-CLI 或 WP-Cron 加上**:網頁請求(包括 auth_redirect 之前的
|
||||||
|
`/wp-admin/` 與 `admin-post.php`)改排單次事件 `tmdo_ensure_group_indexes`;以 MySQL
|
||||||
|
`GET_LOCK` 保證同一時間只有一個程序執行 ALTER;每個 ALTER 都成功才記錄已套用,失敗記
|
||||||
|
`schema.index_failed` 並保留在 `doctor` 的 `[INDEX]` 待辦;新表在 CREATE TABLE 時就帶上
|
||||||
|
索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a)
|
||||||
|
- **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單)
|
||||||
|
改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果
|
||||||
|
精確,快取不再被打散。(PR-091)快取 key 另外去掉 wpdb 替 `%` 產生的 placeholder escape
|
||||||
|
(每個請求不同,關鍵字搜尋原本永遠不命中,也就是 2.2 驗收「關鍵字 + 篩選 routed 較慢」的
|
||||||
|
原因)(PR-116);計數改跟隨自有的 `tmdo_posts` last_changed(文章儲存/刪除、經 Hook Bus
|
||||||
|
的扁平寫入)與 terms 的 last_changed,不再跟隨 posts 的 last_changed——任何 post meta 寫入
|
||||||
|
都會推動它,dev36 的 Blocksy 佈景在爬蟲瀏覽每篇 listing 時都寫一筆 meta,快取幾秒就失效
|
||||||
|
(PR-117)。dev36 關鍵字 + 價格 routed 9.8 s → 首次後約 5.5 s(原生 5.0–6.2 s,剩餘為
|
||||||
|
WordPress 核心的 LIKE 全表掃描)。
|
||||||
|
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
|
||||||
|
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
|
||||||
|
- `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`);
|
||||||
|
`notice()` 一律寫入,模式切換(`bridge_mode_changed`)與快照刪除改用它,保留操作紀錄;
|
||||||
|
`debug()`、`warning()`、`error()` 不變。(PR-095、PR-113)
|
||||||
|
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
|
||||||
|
`show_in_rest`。(PR-096)
|
||||||
|
- **既有扁平表的 schema 升級只在 WP-CLI 或 WP-Cron 執行**:網頁請求遇到定義變更時記入
|
||||||
|
`tmdo_schema_deferred` 並排 `tmdo_ensure_group_indexes`;升級前該 group 的 key 走原生
|
||||||
|
(Hook Bus 不攔、router 不路由),升級後排 `tmdo_drift_resync` 補回期間跳過的扁平寫入。
|
||||||
|
新表照常立即建立;aeav_only 仍立即升級(扁平表是唯一副本);`doctor` 顯示 `[SCHEMA]`。(PR-107)
|
||||||
|
驗收審查後補強:既有表的升級與索引共用同一把 `GET_LOCK`(匿名 `/wp-cron.php` 也會通過
|
||||||
|
`ddl_allowed()`),取得鎖後重新比對 hash;延後的 group 要等 drift fix 完成才改回扁平表;
|
||||||
|
寫入扁平表的 entity 新建 group 表時同樣先走原生,由 drift fix 從原生填滿(新宣告的
|
||||||
|
HivePress 屬性不會把搜尋導到空表);定義還原時解除延後;cron 事件本身也會升級延後的表
|
||||||
|
(`ALTERNATE_WP_CRON`);dbDelta 之後每個欄位都存在才記錄定義(否則 `schema.upgrade_failed`
|
||||||
|
並重試);延後期間 `direct_read()` 回傳原生值。(PR-111、PR-114)
|
||||||
|
- 讀寫分離(HyperDB、LudicrousDB)時,取索引鎖前先 `send_reads_to_masters()`,
|
||||||
|
`GET_LOCK` 才會在 primary 上。(PR-108)
|
||||||
|
- Cold 快取的「清除」改為遞增版本號(`wpdo_cold_cache_version`),不再呼叫
|
||||||
|
`wp_cache_flush_group()`(Redis Object Cache 以伺服器端腳本掃描整個 group,大 group 會卡住
|
||||||
|
Redis);舊 key 依 TTL 過期。(PR-110)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
|
||||||
|
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
|
||||||
|
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
|
||||||
|
- 扁平表已被刪除的 group 不再留在 `tmdo_post_status_columns`:每次文章狀態變更都 UPDATE
|
||||||
|
不存在的表並印出 DB error。(PR-106)
|
||||||
|
|
||||||
|
### Security(已驗證,無程式變更)
|
||||||
|
|
||||||
|
- 快照目錄在 nginx 下的外洩風險(稽核 Medium):快照檔名含 64-bit 隨機值、遷移備份檔名含
|
||||||
|
12 字元隨機 job id,目錄有 index.php 擋列表;`doctor` 的 HTTP 探測與 nginx 後台提示已存在。
|
||||||
|
dev31 探測全為 403。不採用「目錄名加隨機後綴」:各站 nginx deny 規則比對 `wpdo-backups/`,
|
||||||
|
改名反而讓新快照脫離保護。(PR-093)
|
||||||
|
|
||||||
|
### 升級後
|
||||||
|
|
||||||
|
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
|
||||||
|
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
|
||||||
|
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
|
||||||
|
- 已知限制:cold 快取版本遞增的瞬間,在遞增前就開始的長請求寫入後只會清掉舊版 key,新版 key
|
||||||
|
最多保留舊值到 TTL(預設 1 小時);cold module 預設 idle。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2)
|
||||||
|
|
||||||
|
**性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配
|
||||||
|
HivePress AddOn 0.3.0、WooCommerce AddOn 0.2.0。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`wp tmdo drift-check <entity> [--group] [--sample=<n>] [--format]`**(`TMDO_Drift_Check`):
|
||||||
|
逐 key 比對扁平表與原生 meta 的值(第一筆原生 row → 安全 unserialize →
|
||||||
|
`Type_Caster::to_db`),回報 miss / diff / extra。序列化 vs JSON、`50` vs `50.000000`
|
||||||
|
不算漂移;超過 255 字元存 NULL 的 text 不算 miss。結果寫入 `wpdo_drift_status`。(PR-070)
|
||||||
|
- **`drift-check --fix`**:刪除已刪實體與非本 group post type 的扁平列,其餘漂移列經
|
||||||
|
`TMDO_Entity_Migration_Engine::migrate_entity()` 從原生重建,清除列快取後再檢查一次。
|
||||||
|
不寫原生;aeav_only(扁平才是真實資料)一律拒絕。(PR-071、PR-073b)
|
||||||
|
- **每日抽樣**:`wpdo_daily_health_check` 對 dual_write 以上、非 aeav_only 的 entity 每個
|
||||||
|
group 抽 500 筆,結果取代既有紀錄。(PR-073)
|
||||||
|
- **Hook Bus 關閉期間的寫入補回**:entity 從 disabled 進入 dual_write / shadow_read,或
|
||||||
|
Hook Bus flag 由關轉開時,60 秒後以單次 WP-Cron `tmdo_drift_resync` 執行全量
|
||||||
|
`drift-check --fix`(不做 `post_modified_gmt` 增量:WooCommerce 直接 SQL 改庫存不會更新
|
||||||
|
post_modified)。停用 / 解除安裝時清除排程。(PR-074)
|
||||||
|
- **`wp tmdo bridge-mode <entity> <mode>`**:經 `TMDO_Mode_Manager::set()`,受
|
||||||
|
Promotion Guard 約束;被拒時 exit 1、模式不變。(PR-069)
|
||||||
|
- **`wp tmdo member-backfill --all`**:回填所有已註冊的 user group。(PR-068)
|
||||||
|
- **`wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=<n>]`**:
|
||||||
|
刪除 HivePress AddOn 0.3.0 之前寫進 wp_options 的 `_transient_wpdo_hp_pm_*`(含 timeout),
|
||||||
|
分批刪除(預設 5000),AddOn 未啟用也可執行。dev36 有 2,314,750 列。(PR-080)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Post query router 避開漂移中的 group**:上次 drift-check 有 miss / diff / extra 的
|
||||||
|
group 不路由,整個查詢維持原生(全有或全無);從未檢查的 group 照常路由;aeav_only
|
||||||
|
不受影響。(PR-072、PR-073b)
|
||||||
|
- **純排序搜尋 flat-first**:沒有路由篩選的 HivePress `_sort` 搜尋,在該 post type 每篇都有
|
||||||
|
扁平列時改由扁平表驅動(`STRAIGHT_JOIN` + INNER JOIN);Router 在 `wp_insert_post`
|
||||||
|
替新文章建空列,覆蓋率以 anti-join 檢查並快取 15 分鐘(隨 post 模式變更失效)。
|
||||||
|
flat-first 且扁平表是唯一 JOIN 時拿掉 WordPress 為 tax query 加的 `GROUP BY posts.ID`。
|
||||||
|
dev36 `rating__desc`:原生約 3.3 s、1.1.0 約 1.5–1.9 s、現在約 0.51–0.85 s。(PR-079)
|
||||||
|
- **Auto-promoter** 的第三個條件改為 `TMDO_Drift_Check::clean_since()`:2 天內每個 group 都
|
||||||
|
檢查過(抽樣或全量)且沒有漂移;沒有紀錄就不升級。(PR-075)
|
||||||
|
- **shadow_read diff 節流**:每個 entity type + key 每小時最多記 20 列(疊加既有的
|
||||||
|
每物件 5 分鐘視窗)。(PR-075)
|
||||||
|
- **回填 group 改讀 registry**:admin「全部回填」、`post-migrate-group`、`member-backfill`、
|
||||||
|
`member-audit` 取所有已註冊 group(排除已還原的孤兒 group),不再是寫死清單;含 json
|
||||||
|
欄位的 group(attachment、nav_menu_item)逐列經 migration engine。(PR-067、PR-068)
|
||||||
|
- **不再註冊 `post/wc_product`**:WooCommerce 直接讀 product meta、以 SQL 寫庫存,扁平副本
|
||||||
|
必然漂移(ADR-004 決策 2)。既有的 `wp_wpdo_post_wc_product` 表保留,以
|
||||||
|
`wp tmdo bridge-restore-native post --group=wc_product --retire` 封存;解除安裝仍會刪除。(PR-077)
|
||||||
|
- `member-sso-sync`、`member-force-logout` 標示為 deprecated(`sso` group 已不註冊)。(PR-069)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Entity 回填替沒有原生 row 的 key 寫入 DDL 預設值(`session_count` / `account_flags` = 0),
|
||||||
|
每個 user 都被捏造出 0;現在寫 NULL,required NOT NULL 欄位保留預設值。(PR-066)
|
||||||
|
- `member-cutover` 在轉換被拒時 fatal(把 `WP_Error` 當陣列)。(PR-068)
|
||||||
|
- `doctor` 依 registry 檢查 entity 表(不再因從未註冊的 `sso` 表而每站失敗),並顯示
|
||||||
|
Promotion Guard 違規與 drift 狀態;`term-comment-diagnose` 依各 filter 的 `is_enabled()`
|
||||||
|
顯示狀態;`bridge-status` 只計有值的扁平列、eav_ids 限定 group 的 post type
|
||||||
|
(dev36 wp_core 15306% → 100%)。(PR-076)
|
||||||
|
- 2.1 稽核:公開 REST route 註明公開理由;admin 23 處三元 echo 依情境 `esc_attr` /
|
||||||
|
`esc_html`;migration engine 與 health cron 的例外改記 `TMDO_Logger`。(PR-078)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- ADR-004「設計(Release 2.2)」、`CONTEXT.md` Entity Bridge 的漂移段落。(PR-086)
|
||||||
|
|
||||||
|
### 升級後
|
||||||
|
|
||||||
|
- HivePress 站:部署 AddOn 0.3.0 後執行 `wp tmdo cleanup-hp-transients --options --confirm`。
|
||||||
|
- 曾註冊 wc_product 的站:`wp tmdo bridge-restore-native post --group=wc_product --retire`。
|
||||||
|
- 已進入 dual_write 以上的站:`wp tmdo drift-check <entity>` 建立第一筆紀錄(未檢查的
|
||||||
|
group 照常路由)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
|
||||||
|
|
||||||
|
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **公開的 `GET /wpdo/v1/listings`、`/wpdo/v1/listings/{id}` 會把所有已註冊的 hot / cold
|
||||||
|
欄位回傳給未登入者**:`TMDO_Schema_Registry::register()` 的 `show_in_rest` 預設為 true,
|
||||||
|
而 addon 都沒有設定它。dev36 實測可取得 infocards 的 `tmeetic_ig_access_token_enc`、
|
||||||
|
`tmeetic_ig_token_expiry`、`tmeetic_ig_user_id`,以及 WooCommerce 的 `_purchase_note`。
|
||||||
|
- `show_in_rest` 預設改為 **false**:要公開的欄位必須明確宣告。
|
||||||
|
- 受保護的 meta key(底線開頭)與名稱像憑證的 key(token、secret、password、api key、
|
||||||
|
private key、credential、salt、nonce)即使宣告公開也一律不輸出
|
||||||
|
(`TMDO_Schema_Registry::rest_exposable()`)。
|
||||||
|
- Zone A 列表路徑在沒有任何公開欄位時不再回傳整列(fail-closed);篩選參數與 orderby
|
||||||
|
只接受公開欄位,避免藉由篩選 / 排序推測隱藏值。
|
||||||
|
- **相容性**:依賴預設公開的欄位,現在需在註冊時加上 `'show_in_rest' => true`,或使用
|
||||||
|
既有的 `tmdo_rest_listing_visible_fields` filter。
|
||||||
|
- 測試:`RestApiTest` 新增 3 個;live suite `rest`(未登入 GET 不得出現私有 / 憑證類 key)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1)
|
||||||
|
|
||||||
|
**性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見
|
||||||
|
`docs/adr-004-search-index-mode-for-hp-wc.md`;搭配 HivePress AddOn 0.2.0。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Promotion Guard**(`TMDO_Promotion_Guard`):HivePress 啟用時 post / user / term /
|
||||||
|
comment、WooCommerce 啟用時 post / user 不得升到 aeav_only 或執行 cleanup
|
||||||
|
(`WP_Error( 'promotion_guarded' )`,filter `tmdo_promotion_guard_allow`);已在
|
||||||
|
aeav_only 的站只顯示後台 notice。(PR-053)
|
||||||
|
- **`wp tmdo repair-array-literals [--entity] [--confirm]`**:把扁平表中的 `"Array"`
|
||||||
|
從原生修回;原生也被污染的列出清單。(PR-055)
|
||||||
|
- **`bridge-restore-native --overwrite / --only-missing`**:預設依模式(aeav_only 覆寫、
|
||||||
|
其他只補缺);報告新增 `missing` / `differs` / `same` / `default_skipped`。(PR-058)
|
||||||
|
- **live 測試層** `tests/live/`(在真實站台以 `wp eval-file` 執行):suites
|
||||||
|
`shadow_write`、`serialized`、`resync`、`router`、`zero_side_effect`,以及 qbench /
|
||||||
|
fe.sh 等量測工具。(PR-052)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Hook Bus 寫入順序**:dual_write / shadow_read 由 WordPress 寫原生,扁平表在
|
||||||
|
`added_ / updated_ / deleted_{type}_meta` 從原生重新同步(含 `delete_all`、
|
||||||
|
`update_metadata_by_mid` 改 key)。(PR-057)
|
||||||
|
- **shadow_read** 讀原生,並抽樣比對扁平值(`tmdo_shadow_compare_sample_rate`)。(PR-056)
|
||||||
|
- **Post query router**:
|
||||||
|
- 全有或全無:OR、多 post type、含已註冊 key 的巢狀 group、type 語意與欄位不符的
|
||||||
|
clause 讓整個查詢維持原生;LIKE 包 `%esc_like%`;`pre_get_posts` priority 1000。(PR-060)
|
||||||
|
- 每個 group 各自 JOIN、不加 GROUP BY;NOT EXISTS 不走狀態 / 日期副本路由。(PR-061)
|
||||||
|
- meta orderby:HivePress `_sort`(NOT EXISTS + EXISTS)形狀、具名 clause、
|
||||||
|
`meta_key` + `meta_value_num`。(PR-062)
|
||||||
|
- routed 搜尋維持 WordPress 的 split query(ID-first)。(PR-062b)
|
||||||
|
- 依扁平欄位排序且有篩選的搜尋改由扁平表驅動(`STRAIGHT_JOIN`,posts.ID tie-break 改讀等值的
|
||||||
|
`post_id`),不再以 temporary table 排序;filter `tmdo_router_flat_first` 可關閉。(PR-062c)
|
||||||
|
- `composer phpcs` 改用 `phpcs.xml`(與 CI 相同)。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- shadow_read 下 `update_*_meta()` 回傳 false、原生沒有寫入(資料遺失)。(PR-056)
|
||||||
|
- 陣列 / 物件存進 text 欄位變成 `"Array"`;text 超過 255 字元改存 NULL 並記錄
|
||||||
|
`text_overflow`。(PR-055)
|
||||||
|
- `bridge-restore-native` 覆蓋較新的原生值(WC 庫存)、替每個 user 捏造預設值列。(PR-058)
|
||||||
|
- Cold / Hot 表不存在時,`save_post`、`loop_start`、刪除文章仍查詢它們(DB error)。(PR-054、PR-054b)
|
||||||
|
- 刪除 post / user / term / comment 後,扁平表留下全 NULL 的孤兒列(PR-057 的 NULL 同步會重建已刪的列)。(PR-057b)
|
||||||
|
- Router:Q05(wp_core 與另一 group 的 key 並用)`Unknown column`;OR / 多 post type /
|
||||||
|
無 type 數值比較 / 具名 orderby 的結果與原生不同。(PR-060〜062)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- ADR-004、`CONTEXT.md` Entity Bridge(寫入順序、shadow_read、搜尋索引模式、Router 規則)、
|
||||||
|
`docs/BENCHMARK-SCALE.md` 前台與寫入鏈實測。(PR-064)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
|
||||||
|
|
||||||
|
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`docs/WAF-COMPATIBILITY.md`** — NinjaFirewall (WP Edition) 4.9 相容性評估
|
||||||
|
|
||||||
|
結論:兩者可共存,**不需要開發 AddOn**(NinjaFirewall 全 codebase 零個
|
||||||
|
`apply_filters('nfw_*')` / `do_action('nfw_*')`,官方相容手段全在部署層)。
|
||||||
|
文件涵蓋 WP WAF 與 Full WAF 的模式差異、symlink 多租戶部署的必要設定、
|
||||||
|
三條開發約束,以及實測風險矩陣。
|
||||||
|
|
||||||
|
- **`TMDO_Options_Manager::PROTECTED_OPTIONS`** 常數與註冊守衛
|
||||||
|
(`modules/options/class-tmdo-options-manager.php`)
|
||||||
|
|
||||||
|
`register_settings_group()` 會以 `pre_update_option_{key}` 回傳 `$old_value`,
|
||||||
|
讓選項不再落地 `wp_options`、改存專屬設定表。但 NinjaFirewall 的 Full WAF 走
|
||||||
|
`auto_prepend_file`,在 WordPress 載入前就以**原生 mysqli 直查 `wp_options`**
|
||||||
|
取 `nfw_options` / `nfw_rules`。一旦這些鍵被重導向,WAF 會讀不到設定而
|
||||||
|
**靜默停止防護 —— 不報錯、不寫 log**。
|
||||||
|
|
||||||
|
現以 `PROTECTED_OPTIONS`(`nfw_options` / `nfw_rules` / `nfw_checked`)在註冊
|
||||||
|
階段擋下,並發出 `_doing_it_wrong()`。守衛置於方法開頭,全部鍵都被擋時提前
|
||||||
|
返回,不再建立空的設定表。
|
||||||
|
|
||||||
|
註:autoload 最佳化不受影響 —— `optimize_autoload()` 只改 `autoload` 欄位、
|
||||||
|
不刪列,而該處的 `SELECT *` 不看 autoload。
|
||||||
|
|
||||||
|
- **`tests/unit/OptionsManagerProtectedTest.php`** — 4 tests / 10 assertions
|
||||||
|
|
||||||
|
鎖住「受保護鍵絕不會被掛上 `pre_option_*` / `pre_update_option_*` 攔截」這個
|
||||||
|
核心保證,並驗證全數受保護時不對資料庫發出查詢。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Migration Wizard 輪詢間隔 500ms → 2s**(`admin/assets/wpdo-migration-wizard.js`)
|
||||||
|
|
||||||
|
原本 2 req/s 打同一個 REST endpoint,容易觸發 WAF 的 rate-limit 與 bot 偵測。
|
||||||
|
改為 2 秒,與四個 stress-test 面板既有的輪詢節奏一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.1] — 2026-08-08 — Schema Registry 冪等性修正(cold zone 欄位重複累積)
|
||||||
|
|
||||||
|
**性質**:核心 bug 修復。無 schema 變更,無 API 變更,無破壞性變更。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`TMDO_Schema_Registry::register()` 的 cold zone 分支會無限累積重複項**
|
||||||
|
(`includes/class-tmdo-schema-registry.php`)
|
||||||
|
|
||||||
|
三個 zone 索引的寫入方式並不一致:
|
||||||
|
- `hot` → `$this->hot_columns[$post_type][$column] = ...`(以 column 為 key,天生冪等)
|
||||||
|
- `warm` → `$this->warm_fields[$meta_key] = ...`(以 meta_key 為 key,天生冪等)
|
||||||
|
- `cold` → `$this->cold_fields[$post_type][] = ...`(**append,無去重**)
|
||||||
|
|
||||||
|
而 `wpdo_register_fields` 本來就會被觸發多次——核心自身在 `plugins_loaded:4` 觸發,
|
||||||
|
主檔另有 late-bind safety net 於 `plugins_loaded:30` 再次觸發,好讓在 `:6` 之後才
|
||||||
|
bootstrap 的 AddOn 也能趕上;再加上主外掛與其 AddOn 可能同時註冊同一批欄位。
|
||||||
|
|
||||||
|
**實測影響**:某站台 `cold_fields['hp_vendor']` 累積到 **35 筆但只有 9 個相異值**,
|
||||||
|
每個 key 重複 4 次。修正後為 9 筆、零重複。
|
||||||
|
|
||||||
|
修法:cold 分支加入 `in_array( ..., true )` 去重,使 `register()` 對同一
|
||||||
|
`(post_type, meta_key)` 成為冪等操作,與 hot / warm 的既有行為一致。
|
||||||
|
|
||||||
|
### 驗證
|
||||||
|
|
||||||
|
- 核心單元測試 **587 tests / 1156 assertions** 全綠
|
||||||
|
- `wp wpdo doctor` 於兩個站台皆 0 FAIL
|
||||||
|
- Version bump 1.0.0 → 1.0.1(2 點一致:header / `TMDO_VERSION`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-07-31 — 取代 wp-data-optimizer v3.4.6
|
||||||
|
|
||||||
|
本版把 `wp-data-optimizer` v3.0.1–v3.4.6(92 個 commit)全數 backport 進來,
|
||||||
|
`wp-data-optimizer` 自此退休:兩個站台皆已停用它,本機目錄改名為
|
||||||
|
`wp-data-optimizer.retired`,其 git 歷史保留在 gitea。
|
||||||
|
|
||||||
|
### 🔒 Security / 正確性
|
||||||
|
|
||||||
|
- **7 處 `Logger::error()` 參數不足**(array 傳給 string `$hook`)修正 —— 4 個 stress
|
||||||
|
tester、auto-promoter、conflict-detector、hook-bus,皆為 TypeError fatal
|
||||||
|
- **`Logger::trace_id()` 補回**:`Audit_Logger::write_row()` 一直呼叫它,但提煉時漏了;
|
||||||
|
`Audit_Logger::init()` 從未註冊所以隱藏至今 —— 實機驗證才暴露
|
||||||
|
- REST 4 個公開端點加上 `show_in_rest` 欄位 allowlist(CVSS 5.3)
|
||||||
|
- 13 個破壞性 admin 動作由 GET 改 POST + nonce
|
||||||
|
- DDL 型別白名單(19 型別)、CLI 表名守衛
|
||||||
|
- 整欄清空改 `wpdo_allow_mass_column_clear` opt-in(原本 >500 列才擋)
|
||||||
|
- audit 表補 `group_name` / `action` 欄並註冊 `Audit_Logger::init()`
|
||||||
|
- `known_login_ips` 註冊(spoke-sso 實際寫入的欄位,先前落 `wp_usermeta`)
|
||||||
|
|
||||||
|
### ⚡ 並發
|
||||||
|
|
||||||
|
- warm 表 `UNIQUE KEY ui_post_meta`(缺它 `ON DUPLICATE KEY UPDATE` 不生效)
|
||||||
|
- Zone_Cold 改 `JSON_MERGE_PATCH` / `JSON_REMOVE`、Zone_Warm 原子 `increment()`、
|
||||||
|
hook-bus `write_to_flat` 改 upsert
|
||||||
|
|
||||||
|
### 🏗 架構
|
||||||
|
|
||||||
|
- 回填 `TMDO_Zone_Router` + `TMDO_Routing_Predicate`(Sync_Bridge 400→269 行)
|
||||||
|
- 回填 Migration Phase Strategy:interface + base + 11 個 phase 類別,
|
||||||
|
orchestrator 1107→640 行,公開介面不變
|
||||||
|
- 核心新增 `TMDO_Standard_Post_Interceptor`,HP AddOn 4 個 interceptor 573→344 行
|
||||||
|
|
||||||
|
### 🔌 相容性
|
||||||
|
|
||||||
|
- hook 橋改**雙向**:核心一律 `do_action('wpdo_*')`,原本單向轉發使所有
|
||||||
|
`tmdo_*` 契約收不到事件
|
||||||
|
- 57 個 AddOn 類別補 `WPDO_*` alias(核心 alias 表在 `plugins_loaded:4` 執行,
|
||||||
|
涵蓋不到 `:6` 才載入的 AddOn)
|
||||||
|
- 38 個 CLI 子指令補 `wp tmdo` 命名空間
|
||||||
|
- 選單 slug 統一回 `wp-data-optimizer`(B 內部 10+ 處連結本來就指向它)
|
||||||
|
- HP adapter 介面改為 `TMDO_ extends WPDO_`,讓 `instanceof WPDO_HivePress_Adapter` 成立
|
||||||
|
|
||||||
|
### 🖥 Admin
|
||||||
|
|
||||||
|
- Settings 七個分區各加「儲存此區塊」AJAX 存檔(`wp_ajax_wpdo_save_settings_section`
|
||||||
|
+ 7 個 `save_section_*()` + `admin/assets/wpdo-settings.js`),整頁送出仍可用
|
||||||
|
- inline `style=` 由 348 處降到 9 處(剩下的全是 `--wpdo-*` CSS custom property
|
||||||
|
載體);`wpdo-admin.css` 543 → 912 行,補 149 個 class
|
||||||
|
|
||||||
|
### 🚧 邊界
|
||||||
|
|
||||||
|
- 核心不再無守衛呼叫 AddOn 類別。`TMDO_Listing_Stats`(HivePress AddOn)先前
|
||||||
|
在 admin Dashboard、兩個公開 REST 端點、`wp tmdo cleanup --archive-expired`
|
||||||
|
被直呼,沒裝該 AddOn 的站台會 fatal;`TMDO_HPCT_Import` 在 admin 與
|
||||||
|
`wp tmdo import-hpct` 同樣缺守衛
|
||||||
|
- `TMDO_Zone_Warm::VIEW_KEY` / `VIEW_TTL` 由核心持有(值與 AddOn 常數相同的
|
||||||
|
`'wpdo_views'`,指向同一批列,無資料遷移)
|
||||||
|
- 新增 `CoreBoundaryTest`:靜態掃描 126 個核心生產檔,強制每處 AddOn 類別
|
||||||
|
呼叫都在同一函式內有 `class_exists()` 守衛
|
||||||
|
|
||||||
|
### ✅ 品質
|
||||||
|
|
||||||
|
- 128 個生產檔 `declare(strict_types=1)`
|
||||||
|
- PHPCS 0 errors / PHPStan level 6(baseline 710)
|
||||||
|
- 測試:核心 unit 587 / integration 416、hivepress-addon unit 154 /
|
||||||
|
integration 57、woocommerce-addon unit 10 / integration 18
|
||||||
|
- gitea CI:核心 6 個 job 全綠 + 11 個 AddOn workflow + branch protection
|
||||||
|
- 打包 10 項終檢全 PASS(569 KB / 187 entries,schema drift 0:34 CREATE / 34 DROP)
|
||||||
|
|
||||||
|
### ⚠️ ABI / 行為變更
|
||||||
|
|
||||||
|
- `doctor_callback` 由 3 參數改為 1 參數(表名)
|
||||||
|
- `wpdo_capture_before_value` 預設 `true` → `false`;需要 `value_before` 的
|
||||||
|
消費者(audit log)要 `add_filter( 'wpdo_capture_before_value', '__return_true' )`
|
||||||
|
- `wpdo_allow_mass_column_clear` 現為 opt-in,整欄清空預設被拒
|
||||||
|
- `SCHEMA_VERSION` 2.0.0 → 2.1.0(audit 表 ALTER)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.1.0] — 2026-05-15 (initial release, Phase 0-5 完成)
|
## [0.1.0] — 2026-05-15 (initial release, Phase 0-5 完成)
|
||||||
|
|
||||||
### Phase 5 hotfix(同日完成)
|
### Phase 5 hotfix(同日完成)
|
||||||
|
|||||||
+354
@@ -0,0 +1,354 @@
|
|||||||
|
# CONTEXT.md — 2meet Data Optimizer Domain Glossary
|
||||||
|
|
||||||
|
This file defines the canonical vocabulary for 2meet Data Optimizer.
|
||||||
|
Architecture reviews, AI assistance, and code documentation must use these terms exactly.
|
||||||
|
|
||||||
|
> **Status (2026-07-31, v1.0.0):** 核心 451 unit / 416 integration · hivepress-addon 145 unit / 57 integration · woocommerce-addon 10 unit / 18 integration · PHPCS 0 errors · PHPStan L6(baseline 710)· gitea CI 6 job 全綠。
|
||||||
|
> v1.0.0 取代 `wp-data-optimizer` v3.4.6,該外掛已退休(本機目錄改名 `.retired`,git 歷史留在 gitea)。版號自 1.0.0 重啟。
|
||||||
|
> **本外掛只有通用引擎**:4 entity(post / user / term / comment)反 EAV、zone 表、migration、wizard、admin、CLI、REST、snapshot、diagnostic、notifications。HivePress / WooCommerce / LatePoint / 2meet-* 整合層全部在 11 個獨立 AddOn,核心不得引用它們的類別。
|
||||||
|
> **共用命名空間刻意保留**:`wp_wpdo_*` 表、`wpdo_` option / cron / hook 前綴、`wpdo/v1` REST namespace 一律不改名 —— 這是資料層零遷移的前提。類別與函式前綴才是 `TMDO_` / `tmdo_`,並以 `class_alias()` 對外保留 `WPDO_*`。
|
||||||
|
> HivePress addon detection uses the **WP active-plugins list** as the authoritative signal (addons publish no per-addon class/const; they register via `add_filter('hivepress/v1/extensions', …)`). See `TMDO_HivePress_Detector::detect()` / `active_plugin_files()` in the HivePress AddOn.
|
||||||
|
> **測試 harness 隔離**:`TMDO_FSM_GUARD_DISABLED`(integration bootstrap,讓測試可強制 module state)、`TMDO_Routing_Predicate::flush_cache()`、`TMDO_Schema_Manager::flush_table_exists_cache()` 三者是跨測試污染的解法,**不是** production 缺陷的補丁。
|
||||||
|
> **gitea runner 為 wpdev 全 repo 共享**(host-mode label `ubuntu-latest:host`,無 service container,integration job 直接連本機 MariaDB)。CI 卡 pending 時先查 `GET /api/v1/user/actions/runners`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zone
|
||||||
|
|
||||||
|
A dedicated storage tier for WordPress postmeta, optimised for a specific access pattern.
|
||||||
|
|
||||||
|
| Zone | Slug | Table pattern | Purpose |
|
||||||
|
|------|------|---------------|---------|
|
||||||
|
| Hot | `hot` | `wpdo_hot_{post_type}` | Flat columns for search/filter (index-friendly) |
|
||||||
|
| Warm | `warm` | `wpdo_warm` | TTL key-value store for counts and transient flags |
|
||||||
|
| Cold | `cold` | `wpdo_cold_{post_type}` | JSON blob for display-only fields |
|
||||||
|
| Archive | `archive` | `wpdo_archive` | gzip-compressed historical data |
|
||||||
|
|
||||||
|
A post type's fields are assigned to exactly one zone via the **Schema Registry**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Registry
|
||||||
|
|
||||||
|
`TMDO_Schema_Registry` — singleton that maps `(post_type, meta_key) → field definition`.
|
||||||
|
|
||||||
|
A **field definition** carries `zone`, `column` (flat name), and optional `type`.
|
||||||
|
The registry is populated at `wpdo_register_fields` action by integrations and adapters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zone Router
|
||||||
|
|
||||||
|
`TMDO_Zone_Router` — static dispatch layer introduced in v3.0.1.
|
||||||
|
|
||||||
|
Single module that knows how to route `read`, `write`, and `delete_field` to the correct
|
||||||
|
zone handler (Hot / Warm / Cold / Archive) given a field definition.
|
||||||
|
Also produces canonical **module names** for Feature Flag lookups.
|
||||||
|
|
||||||
|
> **Why it exists**: before v3.0.1, routing logic was duplicated across `TMDO_Sync_Bridge`,
|
||||||
|
> `TMDO_REST_API`, and two query files. Extracting it here creates one seam for tests and
|
||||||
|
> one place to change zone routing decisions.
|
||||||
|
|
||||||
|
Interface (all static):
|
||||||
|
```
|
||||||
|
TMDO_Zone_Router::module_name(zone, post_type) → string
|
||||||
|
TMDO_Zone_Router::read(field, post_id, post_type, meta_key) → mixed
|
||||||
|
TMDO_Zone_Router::write(field, post_id, post_type, meta_key, value) → void
|
||||||
|
TMDO_Zone_Router::delete_field(field, post_id, post_type, meta_key) → void
|
||||||
|
TMDO_Zone_Router::delete_post(post_id, post_type) → void # v3.4.0: all-zone post cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routing Predicate
|
||||||
|
|
||||||
|
`TMDO_Routing_Predicate` — centralised predicate module introduced in v3.4.0.
|
||||||
|
|
||||||
|
Two recurring guard patterns previously scattered across `TMDO_Sync_Bridge`,
|
||||||
|
`TMDO_Query_Router`, and the REST API are now a single module:
|
||||||
|
|
||||||
|
```
|
||||||
|
TMDO_Routing_Predicate::entity_bridge_owns(meta_key) → bool
|
||||||
|
TMDO_Routing_Predicate::should_write_to_zone(post_type, zone) → bool
|
||||||
|
TMDO_Routing_Predicate::should_read_from_zone(post_type, zone) → bool
|
||||||
|
TMDO_Routing_Predicate::should_query_from_zone(post_type, zone) → bool
|
||||||
|
TMDO_Routing_Predicate::flush_cache() → void # test isolation helper
|
||||||
|
```
|
||||||
|
|
||||||
|
`entity_bridge_owns()` returns true when `TMDO_Mode_Manager::writes_to_flat('post')` AND
|
||||||
|
the key is registered in `TMDO_Entity_Registry` for 'post'. Result is memoised in a
|
||||||
|
request-level static cache (`$entity_bridge_cache`) keyed by `meta_key`.
|
||||||
|
|
||||||
|
The three `should_*` predicates are thin compositions of
|
||||||
|
`TMDO_Zone_Router::module_name()` + the matching `TMDO_Feature_Flags::is_*()` method.
|
||||||
|
|
||||||
|
> **Why it exists**: without this module, callers re-implemented the same two-step
|
||||||
|
> "module_name then Feature_Flags" pattern inline. The **deletion test** confirms depth:
|
||||||
|
> removing it pushes the repeated guard back into 8+ call sites in Sync_Bridge alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Bridge
|
||||||
|
|
||||||
|
`TMDO_Sync_Bridge` — zone-aware dual-write dispatcher hooked into the WordPress metadata API.
|
||||||
|
|
||||||
|
Intercepts `get/update/add/delete_post_metadata` and `before_delete_post`.
|
||||||
|
Calls `TMDO_Zone_Router::read/write/delete_field/delete_post` directly — no private
|
||||||
|
I/O wrappers (v3.4.1: delegate methods inlined and removed).
|
||||||
|
Uses the **Routing Predicate** for all ownership and activation checks.
|
||||||
|
|
||||||
|
The bridge skips fields owned by the **Entity Bridge** to avoid duplicate writes.
|
||||||
|
|
||||||
|
Its only private helper is `get_field_cached()` — a request-level Schema Registry
|
||||||
|
lookup cache keyed by `post_type:meta_key`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module
|
||||||
|
|
||||||
|
A named unit whose lifecycle is tracked by the **Feature Flags** 7-state machine.
|
||||||
|
Module names follow the convention: `hot_{post_type}`, `cold_{post_type}`, `warm`, `archive`.
|
||||||
|
|
||||||
|
`TMDO_Zone_Router::module_name()` is the single authoritative source for this naming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Flags / 7-state FSM
|
||||||
|
|
||||||
|
`TMDO_Feature_Flags` — state machine governing migration lifecycle for each module.
|
||||||
|
|
||||||
|
States: `idle → dual_write → backfill → verify → cutover → cleanup → complete`
|
||||||
|
|
||||||
|
`is_write_active(module)` returns true for `dual_write` and above.
|
||||||
|
`is_read_custom(module)` returns true for `cutover` and above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Bridge
|
||||||
|
|
||||||
|
Newer anti-EAV system (v2.5+) covering user, term, comment, and post entities via a
|
||||||
|
**Hook Bus** that intercepts native `*meta()` API calls and writes to flat entity tables.
|
||||||
|
|
||||||
|
`TMDO_Mode_Manager` controls per-entity mode: `disabled → dual_write → shadow_read → aeav_only`.
|
||||||
|
`TMDO_Entity_Registry` maps `(entity_type, group) → fields`.
|
||||||
|
|
||||||
|
The Entity Bridge and the Zone system coexist; the Sync Bridge's
|
||||||
|
`is_owned_by_entity_bridge()` guard prevents duplicate writes.
|
||||||
|
|
||||||
|
**Write order (v1.1.0).** In `dual_write` / `shadow_read` the Hook Bus pre-filters
|
||||||
|
only decide the route and return `null`: WordPress writes native meta itself, and the
|
||||||
|
flat value is then re-synced *from native* in `added_/updated_/deleted_{type}_meta`
|
||||||
|
(priority 1) — `TMDO_Hook_Bus::sync_from_native()` reads the first native row with
|
||||||
|
direct SQL; no native row → flat NULL. `aeav_only` keeps the pre-filter short-circuit.
|
||||||
|
|
||||||
|
**shadow_read** reads native meta (WordPress meta cache) and compares a sample of reads
|
||||||
|
with the flat value (`tmdo_shadow_compare_sample_rate`, default 0.1). Flat tables serve
|
||||||
|
queries only: `TMDO_Post_Query_Router` is active from `shadow_read` up.
|
||||||
|
|
||||||
|
**Search-index mode** (ADR-004). On HivePress / WooCommerce sites native meta is the
|
||||||
|
source of truth and flat tables are its search index: `TMDO_Promotion_Guard` refuses
|
||||||
|
`aeav_only` and cleanup there (post/user/term/comment with HivePress, post/user with
|
||||||
|
WooCommerce), because both read meta with an empty key and WooCommerce updates stock
|
||||||
|
and sales with direct SQL. Override: filter `tmdo_promotion_guard_allow`.
|
||||||
|
|
||||||
|
**Post query router rules.** A query is rewritten only when the flat table returns
|
||||||
|
what native `meta_query` would: every registered clause routable (else the whole query
|
||||||
|
stays native), AND relation, one post type, column type matching the clause's CAST
|
||||||
|
semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT EXISTS/EXISTS
|
||||||
|
shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole
|
||||||
|
`orderby` on a column that orders like native.
|
||||||
|
|
||||||
|
**Drift** (v1.2.0). `TMDO_Drift_Check` compares flat values with native meta per key
|
||||||
|
(miss / diff / extra) and records the outcome per group in `wpdo_drift_status`;
|
||||||
|
`--fix` rebuilds flat rows from native. The router routes no clause of a group recorded
|
||||||
|
as drifting (never checked → routes). A daily sample, and a full fix 60 s after an
|
||||||
|
entity leaves `disabled` or the Hook Bus is switched back on (`tmdo_drift_resync`), keep
|
||||||
|
the record current. None of this applies in `aeav_only`, where flat is the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Phase (Strategy pattern)
|
||||||
|
|
||||||
|
Interface: `TMDO_Migration_Phase_Interface` (v3.0.1).
|
||||||
|
|
||||||
|
Each phase encapsulates a single step of the Entity Bridge migration pipeline:
|
||||||
|
|
||||||
|
| Phase class | Slug | What it does |
|
||||||
|
|-------------|------|--------------|
|
||||||
|
| `TMDO_Phase_Diagnose` | `diagnose` | Records preflight EAV row count / ratio |
|
||||||
|
| `TMDO_Phase_Backup` | `backup` | Dumps native meta table to `uploads/wpdo-backups/` |
|
||||||
|
| `TMDO_Phase_Demote` | `demote` | aeav_only → dual_write (rollback entry point) |
|
||||||
|
| `TMDO_Phase_Install_Schema` | `install_schema` | Creates flat tables via Schema Manager |
|
||||||
|
| `TMDO_Phase_Backfill_Bulk` | `backfill_bulk` | Pivots text-only groups via INSERT…SELECT |
|
||||||
|
| `TMDO_Phase_Backfill_Unserialize` | `backfill_unserialize` | Row-by-row migration for JSON fields |
|
||||||
|
| `TMDO_Phase_Promote_Shadow` | `promote_shadow` | dual_write → shadow_read |
|
||||||
|
| `TMDO_Phase_Verify_Sample` | `verify_sample` | Samples entities, compares flat vs EAV |
|
||||||
|
| `TMDO_Phase_Promote_Aeav` | `promote_aeav` | shadow_read → aeav_only |
|
||||||
|
| `TMDO_Phase_Cleanup` | `cleanup` | Deletes managed keys from EAV table |
|
||||||
|
| `TMDO_Phase_Completed` | `completed` | Terminal — marks job done, returns `'done'` |
|
||||||
|
|
||||||
|
`TMDO_Migration_Phase_Base` provides shared helpers (`log()`, `get_managed_keys()`,
|
||||||
|
`execute_bulk_pivot()`, `values_loose_equal()`).
|
||||||
|
|
||||||
|
The **Migration Orchestrator** (`TMDO_Migration_Orchestrator`) injects phase objects and
|
||||||
|
calls `execute($job)` in sequence, advancing through the pipeline.
|
||||||
|
|
||||||
|
> **Seam**: the interface is the test surface. A phase can be tested by constructing it
|
||||||
|
> with a stub entity type, calling `execute()` with a job array, and asserting on the
|
||||||
|
> job's `state`, `log`, and `metrics` — no hooks or DB needed for unit tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Post Interceptor (Template Method pattern)
|
||||||
|
|
||||||
|
`TMDO_Standard_Post_Interceptor` — abstract base (v3.0.1) for HPCT-inherited interceptors.
|
||||||
|
|
||||||
|
Eliminates the three hook methods (`filter_update_meta`, `action_insert_post`,
|
||||||
|
`action_delete_post`) that were previously duplicated across four interceptor classes.
|
||||||
|
Subclasses declare only:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public const FIELD_MAP = ['meta_key' => 'flat_column', ...];
|
||||||
|
protected function get_post_type(): string { ... }
|
||||||
|
protected function get_table_key(): string { ... }
|
||||||
|
protected function build_insert_data(int $post_id, WP_Post $post, string $now): array { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Concrete subclasses: `TMDO_Reviews_Interceptor`, `TMDO_Messages_Interceptor`,
|
||||||
|
`TMDO_Memberships_Interceptor`, `TMDO_Requests_Interceptor`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Partners
|
||||||
|
|
||||||
|
`TMDO_Core::EXTERNAL_PARTNERS` (v3.0.1) — PHP class-name constant array listing all
|
||||||
|
external plugin integrations that self-register via the Hook Bus.
|
||||||
|
|
||||||
|
```php
|
||||||
|
['TMDO_Infocards', 'TMDO_Bookings', 'TMDO_Quotation',
|
||||||
|
'TMDO_Mobile_Bridge', 'TMDO_Collab', 'TMDO_Playlist']
|
||||||
|
```
|
||||||
|
|
||||||
|
Used in the late-bind priority-30 closure in `2meet-data-optimizer.php` to initialize
|
||||||
|
partner integrations only when their plugin class is present. Single source of truth —
|
||||||
|
previously the list existed only inside the closure and diverged from `TMDO_Core`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HPCT Interceptors Manifest
|
||||||
|
|
||||||
|
> **v1.0.0:本節描述的是 AddOn 的內部結構,核心已無此常數。**
|
||||||
|
> `HPCT_INTERCEPTORS` 與 `register_hpct_interceptors()` 隨整合層一起搬到
|
||||||
|
> `2meet-data-optimizer-hivepress-addon` 的 bootstrap;核心刻意不保留,否則會引用
|
||||||
|
> 8 個核心不存在的類別名。下表列的 interceptor ↔ query-handler 配對關係仍然成立。
|
||||||
|
|
||||||
|
`TMDO_Core::HPCT_INTERCEPTORS` (v3.4.0) — PHP class-constant array that is the single
|
||||||
|
authoritative list of HPCT-inherited interceptor → query-handler pairs:
|
||||||
|
|
||||||
|
```php
|
||||||
|
TMDO_Reviews_Interceptor::class → TMDO_Reviews_Query::class
|
||||||
|
TMDO_Messages_Interceptor::class → TMDO_Messages_Query::class
|
||||||
|
TMDO_Favorites_Interceptor::class → null
|
||||||
|
TMDO_Memberships_Interceptor::class → TMDO_Memberships_Query::class
|
||||||
|
TMDO_Statistics_Interceptor::class → null
|
||||||
|
TMDO_Requests_Interceptor::class → TMDO_Requests_Query::class
|
||||||
|
TMDO_Listing_Meta_Interceptor::class → TMDO_Listing_Meta_Query::class
|
||||||
|
TMDO_LatePoint_Interceptor::class → null
|
||||||
|
```
|
||||||
|
|
||||||
|
`register_hpct_interceptors()` iterates this constant; adding a new interceptor requires
|
||||||
|
only one entry here — no code change in the registration method.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HPCT (HP Custom Tables)
|
||||||
|
|
||||||
|
The plugin this replaces. Still referenced in import path (`wp tmdo import-hpct`).
|
||||||
|
Any "HPCT-inherited" interceptor means it originated in HPCT and was migrated here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adapter (HivePress)
|
||||||
|
|
||||||
|
> **v1.0.0:這 13 個 adapter 住在 `2meet-data-optimizer-hivepress-addon`,不在核心。**
|
||||||
|
> 核心只提供 `TMDO_Schema_Registry` 與 `wpdo_register_fields` 這個 seam。
|
||||||
|
|
||||||
|
Each HivePress addon (core, bookings, events, …) has a corresponding
|
||||||
|
`TMDO_Hivepress_*_Adapter` that registers its meta keys into the Schema Registry at
|
||||||
|
`wpdo_register_fields`. The adapter is the seam between HivePress and the zone system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-EAV
|
||||||
|
|
||||||
|
The overarching goal: eliminate Entity-Attribute-Value (EAV) reads from `wp_postmeta`,
|
||||||
|
`wp_usermeta`, etc. by moving data to flat tables.
|
||||||
|
|
||||||
|
Compliance is enforced by `wpdo-policy-enforcer.php` (mu-plugin) and the
|
||||||
|
`wp tmdo lint` CLI gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Group
|
||||||
|
|
||||||
|
A named set of fields registered under one entity type (user / post / term / comment),
|
||||||
|
stored as one flat table: `wp_wpdo_{entity_type}_{group_name}`.
|
||||||
|
|
||||||
|
Each group has **one row per entity** (keyed on `user_id` / `post_id` / etc.).
|
||||||
|
Fields within the group become typed columns; `sanitize_column_name()` produces the
|
||||||
|
column name (strips all non-`[a-zA-Z0-9_]` characters — hyphens are removed, not replaced).
|
||||||
|
|
||||||
|
Group registration: `TMDO_Entity_Registry::register_group(entity_type, group_name, fields[])`.
|
||||||
|
|
||||||
|
Canonical user groups (v3.1.4):
|
||||||
|
|
||||||
|
| Group | Table | Purpose |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| `membership` | `wp_wpdo_user_membership` | Tier, points, expiry |
|
||||||
|
| `activity` | `wp_wpdo_user_activity` | Login counters, last-active |
|
||||||
|
| `profile` | `wp_wpdo_user_profile` | Bio, avatar, display name |
|
||||||
|
| `sso` | `wp_wpdo_user_sso` | Hub token cache, SLO hash |
|
||||||
|
| `core_profile` | `wp_wpdo_user_core_profile` | WP first_name, last_name, nickname, description |
|
||||||
|
| `social` | `wp_wpdo_user_social` | 15 social platform URLs |
|
||||||
|
| `commerce` | `wp_wpdo_user_commerce` | WC billing/shipping + runtime stats (wc_last_active, wc_order_count_wp, last_update) |
|
||||||
|
| `hp_user` | `wp_wpdo_user_hp_user` | HP favorites, avatar, hp_verified |
|
||||||
|
| `admin_prefs` | `wp_wpdo_user_admin_prefs` | WP default admin-UI keys written by wp_insert_user + extended UI prefs (22 keys total, v3.1.4) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EAV Floor
|
||||||
|
|
||||||
|
The minimum irreducible rows that must remain in the native meta table even after full
|
||||||
|
Entity Bridge migration to `aeav_only`.
|
||||||
|
|
||||||
|
For `wp_usermeta`, the floor consists of:
|
||||||
|
|
||||||
|
| Key | Rows per user | Why irreducible |
|
||||||
|
|-----|---------------|-----------------|
|
||||||
|
| `wp_capabilities` | 1 | WP core reads directly in `WP_User` constructor (not through `get_user_meta`) |
|
||||||
|
| `session_tokens` | 0–N | WP authentication reads sessions directly; Hook Bus cannot intercept session creation safely |
|
||||||
|
| `_application_passwords` | 0–1 | WP reads directly during REST auth |
|
||||||
|
|
||||||
|
**Theoretical minimum ratio** for a standard WordPress site: `(users + sessions + app_passwords) / users ≈ 1:1.07` (varies by active sessions).
|
||||||
|
|
||||||
|
Any ratio above 1:1.07 represents reducible EAV that WPDO can absorb.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WP Admin Prefs
|
||||||
|
|
||||||
|
The set of `wp_usermeta` keys that WordPress core writes automatically:
|
||||||
|
|
||||||
|
**Written by `wp_insert_user()` for every new user (7 keys)**:
|
||||||
|
`rich_editing`, `syntax_highlighting`, `comment_shortcuts`, `admin_color`, `use_ssl`,
|
||||||
|
`show_admin_bar_front`, `dismissed_wp_pointers`
|
||||||
|
|
||||||
|
**Written on first admin-page visit or explicit user action (extended, registered v3.1.4)**:
|
||||||
|
`show_welcome_panel`, `wp_persisted_preferences`, `nav_menu_recently_edited`,
|
||||||
|
`wp_dashboard_quick_press_last_post_id`, `edit_*_per_page` variants,
|
||||||
|
`community-events-location`, `wp_user-settings`, `wp_user-settings-time`,
|
||||||
|
`managenav-menuscolumnshidden`, `metaboxhidden_nav-menus`,
|
||||||
|
`dismissed_no_secure_connection_notice`, `meta-box-order_product`
|
||||||
|
|
||||||
|
All 22 keys are registered in the `admin_prefs` Entity Group and routed to
|
||||||
|
`wp_wpdo_user_admin_prefs` by the Hook Bus when user entity mode ≥ `dual_write`.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Version**: 0.1.0 (scaffold + 4 phase 完成 2026-05-15)
|
- **Version**: 1.0.2 (2026-08-15;scaffold + 4 phase 完成於 2026-05-15)
|
||||||
- **Phase**: 全 4 phase 已完成
|
- **Phase**: 全 4 phase 已完成
|
||||||
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
|
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
|
||||||
|
|
||||||
@@ -207,10 +207,59 @@
|
|||||||
| 2 | DDL 與並發 B1–B4(warm UNIQUE KEY → Zone_Cold save_patch → Zone_Warm increment) | ✅ 2026-07-31 |
|
| 2 | DDL 與並發 B1–B4(warm UNIQUE KEY → Zone_Cold save_patch → Zone_Warm increment) | ✅ 2026-07-31 |
|
||||||
| 3 | 架構回填(Zone_Router + Routing_Predicate;13 個 Migration Phase 類別) | ✅ 2026-07-31 |
|
| 3 | 架構回填(Zone_Router + Routing_Predicate;13 個 Migration Phase 類別) | ✅ 2026-07-31 |
|
||||||
| 4 | 相容層 E1–E7(雙向 hook 橋、57 個 AddOn alias、38 個 CLI 子指令、選單 slug) | ✅ 2026-07-31 |
|
| 4 | 相容層 E1–E7(雙向 hook 橋、57 個 AddOn alias、38 個 CLI 子指令、選單 slug) | ✅ 2026-07-31 |
|
||||||
| 5 | AddOn 同步 F1–F5(Standard_Post_Interceptor、HP detector v3.4.5、WC) | ⬜ |
|
| 5 | AddOn 同步 F1–F5(Standard_Post_Interceptor、HP detector v3.4.5、WC) | ✅ 2026-07-31 |
|
||||||
| 6 | 功能與診斷 PR-G(crypto 健檢、doctor_callback ABI、15 個 usermeta 欄位…) | ⬜ |
|
| 6 | 功能與診斷 PR-G(crypto 健檢、doctor_callback ABI、15 個 usermeta 欄位…) | ✅ 2026-07-31(**Settings 分區 AJAX 未做**,見下) |
|
||||||
| 7 | 全域清理與 CI(strict_types、phpcs.xml、phpstan + baseline、gitea workflows) | ⬜ |
|
| 7 | 全域清理與 CI(strict_types、phpcs.xml、phpstan + baseline、gitea workflows) | 🔶 strict_types / PHPCS / PHPStan / 3 個 workflow / ci-package.sh ✅;**測試補齊(剩 36 檔,多為 HP/WC)、文件移植未做** |
|
||||||
| 退休 | 兩站台切到 B、移除 A、B 升 1.0.0 | ⬜ |
|
| 退休 | 兩站台切到 B、移除 A、B 升 1.0.0 | 🔶 dev30 已切換並驗證通過;dev 未切、A 未移除、版本未升 |
|
||||||
|
|
||||||
|
### dev30 實機驗證結果(2026-07-31)
|
||||||
|
|
||||||
|
環境:`2meet-brandcards` 1.8.3 / `2meet-hub-core` 1.16.0 / `2meet-spoke-sso` 1.15.0 同時啟用;HivePress / WooCommerce 未安裝,故只啟用核心 + hub-addon + spoke-addon。
|
||||||
|
|
||||||
|
| 項目 | A v3.0.0(切換前) | B v0.1.0(切換後) |
|
||||||
|
|---|---|---|
|
||||||
|
| `doctor` | 33 個 `[OK]` + 15 個 WC 表 MISSING 警告 | **49 個 `[OK]`、All checks passed** |
|
||||||
|
| 姊妹外掛自訂表 | 36 張 OK | 36 張 OK(含 brandcards 的 `doctor_callback` 回傳訊息) |
|
||||||
|
| 新健檢 | 無 | `Backup dir blocked (HTTP 404)`、`Crypto key derivable` 皆 OK |
|
||||||
|
|
||||||
|
逐項確認:
|
||||||
|
- **alias**:15 個核心類別 + trait + interface + `wpdo_run()` + `WPDO_VERSION` 全部可用
|
||||||
|
- **CLI**:`wp tmdo bridge-status / crypto-status / mode-audit` 與 `wp wpdo *` 兩條路徑都通(38 個子指令)
|
||||||
|
- **anti-EAV 寫入**:`WPDO_API::set_entity()` → `wp_wpdo_user_sso.known_login_ips` 寫入成功(A14 生效)
|
||||||
|
- **audit**:`wp_wpdo_audit` 的 `group_name` / `action` / `trace_id`(UUIDv4)皆有值(A11/A12 生效)
|
||||||
|
- **REST allowlist**:註冊 `show_in_rest => false` 的欄位不出現在 `get_rest_visible_hot_columns()`(A5/A6 生效)
|
||||||
|
- **doctor_callback ABI 變更**:brandcards 既有 callback 在 1 參數簽章下仍正常
|
||||||
|
- 前台 HTTP 200;`_load_textdomain_just_in_time` notice 僅出現在 `wp plugin activate` 當下(啟用鉤子早於 `init`),一般請求與前台皆無
|
||||||
|
|
||||||
|
**實機才抓到的 fatal**:`TMDO_Logger::trace_id()` 在 B 提煉時遺漏,而 `Audit_Logger::write_row()` 會呼叫它。A12 把 Audit_Logger 掛上後,任何一次受管 meta 寫入都會 fatal。PHPUnit 沒有覆蓋 audit 寫入路徑 → 這類「跨模組才會踩到」的缺口只有實機能發現。
|
||||||
|
|
||||||
|
### 尚未完成(follow-up)
|
||||||
|
|
||||||
|
1. **Settings 分區 AJAX 儲存**(A v3.0.2):`ajax_save_settings_section()` + 7 個 `save_section_*()` + `admin/assets/wpdo-settings.js`。B 目前是一次存全部的 inline `update_option` 巨塊,功能可用,屬 UX 改善。其中 `save_section_hp_transient` / `save_section_wc_term_count` 對應 AddOn 已移除的設定,移植時要拆掉或加 `class_exists` 守衛。
|
||||||
|
2. ~~CI workflow~~ ✅ 三個檔已建,但 **B 沒有 git remote**,要推上 gitea 才會實際執行;推上去後還需設 repo secret `TMDO_TEST_DB_PASS`、var `RELEASE_GITEA_URL`、secret `RELEASE_TOKEN`,並開 main branch protection。11 個 AddOn 的 CI 仍未建。
|
||||||
|
3. ~~`scripts/ci-package.sh`~~ ✅(委派共用 `package-plugin.sh`,實測 10 項終檢全 PASS)
|
||||||
|
4. ~~補測試檔~~ ✅ 核心補 `HookBusIntegrationTest`(14)+ 新寫 `AuditLoggerIntegrationTest`(4);HP AddOn 建 phpunit 基建並移植 25 檔(145 tests);WC AddOn 建基建並移植 1 檔(10 tests)。11 個 AddOn 皆有 CI workflow(HP/WC 跑 lint+unit,其餘 9 個薄註冊層只跑 lint)。
|
||||||
|
- 仍缺:`TermCommentBackfillTest`(require HP AddOn 的 term-comment-fields,可移入 HP AddOn 套件)、`ListingStatsTest`(同上)、HP/WC 的 integration 測試(`HivepressIntegrationTest` ×3、`WCTermCountFilterTest`、`ListingMetaInterceptorTest`)——這些需要 AddOn 端的 integration bootstrap。
|
||||||
|
|
||||||
|
### 全家族測試現況(2026-07-31)
|
||||||
|
|
||||||
|
| 套件 | 結果 |
|
||||||
|
|---|---|
|
||||||
|
| 核心 unit | 451 tests / 889 assertions |
|
||||||
|
| 核心 integration | 416 tests / 1165 assertions |
|
||||||
|
| hivepress-addon unit | 145 tests / 357 assertions |
|
||||||
|
| woocommerce-addon unit | 10 tests / 33 assertions |
|
||||||
|
|
||||||
|
AddOn 測試 bootstrap 直接 `require` 核心 plugin 的 `tests/bootstrap.php`(避免複製 ~700 行 WP stub),因此 **AddOn 的 CI job 必須同時 checkout 核心 plugin**,workflow 已如此設定。
|
||||||
|
5. **文件移植**:`readme.txt`、`CONTEXT.md`、`docs/`(ENTITY_ADAPTER_COOKBOOK、INTEGRATION_PATTERN_DECISION、2 篇 ADR)。
|
||||||
|
6. **實機驗證與退休切換**:兩站台目前仍跑 A(dev = v3.4.6、dev30 = v3.0.0),B 家族 12 個外掛全 inactive,尚未做過 `wp tmdo doctor` 實機驗證。
|
||||||
|
|
||||||
|
### ABI / 行為變更(升級須知)
|
||||||
|
|
||||||
|
- `doctor_callback` 由 3 參數改為 1 參數(表名)——AddOn 若註冊過該 callback 需同步。
|
||||||
|
- `wpdo_capture_before_value` 預設 `true` → `false`,每次受管寫入省一次 DB read;需要 `value_before` 的消費者(audit log)要 `add_filter( 'wpdo_capture_before_value', '__return_true' )`。
|
||||||
|
- `wpdo_allow_mass_column_clear` 現為 opt-in:`delete_all=true` 的整欄清空預設被拒。
|
||||||
|
- 選單 slug 由 `2meet-data-optimizer` 改回 `wp-data-optimizer`。
|
||||||
|
|
||||||
### Lessons learned(backport)
|
### Lessons learned(backport)
|
||||||
|
|
||||||
@@ -218,6 +267,7 @@
|
|||||||
- `composer update` 後 autoload 出現 `WPDO_Entity_Adapter_Interface` 三重宣告警告(`includes/adapters/interface-entity-adapter.php`、`back-compat/interface-wpdo-entity-adapter-alias.php`、`back-compat/trait-wpdo-anti-eav-aware-alias.php`)→ 階段 4 E2 要刪掉重複的那一份。
|
- `composer update` 後 autoload 出現 `WPDO_Entity_Adapter_Interface` 三重宣告警告(`includes/adapters/interface-entity-adapter.php`、`back-compat/interface-wpdo-entity-adapter-alias.php`、`back-compat/trait-wpdo-anti-eav-aware-alias.php`)→ 階段 4 E2 要刪掉重複的那一份。
|
||||||
- `TMDO_FSM_GUARD_DISABLED` 兩個 test bootstrap 原本都沒 define,但 `class-tmdo-feature-flags.php:128` 會檢查它 → 強制 FSM 轉換的測試會被 guard 擋掉(A 在 v3.4.6 踩過同一個坑)。
|
- `TMDO_FSM_GUARD_DISABLED` 兩個 test bootstrap 原本都沒 define,但 `class-tmdo-feature-flags.php:128` 會檢查它 → 強制 FSM 轉換的測試會被 guard 擋掉(A 在 v3.4.6 踩過同一個坑)。
|
||||||
- integration 測試需要 `TMDO_TEST_DB_PASS`(或 `WPDO_TEST_DB_PASS`)環境變數,DB 為本機 `wp_wpdo_test`。
|
- integration 測試需要 `TMDO_TEST_DB_PASS`(或 `WPDO_TEST_DB_PASS`)環境變數,DB 為本機 `wp_wpdo_test`。
|
||||||
|
- **跑完 `scripts/ci-package.sh` 或 `package-plugin.sh` 後要重跑 `composer install`**:打包流程內含 `composer install --no-dev`,會把 `vendor/bin/{phpunit,phpcs,phpstan}` 移掉,之後任何測試指令都會 "No such file or directory"。
|
||||||
- **A 不是照抄對象,有兩處自身缺陷不可照搬**:
|
- **A 不是照抄對象,有兩處自身缺陷不可照搬**:
|
||||||
1. `A/includes/engine/class-wpdo-hook-bus.php:511,522` 傳 3 個 string 給 `Logger::warning( string $event, array $context )` → A 有 `strict_types`,mass-clear 路徑必炸。B 用正確簽章。
|
1. `A/includes/engine/class-wpdo-hook-bus.php:511,522` 傳 3 個 string 給 `Logger::warning( string $event, array $context )` → A 有 `strict_types`,mass-clear 路徑必炸。B 用正確簽章。
|
||||||
2. `A/admin/class-wpdo-dashboard-widget.php:191` 的 `wpdo_postmeta_cleanup` 仍是 `wp_nonce_url` GET 連結,但其 handler 已只收 POST → 該按鈕在 A 是壞的。B 改成 form。
|
2. `A/admin/class-wpdo-dashboard-widget.php:191` 的 `wpdo_postmeta_cleanup` 仍是 `wp_nonce_url` GET 連結,但其 handler 已只收 POST → 該按鈕在 A 是壞的。B 改成 form。
|
||||||
@@ -237,3 +287,28 @@
|
|||||||
- `v0.1.0` — 本 release(Phase 0-4 完成)
|
- `v0.1.0` — 本 release(Phase 0-4 完成)
|
||||||
- `v0.1.1` — Phase 5 follow-up(tests / PHPCS / packaging)
|
- `v0.1.1` — Phase 5 follow-up(tests / PHPCS / packaging)
|
||||||
- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI
|
- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NinjaFirewall 相容性 ✅(2026-08-15,v1.0.2)
|
||||||
|
|
||||||
|
計畫檔:`~/.claude/plans/2meet-data-optimizer-ninjafirewall-effervescent-lighthouse.md`
|
||||||
|
|
||||||
|
- [x] 評估與 NinjaFirewall 4.9 的相容性 → **不需要開發 AddOn**
|
||||||
|
- [x] 診斷 `Cannot retrieve user options from database (#3)` → 與本外掛無關,根因為 symlink 部署
|
||||||
|
- [x] 建立 `/var/www/Studio/.htninja`(symlink-safe 站台錨點,全租戶共用)
|
||||||
|
- [x] dev30 啟用 ninjafirewall,補上缺失的 `nfw_options` / `nfw_rules`
|
||||||
|
- [x] `TMDO_Options_Manager::PROTECTED_OPTIONS` 守衛 + 4 個單元測試
|
||||||
|
- [x] Migration Wizard 輪詢 500ms → 2s
|
||||||
|
- [x] `docs/WAF-COMPATIBILITY.md`
|
||||||
|
- [x] 全套件回歸:591 tests / 1166 assertions OK
|
||||||
|
|
||||||
|
### Lessons learned(WAF)
|
||||||
|
|
||||||
|
- **`__DIR__` 會解析 symlink**。NinjaFirewall Full WAF 用 `dirname(dirname(dirname(__DIR__)))`(`lib/firewall.php:78`)推導站台位置,在 symlink 共享 codebase 的 SaaS 架構下,所有租戶都會被判定成「共享目錄所屬的那個站」,於是連錯資料庫、撈不到自己的 `nfw_options`,回錯誤碼 6(訊息寫作 `#3`)。**per-site 的可靠錨點是 `$_SERVER['DOCUMENT_ROOT']`**(web server 的 root 指令值,不受 symlink 影響),這正是 `.htninja` 的搜尋基準。
|
||||||
|
- **`.htninja` 放在 `dirname(DOCUMENT_ROOT)` 可服務全部租戶**(`firewall.php:47-48` 的第二順位),因為內容以 DOCUMENT_ROOT 動態推導,一份檔案通用。切勿在其中寫 `return` —— `'ALLOW'` / `'BLOCK'` 是有意義的回傳值。
|
||||||
|
- **`NFW_LOG_DIR` 對 WP WAF 模式同樣必要**:L78 的誤判在兩種模式都存在,只是 WP WAF 不用它連 DB,但 log / cache / session 仍會全部寫進共享目錄互相覆蓋。
|
||||||
|
- **WAF 失效是靜默的**。讀不到設定時 `nfw_quit()` 直接返回,不擋任何請求也不寫 log —— 「your site is not protected」是字面意思。因此任何會讓 `nfw_options` 離開 `wp_options` 的機制(例如本外掛的 `register_settings_group()` 重導向)都必須擋在註冊階段。
|
||||||
|
- **`.user.ini` 必須讓 php-fpm worker 可讀**。dev32 那份是 `root:root 0640`,www-data 讀不到 → `auto_prepend_file` 靜默失效、Full WAF 等同沒裝。排查時容易誤判成「WAF 有在跑」,實際上擋下請求的是共享 mu-plugin 的 WP WAF。
|
||||||
|
- **判斷 WAF 是否真的生效,要用會被規則擋的請求實測**(例如 `?x=../../etc/passwd` → 規則 1 → 403),光看首頁 200 或後台無錯誤訊息都不算數。
|
||||||
|
- WP-CLI 完全豁免(`firewall.php:18-23`),所以 CLI 全綠**不能**當作「WAF 與外掛相容」的證據,必須另外走 HTTP 驗證。
|
||||||
|
|||||||
@@ -119,9 +119,19 @@ GPL-2.0-or-later
|
|||||||
|
|
||||||
## 文件
|
## 文件
|
||||||
|
|
||||||
|
- [readme.txt](readme.txt) — WordPress 外掛目錄格式說明(隨 ZIP 發佈)
|
||||||
|
- [CONTEXT.md](CONTEXT.md) — 領域詞彙表(Zone / Registry / Mode / FSM 的正式定義)
|
||||||
- [PLAN.md](PLAN.md) — 開發任務追蹤
|
- [PLAN.md](PLAN.md) — 開發任務追蹤
|
||||||
- [CHANGELOG.md](CHANGELOG.md) — 版本歷史
|
- [CHANGELOG.md](CHANGELOG.md) — 版本歷史
|
||||||
- [DEPLOY.md](DEPLOY.md) — 部署流程
|
- [DEPLOY.md](DEPLOY.md) — 部署流程
|
||||||
- [SECURITY.md](SECURITY.md) — 安全聯絡
|
- [SECURITY.md](SECURITY.md) — 安全聯絡
|
||||||
- [DESIGN.md](DESIGN.md) — 架構決策
|
- [DESIGN.md](DESIGN.md) — 架構決策
|
||||||
- [CLAUDE.md](CLAUDE.md) — AI 協作指引
|
- [CLAUDE.md](CLAUDE.md) — AI 協作指引
|
||||||
|
|
||||||
|
給夥伴外掛作者(`docs/`,不進 ZIP):
|
||||||
|
|
||||||
|
- [ENTITY_ADAPTER_COOKBOOK.md](docs/ENTITY_ADAPTER_COOKBOOK.md) — 五層整合階梯與決策樹
|
||||||
|
- [adr-001-post-entity-source-of-truth.md](docs/adr-001-post-entity-source-of-truth.md) — post entity 的權威來源契約
|
||||||
|
- [adr-002-dual-write-naming-collision.md](docs/adr-002-dual-write-naming-collision.md) — `dual_write` 在兩套 FSM 的同名衝突
|
||||||
|
- [INTEGRATION_PATTERN_DECISION.md](docs/INTEGRATION_PATTERN_DECISION.md) — 整合模式取捨紀錄(含 v1.0.0 後記)
|
||||||
|
- [anti-eav-lint.yml.template](docs/anti-eav-lint.yml.template) — 夥伴外掛 CI gate 樣板
|
||||||
|
|||||||
+401
-2
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* WP Data Optimizer — Admin Styles (Morandi Design System)
|
* 2meet Data Optimizer — Admin Styles (Morandi Design System)
|
||||||
*
|
*
|
||||||
* Morandi tokens are provided via the 'morandi-design-system' CSS dependency
|
* Morandi tokens are provided via the 'morandi-design-system' CSS dependency
|
||||||
* registered in WPDO_Admin::enqueue_assets(). No @import needed.
|
* registered in TMDO_Admin::enqueue_assets(). No @import needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ── KPI Hero Strip (v2.16.0) ────────────────────────────────────────── */
|
/* ── KPI Hero Strip (v2.16.0) ────────────────────────────────────────── */
|
||||||
@@ -512,4 +512,403 @@
|
|||||||
.nav-tab .wpdo-tab-dot {
|
.nav-tab .wpdo-tab-dot {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
.wpdo-bar-fill,
|
||||||
|
.wpdo-eb-bar-fill,
|
||||||
|
.wpdo-setup-progress-fill { transition-duration: 0.01ms; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── v3.0.2 — Inline-style Elimination Utilities ────────────────────── */
|
||||||
|
|
||||||
|
/* Margin / visibility */
|
||||||
|
.wpdo-mt-0 { margin-top: 0; }
|
||||||
|
.wpdo-mt-24 { margin-top: 24px; }
|
||||||
|
.wpdo-hidden { display: none; }
|
||||||
|
|
||||||
|
/* Semantic text colours */
|
||||||
|
.wpdo-text-success { color: #155724; }
|
||||||
|
.wpdo-text-error { color: #721c24; }
|
||||||
|
.wpdo-text-muted { color: #999; }
|
||||||
|
.wpdo-text-warn { color: #856404; }
|
||||||
|
.wpdo-text-success-em { color: #46b450; font-weight: bold; }
|
||||||
|
.wpdo-text-warn-em { color: #dba617; font-weight: bold; }
|
||||||
|
.wpdo-text-neutral { color: #8c8f94; }
|
||||||
|
.wpdo-text-skip { color: #888; font-size: 0.9em; }
|
||||||
|
.wpdo-check-success { color: #155724; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Mode badges — small pill (Entity Bridge status table) */
|
||||||
|
.wpdo-mode-sm {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
/* Mode badges — round pill (Entity Bridge health cards) */
|
||||||
|
.wpdo-mode-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.wpdo-mode--disabled { background: #e0e0e0; color: #444; }
|
||||||
|
.wpdo-mode--dual-write { background: #d4edda; color: #155724; }
|
||||||
|
.wpdo-mode--shadow-read { background: #fff3cd; color: #856404; }
|
||||||
|
.wpdo-mode--aeav-only { background: #cce5ff; color: #004085; }
|
||||||
|
|
||||||
|
/* Entity Bridge grid */
|
||||||
|
.wpdo-eb-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.wpdo-eb-card {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
.wpdo-eb-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.wpdo-eb-title { margin: 0 0 4px; font-size: 16px; }
|
||||||
|
.wpdo-mode-days { margin-left: 8px; font-size: 12px; color: var(--color-text-secondary, #666); }
|
||||||
|
.wpdo-mode-warn-sm { font-size: 12px; color: #856404; background: #fff3cd; padding: 2px 8px; border-radius: 4px; }
|
||||||
|
.wpdo-eb-pipeline { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; font-size: 11px; }
|
||||||
|
.wpdo-pipeline-arrow { color: #999; }
|
||||||
|
.wpdo-stage-pill { padding: 2px 8px; border-radius: 3px; }
|
||||||
|
.wpdo-stage-pill--inactive { background: #f0f0f0; color: #666; }
|
||||||
|
|
||||||
|
.wpdo-eb-tables-row {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.wpdo-eb-tables-label { font-weight: 600; margin-right: 4px; }
|
||||||
|
.wpdo-eb-sep { color: #ccc; }
|
||||||
|
.wpdo-code-xs { font-size: 11px; }
|
||||||
|
|
||||||
|
.wpdo-eb-section { margin-bottom: 14px; }
|
||||||
|
.wpdo-eb-section-title { font-size: 12px; text-transform: uppercase; color: var(--color-text-secondary, #666); letter-spacing: .5px; font-weight: 600; }
|
||||||
|
.wpdo-eb-group { margin-top: 8px; }
|
||||||
|
.wpdo-eb-group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 3px; }
|
||||||
|
.wpdo-eb-group-name { font-size: 13px; font-weight: 500; }
|
||||||
|
.wpdo-eb-group-meta { font-size: 12px; color: var(--color-text-secondary, #666); }
|
||||||
|
.wpdo-eb-group-bar-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.wpdo-eb-bar-track { flex: 1; height: 8px; background: #e0e0e0; border-radius: 4px; overflow: hidden; }
|
||||||
|
.wpdo-eb-bar-fill { height: 100%; width: var(--wpdo-eb-pct, 0%); background: var(--wpdo-eb-color, #28a745); transition: width .4s ease; border-radius: 4px; }
|
||||||
|
.wpdo-eb-pct-label { font-size: 12px; min-width: 42px; text-align: right; color: #333; }
|
||||||
|
.wpdo-eb-key-hint { font-size: 11px; color: #888; margin-top: 2px; }
|
||||||
|
.wpdo-eb-actions { margin-top: 6px; }
|
||||||
|
|
||||||
|
.wpdo-eb-shadow-row { margin-bottom: 12px; padding: 8px; background: #f8f9fa; border-radius: 4px; font-size: 13px; }
|
||||||
|
.wpdo-eb-shadow-ok { color: #28a745; font-size: 12px; }
|
||||||
|
.wpdo-eb-shadow-warn { color: #dc3545; }
|
||||||
|
|
||||||
|
.wpdo-eb-promote-row { margin-bottom: 12px; font-size: 12px; color: var(--color-text-secondary, #666); }
|
||||||
|
.wpdo-eb-auto-ok { color: #155724; }
|
||||||
|
.wpdo-eb-auto-warn { color: #856404; }
|
||||||
|
.wpdo-eb-auto-badge { background: #d4edda; color: #155724; padding: 1px 5px; border-radius: 3px; margin-left: 4px; font-size: 11px; }
|
||||||
|
|
||||||
|
.wpdo-eb-info-row {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f0f4ff;
|
||||||
|
border-left: 3px solid #4f6ef7;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.wpdo-eb-button-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.wpdo-eb-btn-dim { opacity: .5; }
|
||||||
|
|
||||||
|
.wpdo-eb-footer { margin-top: 24px; padding: 20px; background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||||
|
.wpdo-eb-ol { margin-left: 20px; line-height: 1.9; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Stress-test table cells */
|
||||||
|
.wpdo-td { padding: 6px; }
|
||||||
|
.wpdo-td-muted { padding: 6px; color: var(--color-text-secondary, #666); }
|
||||||
|
.wpdo-td-val { padding: 6px; font-weight: 600; }
|
||||||
|
|
||||||
|
.wpdo-stats-table { width: 100%; font-size: 13px; margin-top: 10px; border-collapse: collapse; }
|
||||||
|
|
||||||
|
/* Full-width progress bar */
|
||||||
|
.wpdo-bar-track { height: 14px; background: var(--color-bg-tertiary, #e0e0e0); border-radius: 7px; overflow: hidden; }
|
||||||
|
.wpdo-bar-fill { height: 100%; background: linear-gradient(90deg, #28a745, #20c997); width: var(--wpdo-bar-width, 0%); transition: width 0.4s; }
|
||||||
|
.wpdo-bar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 13px; }
|
||||||
|
.wpdo-bar-pct { font-weight: 600; }
|
||||||
|
.wpdo-bar-section { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* Layouts */
|
||||||
|
.wpdo-grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; }
|
||||||
|
.wpdo-grid-4col { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; max-width: 720px; margin: 1rem 0; }
|
||||||
|
|
||||||
|
/* Notices */
|
||||||
|
.wpdo-notice-error {
|
||||||
|
margin: 14px 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.wpdo-notice-warn-inline { margin: 0; font-size: 12px; color: #856404; background: #fff3cd; padding: 6px 10px; border-radius: 4px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* Count badge */
|
||||||
|
.wpdo-count-danger { font-size: 18px; color: #dc3545; }
|
||||||
|
|
||||||
|
/* Form helpers */
|
||||||
|
.wpdo-th-main { width: 35%; }
|
||||||
|
.wpdo-label-block { display: block; margin-bottom: 6px; }
|
||||||
|
.wpdo-hr-section { margin: 18px 0; }
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.wpdo-list-disc { list-style: disc; padding-left: 1.5em; }
|
||||||
|
.wpdo-list-ol { margin-left: 20px; line-height: 1.9; font-size: 13px; }
|
||||||
|
.wpdo-list-ol-tight { margin: 6px 0 8px 22px; padding: 0; }
|
||||||
|
|
||||||
|
/* Max-width constraints */
|
||||||
|
.wpdo-max-520 { max-width: 520px; }
|
||||||
|
.wpdo-max-600 { max-width: 600px; }
|
||||||
|
.wpdo-max-720 { max-width: 720px; }
|
||||||
|
.wpdo-max-800 { max-width: 800px; }
|
||||||
|
|
||||||
|
/* Dashboard widget */
|
||||||
|
.wpdo-widget-intro { font-size: 1.1em; margin-bottom: 0.6em; }
|
||||||
|
.wpdo-widget-link-right { float: right; padding-top: 4px; }
|
||||||
|
.wpdo-widget-notice { margin: 0.6em 0; padding: 0.6em 0.8em; border-radius: 3px; }
|
||||||
|
.wpdo-widget-notice-warn { background: #fff8e5; border-left: 3px solid #dba617; }
|
||||||
|
.wpdo-widget-notice-error { background: #fef0f0; border-left: 3px solid #dc3232; }
|
||||||
|
.wpdo-widget-notice-info { background: #e5f5fa; border-left: 3px solid #00a0d2; }
|
||||||
|
.wpdo-widget-notice-neutral { background: #f6f7f7; border-left: 3px solid #2271b1; }
|
||||||
|
.wpdo-widget-notice-ok { background: #ecf7ed; border-left: 3px solid #46b450; }
|
||||||
|
|
||||||
|
/* Setup wizard */
|
||||||
|
.wpdo-setup-progress-track { background: #f0f0f1; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 2em; }
|
||||||
|
.wpdo-setup-progress-fill { background: #2271b1; height: 100%; width: var(--wpdo-progress-pct, 0%); transition: width 0.3s; }
|
||||||
|
.wpdo-setup-card-full { max-width: none; padding: 2em; }
|
||||||
|
.wpdo-setup-banner {
|
||||||
|
background: #f0f6fc;
|
||||||
|
border-left: 4px solid #2271b1;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
.wpdo-setup-banner-btn { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* HivePress / WC tabs */
|
||||||
|
.wpdo-cli-block { background: #f6f7f7; padding: 12px; border-left: 4px solid #2271b1; }
|
||||||
|
.wpdo-opacity-60 { opacity: .6; }
|
||||||
|
|
||||||
|
/* Text colour — strong danger / amber (stress-test status) */
|
||||||
|
.wpdo-text-danger { color: #dc3545; }
|
||||||
|
.wpdo-text-amber { color: #dba617; }
|
||||||
|
|
||||||
|
/* Inline code on white bg (mode-card context) */
|
||||||
|
.wpdo-code-white { background: #fff; padding: 2px 6px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* Mode info card (stress-test modal-status section) */
|
||||||
|
.wpdo-mode-card {
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-left-style: solid;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.wpdo-mode-card--ok { background: #e8f5e9; border-left-color: #28a745; }
|
||||||
|
.wpdo-mode-card--warn { background: #fff3cd; border-left-color: #dba617; }
|
||||||
|
|
||||||
|
/* Settings link margin */
|
||||||
|
.wpdo-ml-2 { margin-left: 8px; }
|
||||||
|
|
||||||
|
/* Matrix / details table helpers */
|
||||||
|
.wpdo-summary-toggle { cursor: pointer; color: #0073aa; font-weight: 600; }
|
||||||
|
.wpdo-table-inset { background: #fff; font-size: 12px; }
|
||||||
|
.wpdo-tr-header th, .wpdo-tr-header td { background: #f0f0f0; }
|
||||||
|
.wpdo-tr-highlight { background: #fff8e1; }
|
||||||
|
.wpdo-tr-success { background: #e8f5e9; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Entity Bridge JS-hook class static styles (admin.php, updated by wpdo-entity-bridge.js) */
|
||||||
|
.wpdo-entity-bridge-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.wpdo-mode-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color var(--duration-normal, 200ms) var(--ease-default, ease),
|
||||||
|
color var(--duration-normal, 200ms) var(--ease-default, ease);
|
||||||
|
}
|
||||||
|
/* JS sets className = 'wpdo-mode-badge wpdo-mode-disabled' etc. */
|
||||||
|
.wpdo-mode-badge.wpdo-mode-disabled { background: #e0e0e0; color: #444; }
|
||||||
|
.wpdo-mode-badge.wpdo-mode-dual-write { background: #d4edda; color: #155724; }
|
||||||
|
.wpdo-mode-badge.wpdo-mode-shadow-read { background: #fff3cd; color: #856404; }
|
||||||
|
.wpdo-mode-badge.wpdo-mode-aeav-only { background: #cce5ff; color: #004085; }
|
||||||
|
|
||||||
|
.wpdo-pipeline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.wpdo-pipeline-dot { padding: 2px 8px; border-radius: 3px; background: #f0f0f0; color: #666; }
|
||||||
|
.wpdo-pipeline-inactive { background: #f0f0f0; color: #666; }
|
||||||
|
|
||||||
|
.wpdo-native-counts {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.wpdo-native-counts-label { font-weight: 600; margin-right: 4px; }
|
||||||
|
.wpdo-native-sep { color: #ccc; }
|
||||||
|
|
||||||
|
.wpdo-mig-status { font-size: 11px; padding: 1px 6px; border-radius: 3px; background: #f0f0f0; }
|
||||||
|
|
||||||
|
.wpdo-cov-pct { font-size: 12px; min-width: 42px; text-align: right; color: #333; }
|
||||||
|
.wpdo-cov-bar-fill { height: 100%; width: var(--wpdo-cov-pct, 0%); background: var(--wpdo-cov-color, #28a745); transition: width .4s ease; border-radius: 4px; }
|
||||||
|
|
||||||
|
.wpdo-recommendation {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f0f4ff;
|
||||||
|
border-left: 3px solid #4f6ef7;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings tab helpers */
|
||||||
|
.wpdo-h3-section { margin-top: 2em; }
|
||||||
|
.wpdo-h4-mb { margin-bottom: 6px; }
|
||||||
|
.wpdo-table-mb { margin-bottom: 14px; }
|
||||||
|
.wpdo-table-mb-sm { margin-bottom: 8px; }
|
||||||
|
.wpdo-pre-code { background: #f0f0f1; padding: 1em; overflow: auto; max-height: 400px; }
|
||||||
|
.wpdo-details-section {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
border: 1px solid #c3c4c7;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.6em 1em;
|
||||||
|
}
|
||||||
|
.wpdo-label-inline { display: inline-block; margin-right: 1em; margin-bottom: 0.3em; }
|
||||||
|
.wpdo-description-mt { margin-top: 1em; }
|
||||||
|
.wpdo-description-sm { margin-top: 0.25em; }
|
||||||
|
.wpdo-description-xs { margin-top: 0.5em; font-size: 12px; }
|
||||||
|
.wpdo-text-ok { color: #46b450; font-weight: bold; }
|
||||||
|
.wpdo-text-err { color: #dc3232; font-weight: bold; }
|
||||||
|
.wpdo-text-muted { color: #666; font-size: 12px; }
|
||||||
|
.wpdo-text-green { color: #155724; }
|
||||||
|
.wpdo-text-olive { color: #856404; }
|
||||||
|
.wpdo-text-green-bold { color: #28a745; font-weight: 600; }
|
||||||
|
.wpdo-text-red-bold { color: #dc3545; font-weight: 600; }
|
||||||
|
.wpdo-code-sm { margin-left: 8px; font-size: 11px; }
|
||||||
|
.wpdo-badge-mode { display: inline-block; margin-left: 8px; padding: 2px 8px; border-radius: 3px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Advisor confidence bar (Classifier tab) */
|
||||||
|
.wpdo-advisor-bar-track {
|
||||||
|
display: inline-block;
|
||||||
|
background: #f0f0f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 80px;
|
||||||
|
height: 18px;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.wpdo-advisor-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: var(--wpdo-adv-color, #c3c4c7);
|
||||||
|
width: var(--wpdo-adv-width, 0%);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.wpdo-advisor-bar-label {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EB card group section: empty state warning */
|
||||||
|
.wpdo-eb-warn-box {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #856404;
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional utilities */
|
||||||
|
.wpdo-text-dark-red { color: #721c24; }
|
||||||
|
.wpdo-text-red { color: #dc3545; }
|
||||||
|
.wpdo-ml-2 { margin-left: 8px; }
|
||||||
|
.wpdo-form-table-mt0 { margin-top: 0; }
|
||||||
|
.wpdo-badge-mode.wpdo-mode-disabled { background: #e0e0e0; color: #444; }
|
||||||
|
.wpdo-badge-mode.wpdo-mode-dual-write { background: #d4edda; color: #155724; }
|
||||||
|
.wpdo-badge-mode.wpdo-mode-shadow-read { background: #fff3cd; color: #856404; }
|
||||||
|
.wpdo-badge-mode.wpdo-mode-aeav-only { background: #cce5ff; color: #004085; }
|
||||||
|
.wpdo-summary-section { cursor: pointer; font-weight: 600; font-size: 1.1em; }
|
||||||
|
.wpdo-notice-warn-desc { margin-top: 8px; color: #856404; background: #fff3cd; padding: 8px 12px; border-radius: 4px; }
|
||||||
|
.wpdo-card--light { background: #f6f7f7; }
|
||||||
|
.wpdo-h3-sm { font-size: 14px; }
|
||||||
|
|
||||||
|
/* ── Settings section cards (task E) ─────────────────────────────── */
|
||||||
|
.wpdo-settings-section {
|
||||||
|
background: var(--color-surface-card, #fbf7f0);
|
||||||
|
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||||
|
border-radius: var(--radius-md, 12px);
|
||||||
|
padding: var(--space-4, 16px) var(--space-6, 24px);
|
||||||
|
margin-bottom: var(--space-5, 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wpdo-section-save-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
padding-top: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wpdo-section-save-status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-success, #5a8c5a);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-normal, 200ms) var(--ease-default, ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wpdo-section-save-status.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline form wrapper — the GET → POST hardening (v1.0.0) turned several
|
||||||
|
action links into single-button forms; they must not break the row flow. */
|
||||||
|
.wpdo-form-inline { display: inline; }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* - Confirms options + checkbox before starting.
|
* - Confirms options + checkbox before starting.
|
||||||
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
|
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
|
||||||
* - Polls /status every 500ms while job is active.
|
* - Polls /status every 2s while job is active.
|
||||||
* - Streams log lines with fade-in; live-updates ratio + progress bar.
|
* - Streams log lines with fade-in; live-updates ratio + progress bar.
|
||||||
*
|
*
|
||||||
* @since 2.8.0
|
* @since 2.8.0
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 2000;
|
||||||
const config = window.wpdoMigrationWizard || {};
|
const config = window.wpdoMigrationWizard || {};
|
||||||
const i18n = config.i18n || {};
|
const i18n = config.i18n || {};
|
||||||
const restUrl = (config.restUrl || '').replace(/\/$/, '');
|
const restUrl = (config.restUrl || '').replace(/\/$/, '');
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* WP Data Optimizer — Settings per-section instant save.
|
||||||
|
*
|
||||||
|
* Each .wpdo-settings-section card has a "儲存此區塊" button that posts only
|
||||||
|
* that section's fields via AJAX, updating options without a full page reload.
|
||||||
|
*
|
||||||
|
* @since 3.0.2
|
||||||
|
*/
|
||||||
|
/* global wpdoSettings */
|
||||||
|
( function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if ( typeof wpdoSettings === 'undefined' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ajaxUrl, nonce, i18n } = wpdoSettings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect form fields within a section into a FormData object.
|
||||||
|
* Unchecked checkboxes are intentionally omitted (server does isset() ? '1' : '0').
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} section
|
||||||
|
* @returns {FormData}
|
||||||
|
*/
|
||||||
|
function collectFields( section ) {
|
||||||
|
const fd = new FormData();
|
||||||
|
section.querySelectorAll( 'input, select, textarea' ).forEach( ( el ) => {
|
||||||
|
if ( ! el.name ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( el.type === 'checkbox' ) {
|
||||||
|
if ( el.checked ) {
|
||||||
|
fd.append( el.name, el.value );
|
||||||
|
}
|
||||||
|
} else if ( el.type === 'radio' ) {
|
||||||
|
if ( el.checked ) {
|
||||||
|
fd.append( el.name, el.value );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fd.append( el.name, el.value );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a transient status message, then fade it out after 2.5s.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} statusEl
|
||||||
|
* @param {string} message
|
||||||
|
* @param {boolean} isError
|
||||||
|
*/
|
||||||
|
function showStatus( statusEl, message, isError ) {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
|
||||||
|
statusEl.classList.add( 'visible' );
|
||||||
|
clearTimeout( statusEl._wpdo_timer );
|
||||||
|
statusEl._wpdo_timer = setTimeout( () => {
|
||||||
|
statusEl.classList.remove( 'visible' );
|
||||||
|
}, 2500 );
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener( 'click', function ( e ) {
|
||||||
|
const btn = e.target.closest( '.wpdo-section-save' );
|
||||||
|
if ( ! btn ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const section = btn.closest( '.wpdo-settings-section' );
|
||||||
|
const statusEl = btn.nextElementSibling;
|
||||||
|
const sectionKey = section ? section.dataset.section : '';
|
||||||
|
if ( ! sectionKey ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLabel = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = i18n.saving;
|
||||||
|
|
||||||
|
const fd = collectFields( section );
|
||||||
|
fd.append( 'action', 'wpdo_save_settings_section' );
|
||||||
|
fd.append( '_ajax_nonce', nonce );
|
||||||
|
fd.append( 'section', sectionKey );
|
||||||
|
|
||||||
|
fetch( ajaxUrl, {
|
||||||
|
method : 'POST',
|
||||||
|
credentials : 'same-origin',
|
||||||
|
body : fd,
|
||||||
|
} )
|
||||||
|
.then( ( r ) => r.json() )
|
||||||
|
.then( ( data ) => {
|
||||||
|
if ( data.success ) {
|
||||||
|
showStatus( statusEl, i18n.saved, false );
|
||||||
|
} else {
|
||||||
|
showStatus( statusEl, i18n.error, true );
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
.catch( () => {
|
||||||
|
showStatus( statusEl, i18n.error, true );
|
||||||
|
} )
|
||||||
|
.finally( () => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalLabel;
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} )();
|
||||||
+406
-300
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: native postmeta count for dashboard widget
|
|
||||||
/**
|
/**
|
||||||
* TMDO_Dashboard_Widget — wp-admin home dashboard widget (v2.4.0 M9).
|
* TMDO_Dashboard_Widget — wp-admin home dashboard widget (v2.4.0 M9).
|
||||||
*
|
*
|
||||||
@@ -15,6 +14,10 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: native postmeta count for dashboard widget
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function render(): void {
|
public static function render(): void {
|
||||||
$page_url = admin_url( 'tools.php?page=wp-data-optimizer' );
|
$page_url = admin_url( 'tools.php?page=wp-data-optimizer' );
|
||||||
$bridge_url = add_query_arg( 'tab', 'entity-bridge', $page_url );
|
$bridge_url = add_query_arg( 'tab', 'entity-bridge', $page_url );
|
||||||
|
|
||||||
$status = self::compute_status();
|
$status = self::compute_status();
|
||||||
@@ -76,7 +79,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
.wpdo-widget-actions a { margin-right: 0.5em; }
|
.wpdo-widget-actions a { margin-right: 0.5em; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<p style="font-size: 1.1em; margin-bottom: 0.6em;">
|
<p class="wpdo-widget-intro">
|
||||||
<span class="wpdo-widget-light <?php echo esc_attr( $light['class'] ); ?>"></span>
|
<span class="wpdo-widget-light <?php echo esc_attr( $light['class'] ); ?>"></span>
|
||||||
<strong><?php echo esc_html( $light['label'] ); ?></strong>
|
<strong><?php echo esc_html( $light['label'] ); ?></strong>
|
||||||
<?php if ( $status['streak'] > 0 ) : ?>
|
<?php if ( $status['streak'] > 0 ) : ?>
|
||||||
@@ -92,7 +95,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<?php if ( '' !== $status['summary_msg'] ) : ?>
|
<?php if ( '' !== $status['summary_msg'] ) : ?>
|
||||||
<p class="description" style="margin-bottom: 0.8em;"><?php echo esc_html( $status['summary_msg'] ); ?></p>
|
<p class="description wpdo-mb-2"><?php echo esc_html( $status['summary_msg'] ); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="wpdo-widget-stats">
|
<div class="wpdo-widget-stats">
|
||||||
@@ -110,7 +113,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
<strong><?php echo esc_html( (string) (int) $status['conflicts'] ); ?></strong>
|
<strong><?php echo esc_html( (string) (int) $status['conflicts'] ); ?></strong>
|
||||||
<?php if ( (int) $status['conflicts'] > 0 ) : ?>
|
<?php if ( (int) $status['conflicts'] > 0 ) : ?>
|
||||||
<a href="<?php echo esc_url( add_query_arg( 'tab', 'conflicts', $page_url ) ); ?>"
|
<a href="<?php echo esc_url( add_query_arg( 'tab', 'conflicts', $page_url ) ); ?>"
|
||||||
style="margin-left:0.5em;"><?php esc_html_e( '查看', '2meet-data-optimizer' ); ?></a>
|
class="wpdo-mt-1"><?php esc_html_e( '查看', '2meet-data-optimizer' ); ?></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +140,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
if ( $actionable_count > 0 ) :
|
if ( $actionable_count > 0 ) :
|
||||||
$ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url );
|
$ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url );
|
||||||
?>
|
?>
|
||||||
<p style="margin: 0.6em 0; padding: 0.5em 0.7em; background: #fff8e5; border-left: 3px solid #dba617; border-radius: 3px;">
|
<p class="wpdo-widget-notice wpdo-widget-notice-warn">
|
||||||
🤖
|
🤖
|
||||||
<?php
|
<?php
|
||||||
printf(
|
printf(
|
||||||
@@ -159,7 +162,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) :
|
if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) :
|
||||||
$wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url );
|
$wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url );
|
||||||
?>
|
?>
|
||||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #fef0f0; border-left: 3px solid #dc3232; border-radius: 3px;">
|
<p class="wpdo-widget-notice wpdo-widget-notice-error">
|
||||||
🔴
|
🔴
|
||||||
<?php
|
<?php
|
||||||
/* translators: 1: EAV row count, 2: group count, 3: ratio. */
|
/* translators: 1: EAV row count, 2: group count, 3: ratio. */
|
||||||
@@ -174,7 +177,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
<a href="<?php echo esc_url( $wizard_url ); ?>"><strong><?php esc_html_e( 'User 遷移精靈 →', '2meet-data-optimizer' ); ?></strong></a>
|
<a href="<?php echo esc_url( $wizard_url ); ?>"><strong><?php esc_html_e( 'User 遷移精靈 →', '2meet-data-optimizer' ); ?></strong></a>
|
||||||
</p>
|
</p>
|
||||||
<?php elseif ( 'running' === ( $attn['job_state'] ?? '' ) ) : ?>
|
<?php elseif ( 'running' === ( $attn['job_state'] ?? '' ) ) : ?>
|
||||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #e5f5fa; border-left: 3px solid #00a0d2; border-radius: 3px;">
|
<p class="wpdo-widget-notice wpdo-widget-notice-info">
|
||||||
⏳ <?php esc_html_e( 'User 遷移精靈正在執行中 —', '2meet-data-optimizer' ); ?>
|
⏳ <?php esc_html_e( 'User 遷移精靈正在執行中 —', '2meet-data-optimizer' ); ?>
|
||||||
<a href="<?php echo esc_url( add_query_arg( 'tab', 'migration-wizard', $page_url ) ); ?>"><?php esc_html_e( '查看進度', '2meet-data-optimizer' ); ?></a>
|
<a href="<?php echo esc_url( add_query_arg( 'tab', 'migration-wizard', $page_url ) ); ?>"><?php esc_html_e( '查看進度', '2meet-data-optimizer' ); ?></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -192,7 +195,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
number_format_i18n( (int) $gc['total'] )
|
number_format_i18n( (int) $gc['total'] )
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #f6f7f7; border-left: 3px solid #2271b1; border-radius: 3px;">
|
<p class="wpdo-widget-notice wpdo-widget-notice-neutral">
|
||||||
🧹
|
🧹
|
||||||
<?php
|
<?php
|
||||||
/* translators: 1: total rows, 2: transients, 3: wp_old_date, 4: edit_locks */
|
/* translators: 1: total rows, 2: transients, 3: wp_old_date, 4: edit_locks */
|
||||||
@@ -205,12 +208,12 @@ class TMDO_Dashboard_Widget {
|
|||||||
esc_html( number_format_i18n( (int) $gc['edit_locks'] ) )
|
esc_html( number_format_i18n( (int) $gc['edit_locks'] ) )
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<form method="post" action="<?php echo esc_url( $page_url ); ?>" style="display:inline">
|
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
|
||||||
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
|
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
|
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
|
||||||
<button type="submit" class="button-link"
|
<button type="submit" class="button-link"
|
||||||
onclick="return confirm(<?php echo wp_json_encode( $confirm_msg ); ?>);">
|
onclick="return confirm(<?php echo wp_json_encode( $confirm_msg ); ?>);">
|
||||||
<strong><?php esc_html_e( '一鍵清理 →', '2meet-data-optimizer' ); ?></strong>
|
<strong><?php esc_html_e( '一鍵清理 →', '2meet-data-optimizer' ); ?></strong>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</p>
|
</p>
|
||||||
@@ -243,7 +246,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
'%2$s'
|
'%2$s'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: <?php echo esc_attr( $post_bg ); ?>; border-left: 3px solid <?php echo esc_attr( $post_color ); ?>; border-radius: 3px;">
|
<p class="wpdo-widget-notice <?php echo esc_attr( $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok' ); ?>">
|
||||||
📦
|
📦
|
||||||
<?php
|
<?php
|
||||||
if ( $total_eav > 0 ) {
|
if ( $total_eav > 0 ) {
|
||||||
@@ -273,7 +276,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="wpdo-widget-actions">
|
<div class="wpdo-widget-actions">
|
||||||
<form method="post" action="<?php echo esc_url( $page_url ); ?>" style="display:inline">
|
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
|
||||||
<input type="hidden" name="wpdo_run_health" value="1">
|
<input type="hidden" name="wpdo_run_health" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_run_health' ); ?>
|
<?php wp_nonce_field( 'wpdo_run_health' ); ?>
|
||||||
<button type="submit" class="button button-small button-primary"><?php esc_html_e( '跑健康檢查', '2meet-data-optimizer' ); ?></button>
|
<button type="submit" class="button button-small button-primary"><?php esc_html_e( '跑健康檢查', '2meet-data-optimizer' ); ?></button>
|
||||||
@@ -281,12 +284,12 @@ class TMDO_Dashboard_Widget {
|
|||||||
<a class="button button-small" href="<?php echo esc_url( $bridge_url ); ?>">
|
<a class="button button-small" href="<?php echo esc_url( $bridge_url ); ?>">
|
||||||
<?php esc_html_e( 'Entity Bridge', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( 'Entity Bridge', '2meet-data-optimizer' ); ?>
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="<?php echo esc_url( $page_url ); ?>" style="display:inline">
|
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
|
||||||
<input type="hidden" name="wpdo_create_snapshot" value="1">
|
<input type="hidden" name="wpdo_create_snapshot" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_create_snapshot' ); ?>
|
<?php wp_nonce_field( 'wpdo_create_snapshot' ); ?>
|
||||||
<button type="submit" class="button button-small"><?php esc_html_e( '建立快照', '2meet-data-optimizer' ); ?></button>
|
<button type="submit" class="button button-small"><?php esc_html_e( '建立快照', '2meet-data-optimizer' ); ?></button>
|
||||||
</form>
|
</form>
|
||||||
<a href="<?php echo esc_url( $page_url ); ?>" style="float: right; padding-top: 4px;">
|
<a href="<?php echo esc_url( $page_url ); ?>" class="wpdo-widget-link-right">
|
||||||
<?php esc_html_e( '完整儀表板 →', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '完整儀表板 →', '2meet-data-optimizer' ); ?>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Admin notice for the deprecated in-house partner AddOns.
|
||||||
|
*
|
||||||
|
* The AddOns for 2meet's own plugins (bookings, collab, hub, infocards,
|
||||||
|
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
|
||||||
|
* on the partner's behalf. Partner plugins now register themselves with the
|
||||||
|
* TMDO_Anti_EAV_Aware trait (docs/PARTNER-REGISTRATION.md), so these AddOns
|
||||||
|
* are deprecated and will be removed. The SSO data needs no AddOn at all:
|
||||||
|
* the hub, spoke and SSO AddOns go without replacement (the `sso` group gave
|
||||||
|
* no measurable gain over usermeta) once its values are written back.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns admins who still run one of the deprecated partner AddOns.
|
||||||
|
*/
|
||||||
|
final class TMDO_Deprecated_Addons_Notice {
|
||||||
|
|
||||||
|
/** In-house plugins whose AddOn is replaced by partner self-registration. */
|
||||||
|
private const SLUGS = array( 'bookings', 'collab', 'hub', 'infocards', 'mobile-bridge', 'playlist', 'quotation', 'spoke', 'sso' );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the notice.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugs of the deprecated AddOns active on this site (or network-wide).
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function active(): array {
|
||||||
|
$active = (array) get_option( 'active_plugins', array() );
|
||||||
|
if ( is_multisite() ) {
|
||||||
|
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
|
||||||
|
}
|
||||||
|
return array_values(
|
||||||
|
array_filter(
|
||||||
|
self::SLUGS,
|
||||||
|
static fn( string $slug ): bool => in_array( "2meet-data-optimizer-{$slug}-addon/2meet-data-optimizer-{$slug}-addon.php", $active, true )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the notice for administrators when any deprecated AddOn is active.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$active = self::active();
|
||||||
|
if ( empty( $active ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = implode( ', ', array_map( static fn( string $slug ): string => "2meet-data-optimizer-{$slug}-addon", $active ) );
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p><p>%s</p></div>',
|
||||||
|
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||||
|
esc_html(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: comma-separated AddOn plugin slugs */
|
||||||
|
__( '以下 AddOn 已淘汰:%s。對應的 2meet 外掛改為自行向核心註冊資料表與欄位,AddOn 之後會移除。', '2meet-data-optimizer' ),
|
||||||
|
$names
|
||||||
|
)
|
||||||
|
),
|
||||||
|
esc_html__( '改寫方式見 2meet-data-optimizer 外掛內的 docs/PARTNER-REGISTRATION.md;partner 外掛完成自行註冊後即可停用對應 AddOn。', '2meet-data-optimizer' )
|
||||||
|
);
|
||||||
|
if ( array_intersect( $active, array( 'hub', 'spoke', 'sso' ) ) ) {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||||
|
esc_html__( 'Hub / Spoke / SSO AddOn 不需替代:2meet-hub-core 與 2meet-spoke-sso 會自行註冊資料表,SSO 值存回 usermeta 即可。停用前先執行 wp tmdo bridge-restore-native user --group=sso --retire。', '2meet-data-optimizer' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -104,8 +106,8 @@ class TMDO_Help_Tabs {
|
|||||||
*/
|
*/
|
||||||
private static function content_dashboard(): string {
|
private static function content_dashboard(): string {
|
||||||
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
|
||||||
@@ -121,7 +123,7 @@ class TMDO_Help_Tabs {
|
|||||||
*/
|
*/
|
||||||
private static function content_zones(): string {
|
private static function content_zones(): string {
|
||||||
return '<p>' . esc_html__( '4 個 Zone 對應不同存取頻率與保留需求:', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( '4 個 Zone 對應不同存取頻率與保留需求:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li><strong>Hot</strong> — ' . esc_html__( '高頻索引欄位,如 listing 的 price / location。獨立 column + index,WP_Query 可 JOIN。', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>Hot</strong> — ' . esc_html__( '高頻索引欄位,如 listing 的 price / location。獨立 column + index,WP_Query 可 JOIN。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>Warm</strong> — ' . esc_html__( 'TTL 暫存(如 view count、cache stats)。固定表 wp_wpdo_warm 含 expires_at。', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>Warm</strong> — ' . esc_html__( 'TTL 暫存(如 view count、cache stats)。固定表 wp_wpdo_warm 含 expires_at。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>Cold</strong> — ' . esc_html__( '低頻 meta(settings / preferences)。讀寫透過 interceptor 攔截後保持 EAV 形式。', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>Cold</strong> — ' . esc_html__( '低頻 meta(settings / preferences)。讀寫透過 interceptor 攔截後保持 EAV 形式。', '2meet-data-optimizer' ) . '</li>'
|
||||||
@@ -137,7 +139,7 @@ class TMDO_Help_Tabs {
|
|||||||
*/
|
*/
|
||||||
private static function content_classifier(): string {
|
private static function content_classifier(): string {
|
||||||
return '<p>' . esc_html__( 'Classifier 分析 wp_postmeta 給每個 meta_key 一個 zone 建議:', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( 'Classifier 分析 wp_postmeta 給每個 meta_key 一個 zone 建議:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li><strong>Confidence</strong> — ' . esc_html__( '0.0~1.0,越高代表分類越確定。≥ 0.8 可放心採納,< 0.5 建議 manual review。', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>Confidence</strong> — ' . esc_html__( '0.0~1.0,越高代表分類越確定。≥ 0.8 可放心採納,< 0.5 建議 manual review。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>Reasons</strong> — ' . esc_html__( '說明為什麼建議這個 zone(access frequency / row count / TTL hints)。', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>Reasons</strong> — ' . esc_html__( '說明為什麼建議這個 zone(access frequency / row count / TTL hints)。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>Already-assigned</strong> — ' . esc_html__( '已透過 Schema_Registry 註冊的 meta_key 數量。', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>Already-assigned</strong> — ' . esc_html__( '已透過 Schema_Registry 註冊的 meta_key 數量。', '2meet-data-optimizer' ) . '</li>'
|
||||||
@@ -151,7 +153,7 @@ class TMDO_Help_Tabs {
|
|||||||
*/
|
*/
|
||||||
private static function content_snapshots(): string {
|
private static function content_snapshots(): string {
|
||||||
return '<p>' . esc_html__( '快照保留政策(v2.2.0):', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( '快照保留政策(v2.2.0):', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li>' . esc_html__( '預設 30 天 TTL,可在 wp wpdo snapshot create 時用 --retention-days 覆蓋。', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( '預設 30 天 TTL,可在 wp wpdo snapshot create 時用 --retention-days 覆蓋。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'pre_uninstall / pre_v2_upgrade triggers 受 size-cap 保護(不會被自動 evict)。', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'pre_uninstall / pre_v2_upgrade triggers 受 size-cap 保護(不會被自動 evict)。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( '檔案存於 wp-content/uploads/wpdo-backups/,含 .htaccess deny all + 每個檔 sha256 校驗。', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( '檔案存於 wp-content/uploads/wpdo-backups/,含 .htaccess deny all + 每個檔 sha256 校驗。', '2meet-data-optimizer' ) . '</li>'
|
||||||
@@ -169,9 +171,8 @@ class TMDO_Help_Tabs {
|
|||||||
private static function content_conflicts(): string {
|
private static function content_conflicts(): string {
|
||||||
return '<p>' . esc_html__( 'Hook 衝突偵測:當多個 plugin 在同一 WordPress 的 metadata filter 上掛 callback 時,可能造成資料寫入順序不確定 / 重複處理。', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( 'Hook 衝突偵測:當多個 plugin 在同一 WordPress 的 metadata filter 上掛 callback 時,可能造成資料寫入順序不確定 / 重複處理。', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
|
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '</li>'
|
|
||||||
. '</ul>'
|
. '</ul>'
|
||||||
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
|
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
|
||||||
}
|
}
|
||||||
@@ -182,8 +183,8 @@ class TMDO_Help_Tabs {
|
|||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function content_doctor(): string {
|
private static function content_doctor(): string {
|
||||||
return '<p>' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( '10 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ol style="padding-left: 1.5em;">'
|
. '<ol class="wpdo-list-disc">'
|
||||||
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>hook_conflicts</strong> — ' . esc_html__( '同上 conflicts tab', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>hook_conflicts</strong> — ' . esc_html__( '同上 conflicts tab', '2meet-data-optimizer' ) . '</li>'
|
||||||
@@ -191,6 +192,9 @@ class TMDO_Help_Tabs {
|
|||||||
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
||||||
|
. '<li><strong>entity_group_collisions</strong> — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '</li>'
|
||||||
|
. '<li><strong>orphaned_entity_groups</strong> — ' . esc_html__( '扁平表還在但已無外掛註冊的群組(已依保存的定義還原以維持可讀)', '2meet-data-optimizer' ) . '</li>'
|
||||||
|
. '<li><strong>missing_integration_addons</strong> — ' . esc_html__( 'HivePress / WooCommerce 已啟用但對應 AddOn 未啟用', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '</ol>'
|
. '</ol>'
|
||||||
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
||||||
}
|
}
|
||||||
@@ -202,7 +206,7 @@ class TMDO_Help_Tabs {
|
|||||||
*/
|
*/
|
||||||
private static function content_logs(): string {
|
private static function content_logs(): string {
|
||||||
return '<p>' . esc_html__( '日誌讀取:', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( '日誌讀取:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li>' . esc_html__( '每筆對應 wp_wpdo_errors 一行:module / zone / hook / message / timestamp。', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( '每筆對應 wp_wpdo_errors 一行:module / zone / hook / message / timestamp。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( '預設保留 90 天(wpdo_errors_gc daily cron 自動清)。', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( '預設保留 90 天(wpdo_errors_gc daily cron 自動清)。', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( '看 message 開頭 [WARN] 是 warning level(不影響運作但需注意)。', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( '看 message 開頭 [WARN] 是 warning level(不影響運作但需注意)。', '2meet-data-optimizer' ) . '</li>'
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Admin notice when HivePress / WooCommerce run without their AddOn.
|
||||||
|
*
|
||||||
|
* Core knows nothing about either plugin; their fields are only optimised by
|
||||||
|
* the matching AddOn. Without it, aeav_only data stays readable only through
|
||||||
|
* the orphan-group restore, and new data is not optimised at all.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns administrators about integrations whose AddOn is not active.
|
||||||
|
*/
|
||||||
|
final class TMDO_Missing_Addons_Notice {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the notice.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the notice for administrators when an integration lacks its AddOn.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$missing = TMDO_Compatibility::missing_integration_addons();
|
||||||
|
if ( empty( $missing ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $missing as $plugin => $addon ) {
|
||||||
|
$lines[] = sprintf( '%s → %s', $plugin, $addon );
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p></div>',
|
||||||
|
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||||
|
esc_html(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: "Plugin → AddOn" pairs */
|
||||||
|
__( '以下外掛已啟用,但對應的 AddOn 沒有啟用,它們的欄位不會被優化:%s。請啟用對應的 AddOn。', '2meet-data-optimizer' ),
|
||||||
|
implode( ';', $lines )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Admin notice when an entity is in aeav_only on a HivePress / WooCommerce site.
|
||||||
|
*
|
||||||
|
* Those plugins read native meta directly, so on an aeav_only entity they read
|
||||||
|
* stale (or, after cleanup, missing) values. The Promotion Guard refuses new
|
||||||
|
* promotions; sites promoted before it existed are only warned — demoting on
|
||||||
|
* read would leave them reading whatever native meta is left.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns administrators about guarded entities that are already in aeav_only.
|
||||||
|
*/
|
||||||
|
final class TMDO_Promotion_Guard_Notice {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the notice.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the notice for administrators when a guarded entity is in aeav_only.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$violations = TMDO_Promotion_Guard::violations();
|
||||||
|
if ( empty( $violations ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $violations as $entity_type => $integrations ) {
|
||||||
|
$lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type );
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
|
||||||
|
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||||
|
esc_html(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: "entity (integrations): command" list */
|
||||||
|
__( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ),
|
||||||
|
implode( ';', $lines )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: postmeta inventory for setup wizard
|
|
||||||
/**
|
/**
|
||||||
* TMDO_Setup_Wizard — First-run onboarding wizard (v2.3.0 M5).
|
* TMDO_Setup_Wizard — First-run onboarding wizard (v2.3.0 M5).
|
||||||
*
|
*
|
||||||
@@ -20,6 +19,10 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: postmeta inventory for setup wizard
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -103,7 +106,7 @@ class TMDO_Setup_Wizard {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function register_menu_page(): void {
|
public static function register_menu_page(): void {
|
||||||
add_submenu_page(
|
$hook_suffix = add_submenu_page(
|
||||||
'tools.php',
|
'tools.php',
|
||||||
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
||||||
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
||||||
@@ -111,6 +114,33 @@ class TMDO_Setup_Wizard {
|
|||||||
'wpdo-setup-wizard',
|
'wpdo-setup-wizard',
|
||||||
array( __CLASS__, 'render_page' )
|
array( __CLASS__, 'render_page' )
|
||||||
);
|
);
|
||||||
|
if ( $hook_suffix ) {
|
||||||
|
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_snapshot_post' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4 baseline snapshot (POST + nonce, prevents Referer leak — v3.3.x
|
||||||
|
* P1-6b hardening), handled on load-{page} so the redirect runs before any
|
||||||
|
* output.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function handle_snapshot_post(): void {
|
||||||
|
if ( ! isset( $_POST['wpdo_take_snapshot'] ) || ! TMDO_Capability::current_user_can_admin() || ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
check_admin_referer( self::NONCE_NAME );
|
||||||
|
$result = TMDO_Snapshot_Manager::create(
|
||||||
|
'manual',
|
||||||
|
array(),
|
||||||
|
array(
|
||||||
|
'notes' => 'wizard_baseline',
|
||||||
|
'retention_days' => 365,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&' . ( empty( $result['ok'] ) ? 'snap_failed=1' : 'snap_done=1' ) ) );
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +155,7 @@ class TMDO_Setup_Wizard {
|
|||||||
$step = max( 1, min( self::TOTAL_STEPS, absint( wp_unslash( $_GET['step'] ?? 1 ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
$step = max( 1, min( self::TOTAL_STEPS, absint( wp_unslash( $_GET['step'] ?? 1 ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||||
$completed = '1' === get_option( self::OPT_COMPLETED );
|
$completed = '1' === get_option( self::OPT_COMPLETED );
|
||||||
?>
|
?>
|
||||||
<div class="wrap" style="max-width: 800px;">
|
<div class="wrap wpdo-max-800">
|
||||||
<p>
|
<p>
|
||||||
<a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
<a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
||||||
← <?php esc_html_e( '返回 WP Data Optimizer', '2meet-data-optimizer' ); ?>
|
← <?php esc_html_e( '返回 WP Data Optimizer', '2meet-data-optimizer' ); ?>
|
||||||
@@ -134,11 +164,11 @@ class TMDO_Setup_Wizard {
|
|||||||
<h1><?php esc_html_e( 'WP Data Optimizer — 設定嚮導', '2meet-data-optimizer' ); ?></h1>
|
<h1><?php esc_html_e( 'WP Data Optimizer — 設定嚮導', '2meet-data-optimizer' ); ?></h1>
|
||||||
|
|
||||||
<?php if ( $completed ) : ?>
|
<?php if ( $completed ) : ?>
|
||||||
<div style="background:#f0f6fc; border-left:4px solid #2271b1; padding:0.75em 1em; margin-bottom:1em; display:flex; align-items:center; gap:1em;">
|
<div class="wpdo-setup-banner">
|
||||||
<span>✅ <?php esc_html_e( '嚮導已完成。你可以重新瀏覽任何步驟,或重設為全新執行。', '2meet-data-optimizer' ); ?></span>
|
<span>✅ <?php esc_html_e( '嚮導已完成。你可以重新瀏覽任何步驟,或重設為全新執行。', '2meet-data-optimizer' ); ?></span>
|
||||||
<a class="button button-secondary"
|
<a class="button button-secondary wpdo-setup-banner-btn"
|
||||||
href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_reset' ), self::NONCE_NAME ) ); ?>"
|
href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_reset' ), self::NONCE_NAME ) ); ?>"
|
||||||
style="white-space:nowrap;">
|
>
|
||||||
<?php esc_html_e( '重新執行精靈', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '重新執行精靈', '2meet-data-optimizer' ); ?>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,17 +181,17 @@ class TMDO_Setup_Wizard {
|
|||||||
<?php esc_html_e( '步', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '步', '2meet-data-optimizer' ); ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="background: #f0f0f1; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 2em;">
|
<div class="wpdo-setup-progress-track">
|
||||||
<div style="background: #2271b1; height: 100%; width: <?php echo (int) ( $step / self::TOTAL_STEPS * 100 ); ?>%; transition: width 0.3s;"></div>
|
<div class="wpdo-setup-progress-fill" style="--wpdo-progress-pct:<?php echo (int) ( $step / self::TOTAL_STEPS * 100 ); ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="max-width: none; padding: 2em;">
|
<div class="card wpdo-setup-card-full">
|
||||||
<?php call_user_func( array( __CLASS__, 'render_step_' . $step ) ); ?>
|
<?php call_user_func( array( __CLASS__, 'render_step_' . $step ) ); ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top: 2em;">
|
<p class="wpdo-mt-3">
|
||||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_dismiss' ), self::NONCE_NAME ) ); ?>"
|
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_dismiss' ), self::NONCE_NAME ) ); ?>"
|
||||||
style="color: #888; font-size: 0.9em;">
|
class="wpdo-text-skip">
|
||||||
<?php esc_html_e( '我熟悉了,跳過嚮導', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '我熟悉了,跳過嚮導', '2meet-data-optimizer' ); ?>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -182,7 +212,7 @@ class TMDO_Setup_Wizard {
|
|||||||
<p><?php esc_html_e( 'WPDO 解決的是 wp_postmeta 表「meta 爆炸」問題:當 postmeta 累積到數百萬行時,autoload 變大、JOIN 變慢、整站變慢。', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( 'WPDO 解決的是 wp_postmeta 表「meta 爆炸」問題:當 postmeta 累積到數百萬行時,autoload 變大、JOIN 變慢、整站變慢。', '2meet-data-optimizer' ); ?></p>
|
||||||
|
|
||||||
<h3><?php esc_html_e( '4 個 Zone 是什麼?', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '4 個 Zone 是什麼?', '2meet-data-optimizer' ); ?></h3>
|
||||||
<table class="widefat" style="margin-bottom: 1em;">
|
<table class="widefat wpdo-mb-2">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Zone</th><th><?php esc_html_e( '用途', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '舉例', '2meet-data-optimizer' ); ?></th></tr>
|
<tr><th>Zone</th><th><?php esc_html_e( '用途', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '舉例', '2meet-data-optimizer' ); ?></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -195,13 +225,13 @@ class TMDO_Setup_Wizard {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3><?php esc_html_e( '何時需要 WPDO?', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '何時需要 WPDO?', '2meet-data-optimizer' ); ?></h3>
|
||||||
<ul style="list-style: disc; padding-left: 1.5em;">
|
<ul class="wpdo-list-disc">
|
||||||
<li><?php esc_html_e( '✅ wp_postmeta 行數 > 100k 開始考慮', '2meet-data-optimizer' ); ?></li>
|
<li><?php esc_html_e( '✅ wp_postmeta 行數 > 100k 開始考慮', '2meet-data-optimizer' ); ?></li>
|
||||||
<li><?php esc_html_e( '✅ 行數 > 1M 強烈建議啟用', '2meet-data-optimizer' ); ?></li>
|
<li><?php esc_html_e( '✅ 行數 > 1M 強烈建議啟用', '2meet-data-optimizer' ); ?></li>
|
||||||
<li><?php esc_html_e( '⚠️ 小站台(< 10k 行)可以裝著但別啟用 module', '2meet-data-optimizer' ); ?></li>
|
<li><?php esc_html_e( '⚠️ 小站台(< 10k 行)可以裝著但別啟用 module', '2meet-data-optimizer' ); ?></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" class="wpdo-mt-3">
|
||||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||||
<input type="hidden" name="step" value="2">
|
<input type="hidden" name="step" value="2">
|
||||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:跑健診 →', '2meet-data-optimizer' ); ?></button>
|
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:跑健診 →', '2meet-data-optimizer' ); ?></button>
|
||||||
@@ -221,18 +251,18 @@ class TMDO_Setup_Wizard {
|
|||||||
$top_keys = $wpdb->get_results( "SELECT meta_key, COUNT(*) AS c FROM `{$wpdb->postmeta}` GROUP BY meta_key ORDER BY c DESC LIMIT 10", ARRAY_A ); // phpcs:ignore WordPress.DB
|
$top_keys = $wpdb->get_results( "SELECT meta_key, COUNT(*) AS c FROM `{$wpdb->postmeta}` GROUP BY meta_key ORDER BY c DESC LIMIT 10", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||||||
?>
|
?>
|
||||||
<h2><?php esc_html_e( '🔬 Baseline 健診', '2meet-data-optimizer' ); ?></h2>
|
<h2><?php esc_html_e( '🔬 Baseline 健診', '2meet-data-optimizer' ); ?></h2>
|
||||||
<table class="widefat striped" style="max-width: 600px; margin-bottom: 1em;">
|
<table class="widefat striped wpdo-max-600 wpdo-mb-2">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><th><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( number_format_i18n( $pm_count ) ); ?></strong></td></tr>
|
<tr><th><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( number_format_i18n( $pm_count ) ); ?></strong></td></tr>
|
||||||
<tr><th><?php esc_html_e( 'autoload 大小', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( size_format( $autoload_bytes, 1 ) ); ?></strong></td></tr>
|
<tr><th><?php esc_html_e( 'autoload 大小', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( size_format( $autoload_bytes, 1 ) ); ?></strong></td></tr>
|
||||||
<tr><th><?php esc_html_e( '建議啟用 WPDO?', '2meet-data-optimizer' ); ?></th>
|
<tr><th><?php esc_html_e( '建議啟用 WPDO?', '2meet-data-optimizer' ); ?></th>
|
||||||
<td>
|
<td>
|
||||||
<?php if ( $pm_count > 1_000_000 ) : ?>
|
<?php if ( $pm_count > 1_000_000 ) : ?>
|
||||||
<span style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '強烈建議', '2meet-data-optimizer' ); ?></span>
|
<span class="wpdo-text-success-em">✅ <?php esc_html_e( '強烈建議', '2meet-data-optimizer' ); ?></span>
|
||||||
<?php elseif ( $pm_count > 100_000 ) : ?>
|
<?php elseif ( $pm_count > 100_000 ) : ?>
|
||||||
<span style="color: #dba617; font-weight: bold;">🟡 <?php esc_html_e( '可以考慮', '2meet-data-optimizer' ); ?></span>
|
<span class="wpdo-text-warn-em">🟡 <?php esc_html_e( '可以考慮', '2meet-data-optimizer' ); ?></span>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<span style="color: #8c8f94;">⏸ <?php esc_html_e( '尚不必', '2meet-data-optimizer' ); ?></span>
|
<span class="wpdo-text-neutral">⏸ <?php esc_html_e( '尚不必', '2meet-data-optimizer' ); ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -249,7 +279,7 @@ class TMDO_Setup_Wizard {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" class="wpdo-mt-3">
|
||||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||||
<input type="hidden" name="step" value="3">
|
<input type="hidden" name="step" value="3">
|
||||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:看建議 →', '2meet-data-optimizer' ); ?></button>
|
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:看建議 →', '2meet-data-optimizer' ); ?></button>
|
||||||
@@ -275,7 +305,7 @@ class TMDO_Setup_Wizard {
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ( empty( $actionable ) ) : ?>
|
<?php if ( empty( $actionable ) ) : ?>
|
||||||
<div class="notice notice-info inline" style="padding: 1em;">
|
<div class="notice notice-info inline">
|
||||||
<p><?php esc_html_e( '目前環境暫無高 confidence 的 module 建議。可隨時前往「模組建議」tab 重新檢查。', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( '目前環境暫無高 confidence 的 module 建議。可隨時前往「模組建議」tab 重新檢查。', '2meet-data-optimizer' ); ?></p>
|
||||||
</div>
|
</div>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
@@ -313,13 +343,13 @@ class TMDO_Setup_Wizard {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<h3><?php esc_html_e( '黃金法則', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '黃金法則', '2meet-data-optimizer' ); ?></h3>
|
||||||
<ul style="list-style: disc; padding-left: 1.5em;">
|
<ul class="wpdo-list-disc">
|
||||||
<li><?php esc_html_e( '一次只推進 1 個 module,每階段觀察至少 24-48 小時。', '2meet-data-optimizer' ); ?></li>
|
<li><?php esc_html_e( '一次只推進 1 個 module,每階段觀察至少 24-48 小時。', '2meet-data-optimizer' ); ?></li>
|
||||||
<li><?php esc_html_e( '進 cutover 前一定要有 snapshot(FSM Guard 自動觸發)。', '2meet-data-optimizer' ); ?></li>
|
<li><?php esc_html_e( '進 cutover 前一定要有 snapshot(FSM Guard 自動觸發)。', '2meet-data-optimizer' ); ?></li>
|
||||||
<li><?php esc_html_e( '出事第一件事:rewind 該 module 到 idle(emergency 流程)。', '2meet-data-optimizer' ); ?></li>
|
<li><?php esc_html_e( '出事第一件事:rewind 該 module 到 idle(emergency 流程)。', '2meet-data-optimizer' ); ?></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" class="wpdo-mt-3">
|
||||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||||
<input type="hidden" name="step" value="4">
|
<input type="hidden" name="step" value="4">
|
||||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:建第一個 snapshot →', '2meet-data-optimizer' ); ?></button>
|
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:建第一個 snapshot →', '2meet-data-optimizer' ); ?></button>
|
||||||
@@ -340,10 +370,10 @@ class TMDO_Setup_Wizard {
|
|||||||
|
|
||||||
<h3><?php esc_html_e( '📦 Snapshot', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '📦 Snapshot', '2meet-data-optimizer' ); ?></h3>
|
||||||
<?php if ( $snapshot_taken ) : ?>
|
<?php if ( $snapshot_taken ) : ?>
|
||||||
<p style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '快照已建立。可隨時於「備份快照」tab 查看與管理。', '2meet-data-optimizer' ); ?></p>
|
<p class="wpdo-text-success-em">✅ <?php esc_html_e( '快照已建立。可隨時於「備份快照」tab 查看與管理。', '2meet-data-optimizer' ); ?></p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<p><?php esc_html_e( '我們會建立一個 baseline snapshot,命名為 wizard_baseline,保留 365 天。即使你日後沒做任何 destructive 操作,這也是一個 known-good 還原點。', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( '我們會建立一個 baseline snapshot,命名為 wizard_baseline,保留 365 天。即使你日後沒做任何 destructive 操作,這也是一個 known-good 還原點。', '2meet-data-optimizer' ); ?></p>
|
||||||
<form method="post" action="<?php echo esc_url( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4' ) ); ?>" style="display:inline">
|
<form method="post" action="<?php echo esc_url( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4' ) ); ?>" class="wpdo-form-inline">
|
||||||
<?php wp_nonce_field( self::NONCE_NAME ); ?>
|
<?php wp_nonce_field( self::NONCE_NAME ); ?>
|
||||||
<input type="hidden" name="wpdo_take_snapshot" value="1">
|
<input type="hidden" name="wpdo_take_snapshot" value="1">
|
||||||
<p>
|
<p>
|
||||||
@@ -353,20 +383,8 @@ class TMDO_Setup_Wizard {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<?php
|
<?php
|
||||||
// Take snapshot if action requested (POST + nonce, prevents Referer leak — v3.3.x P1-6b hardening).
|
// The snapshot itself is taken in handle_snapshot_post(), before any output.
|
||||||
if ( isset( $_POST['wpdo_take_snapshot'] ) && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||||
$result = TMDO_Snapshot_Manager::create(
|
|
||||||
'manual',
|
|
||||||
array(),
|
|
||||||
array(
|
|
||||||
'notes' => 'wizard_baseline',
|
|
||||||
'retention_days' => 365,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if ( ! empty( $result['ok'] ) ) {
|
|
||||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
|
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -389,7 +407,7 @@ class TMDO_Setup_Wizard {
|
|||||||
</p>
|
</p>
|
||||||
<p class="description"><?php esc_html_e( 'Cron 會跑 7 項 Site Health 檢查;critical 警告寫入 admin notice + audit log。', '2meet-data-optimizer' ); ?></p>
|
<p class="description"><?php esc_html_e( 'Cron 會跑 7 項 Site Health 檢查;critical 警告寫入 admin notice + audit log。', '2meet-data-optimizer' ); ?></p>
|
||||||
|
|
||||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" class="wpdo-mt-3">
|
||||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||||
<input type="hidden" name="step" value="5">
|
<input type="hidden" name="step" value="5">
|
||||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:完成 →', '2meet-data-optimizer' ); ?></button>
|
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:完成 →', '2meet-data-optimizer' ); ?></button>
|
||||||
@@ -412,14 +430,14 @@ class TMDO_Setup_Wizard {
|
|||||||
<h2><?php esc_html_e( '🎉 完成!', '2meet-data-optimizer' ); ?></h2>
|
<h2><?php esc_html_e( '🎉 完成!', '2meet-data-optimizer' ); ?></h2>
|
||||||
<p><?php esc_html_e( 'Setup wizard 已結束。下一步建議:', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( 'Setup wizard 已結束。下一步建議:', '2meet-data-optimizer' ); ?></p>
|
||||||
|
|
||||||
<ul style="list-style: disc; padding-left: 1.5em; line-height: 1.8;">
|
<ul class="wpdo-list-disc">
|
||||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ); ?>"><?php esc_html_e( '🌉 看 Entity Bridge 健康卡片', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( 'user / post / term / comment 4 entity 即時健康+模式狀態', '2meet-data-optimizer' ); ?></li>
|
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ); ?>"><?php esc_html_e( '🌉 看 Entity Bridge 健康卡片', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( 'user / post / term / comment 4 entity 即時健康+模式狀態', '2meet-data-optimizer' ); ?></li>
|
||||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=migration-wizard' ) ); ?>"><?php esc_html_e( '🪄 開 User / Post 遷移精靈', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '一鍵推進 disabled → dual_write → shadow_read → aeav_only', '2meet-data-optimizer' ); ?></li>
|
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=migration-wizard' ) ); ?>"><?php esc_html_e( '🪄 開 User / Post 遷移精靈', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '一鍵推進 disabled → dual_write → shadow_read → aeav_only', '2meet-data-optimizer' ); ?></li>
|
||||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=classifier' ) ); ?>"><?php esc_html_e( '🔍 跑 Classifier', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '看推薦 zone 配置', '2meet-data-optimizer' ); ?></li>
|
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=classifier' ) ); ?>"><?php esc_html_e( '🔍 跑 Classifier', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '看推薦 zone 配置', '2meet-data-optimizer' ); ?></li>
|
||||||
<li><a href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>"><?php esc_html_e( '🏥 WP Site Health', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '7 個 WPDO 健康檢查', '2meet-data-optimizer' ); ?></li>
|
<li><a href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>"><?php esc_html_e( '🏥 WP Site Health', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '7 個 WPDO 健康檢查', '2meet-data-optimizer' ); ?></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p style="margin-top: 2em;">
|
<p class="wpdo-mt-3">
|
||||||
<a class="button button-primary button-hero" href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
<a class="button button-primary button-hero" href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
||||||
<?php esc_html_e( '前往 WPDO 儀表板', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '前往 WPDO 儀表板', '2meet-data-optimizer' ); ?>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
|
||||||
/**
|
/**
|
||||||
* Comment Stress Test template (v2.13.1).
|
* Comment Stress Test template (v2.13.1).
|
||||||
*
|
*
|
||||||
@@ -16,16 +15,22 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$status_str = (string) ( $state['status'] ?? 'idle' );
|
$status_str = (string) ( $state['status'] ?? 'idle' );
|
||||||
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
|
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
|
||||||
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'disabled';
|
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'disabled';
|
||||||
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
|
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
|
||||||
$mode_optimal = 'aeav_only' === $current_mode;
|
$mode_optimal = 'aeav_only' === $current_mode;
|
||||||
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
$mode_text_cls = 'aeav_only' === $current_mode ? 'wpdo-text-success' : ( 'shadow_read' === $current_mode ? 'wpdo-text-amber' : 'wpdo-text-danger' );
|
||||||
|
$mode_card_cls = $mode_optimal ? 'ok' : 'warn';
|
||||||
|
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$flat_hp_review = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_hp_review" );
|
$flat_hp_review = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_hp_review" );
|
||||||
@@ -37,7 +42,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
<div class="wpdo-comment-stress-test-tab">
|
<div class="wpdo-comment-stress-test-tab">
|
||||||
<h2><?php esc_html_e( 'Comment Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
<h2><?php esc_html_e( 'Comment Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||||
|
|
||||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
<div class="wpdo-notice-error">
|
||||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||||
<?php esc_html_e( '會建立大量測試 comment 並 seed 對應 hp_review 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '會建立大量測試 comment 並 seed 對應 hp_review 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,8 +52,8 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Diagnose snapshot -->
|
<!-- Diagnose snapshot -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
<div class="wpdo-card wpdo-mt-3">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Comment Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '當前 Comment Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||||
<table class="widefat striped">
|
<table class="widefat striped">
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php esc_html_e( 'wp_comments', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'wp_comments', '2meet-data-optimizer' ); ?></td>
|
||||||
@@ -58,7 +63,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
|
<td><strong class="<?php echo esc_attr( $mode_text_cls ); ?>"><?php echo esc_html( $current_mode ); ?></strong></td>
|
||||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||||
<td><strong>1:<?php echo esc_html( (string) ( $total_comments > 0 ? round( $total_commentmeta / $total_comments, 2 ) : 0 ) ); ?></strong></td>
|
<td><strong>1:<?php echo esc_html( (string) ( $total_comments > 0 ? round( $total_commentmeta / $total_comments, 2 ) : 0 ) ); ?></strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -70,14 +75,14 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 comment 數', '2meet-data-optimizer' ); ?></td>
|
<td colspan="2"><?php esc_html_e( 'Stress 測試 comment 數', '2meet-data-optimizer' ); ?></td>
|
||||||
<td colspan="2"><strong id="wpdo-cst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?></strong></td>
|
<td colspan="2"><strong id="wpdo-cst-count" class="wpdo-count-danger"><?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?></strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 驗證反 EAV 優化指南 -->
|
<!-- 驗證反 EAV 優化指南 -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
<div class="wpdo-card wpdo-mode-card wpdo-mode-card--<?php echo esc_attr( $mode_card_cls ); ?>">
|
||||||
<h3 style="margin-top:0;font-size:14px;">
|
<h3 class="wpdo-mt-0 wpdo-h3-sm">
|
||||||
<?php if ( $mode_optimal ) : ?>
|
<?php if ( $mode_optimal ) : ?>
|
||||||
✅ <?php esc_html_e( 'Comment Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
✅ <?php esc_html_e( 'Comment Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
@@ -86,26 +91,26 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
printf(
|
printf(
|
||||||
/* translators: %s: current mode */
|
/* translators: %s: current mode */
|
||||||
esc_html__( 'Comment Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
esc_html__( 'Comment Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
|
'<code class="wpdo-code-white">' . esc_html( $current_mode ) . '</code>'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</h3>
|
</h3>
|
||||||
<p style="margin:8px 0 0 0;">
|
<p class="wpdo-mt-1">
|
||||||
<strong><?php esc_html_e( '想看 wp_commentmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
<strong><?php esc_html_e( '想看 wp_commentmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
||||||
</p>
|
</p>
|
||||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
<ol class="wpdo-list-ol-tight">
|
||||||
<li>
|
<li>
|
||||||
<?php
|
<?php
|
||||||
printf(
|
printf(
|
||||||
/* translators: 1: open code, 2: close code */
|
/* translators: 1: open code, 2: close code */
|
||||||
esc_html__( 'Comment mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Comment entity)', '2meet-data-optimizer' ),
|
esc_html__( 'Comment mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Comment entity)', '2meet-data-optimizer' ),
|
||||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
'<code class="wpdo-code-white">',
|
||||||
'</code>'
|
'</code>'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<?php if ( ! $mode_optimal ) : ?>
|
<?php if ( ! $mode_optimal ) : ?>
|
||||||
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small wpdo-ml-2">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -114,15 +119,15 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
<div class="wpdo-grid-2col">
|
||||||
|
|
||||||
<!-- Left: Configure & Start -->
|
<!-- Left: Configure & Start -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
<div class="wpdo-card">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||||
|
|
||||||
<table class="form-table" style="margin-top:0;">
|
<table class="form-table wpdo-mt-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:35%;"><label for="wpdo-cst-post-id"><?php esc_html_e( '目標 Post', '2meet-data-optimizer' ); ?></label></th>
|
<th class="wpdo-th-main"><label for="wpdo-cst-post-id"><?php esc_html_e( '目標 Post', '2meet-data-optimizer' ); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select id="wpdo-cst-post-id" class="regular-text">
|
<select id="wpdo-cst-post-id" class="regular-text">
|
||||||
<?php if ( empty( $posts ) ) : ?>
|
<?php if ( empty( $posts ) ) : ?>
|
||||||
@@ -148,11 +153,11 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
<tr>
|
<tr>
|
||||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<label style="display:block;margin-bottom:6px;">
|
<label class="wpdo-label-block">
|
||||||
<input type="radio" name="wpdo-cst-mode" value="fast" checked />
|
<input type="radio" name="wpdo-cst-mode" value="fast" checked />
|
||||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||||
</label>
|
</label>
|
||||||
<label style="display:block;">
|
<label class="wpdo-label-block">
|
||||||
<input type="radio" name="wpdo-cst-mode" value="realistic" />
|
<input type="radio" name="wpdo-cst-mode" value="realistic" />
|
||||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_comment + update_comment_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_comment + update_comment_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||||
</label>
|
</label>
|
||||||
@@ -178,11 +183,11 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Cleanup + Re-run benchmark -->
|
<!-- Right: Cleanup + Re-run benchmark -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
<div class="wpdo-card">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p>
|
<p>
|
||||||
<?php esc_html_e( '目前 stress test comment 數:', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '目前 stress test comment 數:', '2meet-data-optimizer' ); ?>
|
||||||
<strong id="wpdo-cst-count-mirror" style="font-size:18px;color:#dc3545;">
|
<strong id="wpdo-cst-count-mirror" class="wpdo-count-danger">
|
||||||
<?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?>
|
<?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -195,7 +200,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr style="margin:18px 0;" />
|
<hr class="wpdo-hr-section" />
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<button type="button" class="button" id="wpdo-cst-rerun-bench" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
|
<button type="button" class="button" id="wpdo-cst-rerun-bench" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
|
||||||
@@ -206,46 +211,46 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress card (live) -->
|
<!-- Progress card (live) -->
|
||||||
<div id="wpdo-cst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div style="margin-bottom:10px;">
|
<div class="wpdo-bar-section">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
<div class="wpdo-bar-header">
|
||||||
<span>
|
<span>
|
||||||
<strong id="wpdo-cst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
<strong id="wpdo-cst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
||||||
· <code id="wpdo-cst-pg-post-id">post #<?php echo esc_html( (string) ( $state['post_id'] ?? '' ) ); ?></code>
|
· <code id="wpdo-cst-pg-post-id">post #<?php echo esc_html( (string) ( $state['post_id'] ?? '' ) ); ?></code>
|
||||||
· <span id="wpdo-cst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
· <span id="wpdo-cst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||||
</span>
|
</span>
|
||||||
<span id="wpdo-cst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
<span id="wpdo-cst-pg-pct" class="wpdo-bar-pct"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
<div class="wpdo-bar-track">
|
||||||
<div id="wpdo-cst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
<div id="wpdo-cst-pg-bar" class="wpdo-bar-fill" style="--wpdo-bar-width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
<table class="wpdo-stats-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-cst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
<td class="wpdo-td-val"><span id="wpdo-cst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-cst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> comments/sec</td>
|
<td class="wpdo-td-val"><span id="wpdo-cst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> comments/sec</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-cst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
<td class="wpdo-td"><span id="wpdo-cst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-cst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
<td class="wpdo-td"><span id="wpdo-cst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-cst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
<td class="wpdo-td"><span id="wpdo-cst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-cst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
<td class="wpdo-td"><span id="wpdo-cst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benchmark report -->
|
<!-- Benchmark report -->
|
||||||
<div id="wpdo-cst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div id="wpdo-cst-bench-content"></div>
|
<div id="wpdo-cst-bench-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* @since 2.8.0
|
* @since 2.8.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -97,7 +99,7 @@ $failed = 'failed' === $state;
|
|||||||
|
|
||||||
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
|
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
|
||||||
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
|
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
|
||||||
<?php echo $active || $completed ? 'hidden' : ''; ?>>
|
<?php echo esc_attr( $active || $completed ? 'hidden' : '' ); ?>>
|
||||||
|
|
||||||
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
|
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
|
||||||
<div class="wpdo-mw-nothing-todo">
|
<div class="wpdo-mw-nothing-todo">
|
||||||
@@ -168,13 +170,13 @@ $failed = 'failed' === $state;
|
|||||||
|
|
||||||
<!-- Live progress panel (running state) ───────────────────────────── -->
|
<!-- Live progress panel (running state) ───────────────────────────── -->
|
||||||
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
|
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
|
||||||
<?php echo $active || $failed ? '' : 'hidden'; ?>>
|
<?php echo esc_attr( $active || $failed ? '' : 'hidden' ); ?>>
|
||||||
|
|
||||||
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
|
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
|
||||||
|
|
||||||
<div class="wpdo-mw-progress-bar">
|
<div class="wpdo-mw-progress-bar">
|
||||||
<div class="wpdo-mw-progress-fill" id="wpdo-mw-progress-fill"
|
<div class="wpdo-mw-progress-fill" id="wpdo-mw-progress-fill"
|
||||||
style="width:<?php echo esc_attr( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%"></div>
|
style="--wpdo-bar-width:<?php echo esc_attr( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%"></div>
|
||||||
<span class="wpdo-mw-progress-pct" id="wpdo-mw-progress-pct"><?php echo esc_html( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%</span>
|
<span class="wpdo-mw-progress-pct" id="wpdo-mw-progress-pct"><?php echo esc_html( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,7 +210,7 @@ $failed = 'failed' === $state;
|
|||||||
|
|
||||||
<!-- Completed state ───────────────────────────────────────────────── -->
|
<!-- Completed state ───────────────────────────────────────────────── -->
|
||||||
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
|
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
|
||||||
<?php echo $completed ? '' : 'hidden'; ?>>
|
<?php echo esc_attr( $completed ? '' : 'hidden' ); ?>>
|
||||||
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
|
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
|
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
|
||||||
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
|
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -213,10 +215,10 @@ $wpdo_format_msg = static function ( string $code ): string {
|
|||||||
?>
|
?>
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" style="display:inline">
|
<form method="post">
|
||||||
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
|
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
|
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
|
||||||
<button type="submit" class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>"
|
<button type="submit" class="button button-primary <?php echo esc_attr( (int) $garbage['total'] > 0 ? '' : 'disabled' ); ?>"
|
||||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
|
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
|
||||||
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
|
||||||
</button>
|
</button>
|
||||||
@@ -228,7 +230,7 @@ $wpdo_format_msg = static function ( string $code ): string {
|
|||||||
<h3><?php esc_html_e( '步驟 2:把 wp_postmeta 既有資料 backfill 至 flat 表(v2.9.3)', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '步驟 2:把 wp_postmeta 既有資料 backfill 至 flat 表(v2.9.3)', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p><?php esc_html_e( 'Idempotent — 重跑安全。每個 group 跑一次 bulk SQL pivot。', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( 'Idempotent — 重跑安全。每個 group 跑一次 bulk SQL pivot。', '2meet-data-optimizer' ); ?></p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" style="display:inline">
|
<form method="post">
|
||||||
<input type="hidden" name="wpdo_post_backfill_all" value="1">
|
<input type="hidden" name="wpdo_post_backfill_all" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_post_backfill_all' ); ?>
|
<?php wp_nonce_field( 'wpdo_post_backfill_all' ); ?>
|
||||||
<button type="submit" class="button button-primary"
|
<button type="submit" class="button button-primary"
|
||||||
@@ -243,7 +245,7 @@ $wpdo_format_msg = static function ( string $code ): string {
|
|||||||
<h3><?php esc_html_e( '步驟 3:把 legacy wpdo_hot_hp_listing 抄到 flat 表(v2.9.5)', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '步驟 3:把 legacy wpdo_hot_hp_listing 抄到 flat 表(v2.9.5)', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p><?php esc_html_e( '非破壞 — legacy hot 表保留作為 v3.0.0 rollback safety net。', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( '非破壞 — legacy hot 表保留作為 v3.0.0 rollback safety net。', '2meet-data-optimizer' ); ?></p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" style="display:inline">
|
<form method="post">
|
||||||
<input type="hidden" name="wpdo_post_cutover_legacy" value="1">
|
<input type="hidden" name="wpdo_post_cutover_legacy" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_post_cutover_legacy' ); ?>
|
<?php wp_nonce_field( 'wpdo_post_cutover_legacy' ); ?>
|
||||||
<button type="submit" class="button"
|
<button type="submit" class="button"
|
||||||
@@ -258,7 +260,7 @@ $wpdo_format_msg = static function ( string $code ): string {
|
|||||||
<h3><?php esc_html_e( '步驟 4:升級 mode 至 dual_write', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '步驟 4:升級 mode 至 dual_write', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p><?php esc_html_e( 'wp_postmeta 與 flat 表同時寫入。讀仍走 wp_postmeta(生產 safe)。建議至少觀察 24h 後再升級下一階。', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( 'wp_postmeta 與 flat 表同時寫入。讀仍走 wp_postmeta(生產 safe)。建議至少觀察 24h 後再升級下一階。', '2meet-data-optimizer' ); ?></p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" style="display:inline">
|
<form method="post">
|
||||||
<input type="hidden" name="wpdo_post_promote_dual_write" value="1">
|
<input type="hidden" name="wpdo_post_promote_dual_write" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_post_promote_dual_write' ); ?>
|
<?php wp_nonce_field( 'wpdo_post_promote_dual_write' ); ?>
|
||||||
<button type="submit" class="button"
|
<button type="submit" class="button"
|
||||||
@@ -273,7 +275,7 @@ $wpdo_format_msg = static function ( string $code ): string {
|
|||||||
<h3><?php esc_html_e( '步驟 5:升級 mode 至 aeav_only(最終 cutover)', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '步驟 5:升級 mode 至 aeav_only(最終 cutover)', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p><?php esc_html_e( 'flat 表成為 source-of-truth,讀寫都走 flat。完成後可 wp wpdo post-cleanup --confirm 清掉 wp_postmeta 已遷移 keys。', '2meet-data-optimizer' ); ?></p>
|
<p><?php esc_html_e( 'flat 表成為 source-of-truth,讀寫都走 flat。完成後可 wp wpdo post-cleanup --confirm 清掉 wp_postmeta 已遷移 keys。', '2meet-data-optimizer' ); ?></p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" style="display:inline">
|
<form method="post">
|
||||||
<input type="hidden" name="wpdo_post_promote_aeav" value="1">
|
<input type="hidden" name="wpdo_post_promote_aeav" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_post_promote_aeav' ); ?>
|
<?php wp_nonce_field( 'wpdo_post_promote_aeav' ); ?>
|
||||||
<button type="submit" class="button"
|
<button type="submit" class="button"
|
||||||
@@ -284,7 +286,7 @@ $wpdo_format_msg = static function ( string $code ): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wpdo-card" style="background: #f6f7f7;">
|
<div class="wpdo-card wpdo-card--light">
|
||||||
<h3><?php esc_html_e( '回退路徑(rollback)', '2meet-data-optimizer' ); ?></h3>
|
<h3><?php esc_html_e( '回退路徑(rollback)', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p>
|
<p>
|
||||||
<?php esc_html_e( '若任一步驟出問題,可下降 mode:', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '若任一步驟出問題,可下降 mode:', '2meet-data-optimizer' ); ?>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,7 @@ $post_type_options = array(
|
|||||||
<div class="wpdo-post-stress-test-tab">
|
<div class="wpdo-post-stress-test-tab">
|
||||||
<h2><?php esc_html_e( 'Post Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
<h2><?php esc_html_e( 'Post Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||||
|
|
||||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
<div class="wpdo-notice-error">
|
||||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||||
<?php esc_html_e( '會建立大量測試 post 並填滿對應 wp_postmeta + flat 表。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '會建立大量測試 post 並填滿對應 wp_postmeta + flat 表。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,10 +85,12 @@ $post_type_options = array(
|
|||||||
$current_post_mode = (string) ( $diagnose['mode'] ?? 'disabled' );
|
$current_post_mode = (string) ( $diagnose['mode'] ?? 'disabled' );
|
||||||
$mode_color = 'aeav_only' === $current_post_mode ? '#28a745' : '#dc3545';
|
$mode_color = 'aeav_only' === $current_post_mode ? '#28a745' : '#dc3545';
|
||||||
$mode_optimal = 'aeav_only' === $current_post_mode;
|
$mode_optimal = 'aeav_only' === $current_post_mode;
|
||||||
|
$mode_text_cls = $mode_optimal ? 'wpdo-text-success' : 'wpdo-text-danger';
|
||||||
|
$mode_card_cls = $mode_optimal ? 'ok' : 'warn';
|
||||||
$settings_tab_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
$settings_tab_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
||||||
?>
|
?>
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
<div class="wpdo-card wpdo-mt-3">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Post Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '當前 Post Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||||
<table class="widefat striped">
|
<table class="widefat striped">
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></td>
|
||||||
@@ -98,18 +102,18 @@ $post_type_options = array(
|
|||||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||||
<td><strong>1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></strong></td>
|
<td><strong>1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></strong></td>
|
||||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_post_mode ); ?></strong></td>
|
<td><strong class="<?php echo esc_attr( $mode_text_cls ); ?>"><?php echo esc_html( $current_post_mode ); ?></strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 post 數', '2meet-data-optimizer' ); ?></td>
|
<td colspan="2"><?php esc_html_e( 'Stress 測試 post 數', '2meet-data-optimizer' ); ?></td>
|
||||||
<td colspan="2"><strong id="wpdo-pst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_post_count ) ); ?></strong></td>
|
<td colspan="2"><strong id="wpdo-pst-count" class="wpdo-count-danger"><?php echo esc_html( number_format_i18n( $test_post_count ) ); ?></strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 驗證反 EAV 優化指南 -->
|
<!-- 驗證反 EAV 優化指南 -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
<div class="wpdo-card wpdo-mode-card wpdo-mode-card--<?php echo esc_attr( $mode_card_cls ); ?>">
|
||||||
<h3 style="margin-top:0;font-size:14px;">
|
<h3 class="wpdo-mt-0 wpdo-h3-sm">
|
||||||
<?php if ( $mode_optimal ) : ?>
|
<?php if ( $mode_optimal ) : ?>
|
||||||
✅ <?php esc_html_e( 'Post Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
✅ <?php esc_html_e( 'Post Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
@@ -118,27 +122,27 @@ $post_type_options = array(
|
|||||||
printf(
|
printf(
|
||||||
/* translators: %s: current mode */
|
/* translators: %s: current mode */
|
||||||
esc_html__( 'Post Mode = %s — 此模式下壓力測試結果不會展示反 EAV 優化效果', '2meet-data-optimizer' ),
|
esc_html__( 'Post Mode = %s — 此模式下壓力測試結果不會展示反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_post_mode ) . '</code>'
|
'<code class="wpdo-code-white">' . esc_html( $current_post_mode ) . '</code>'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p style="margin:8px 0 0 0;">
|
<p class="wpdo-mt-1">
|
||||||
<strong><?php esc_html_e( '想看 wp_postmeta 真實減量?必須同時滿足兩個條件:', '2meet-data-optimizer' ); ?></strong>
|
<strong><?php esc_html_e( '想看 wp_postmeta 真實減量?必須同時滿足兩個條件:', '2meet-data-optimizer' ); ?></strong>
|
||||||
</p>
|
</p>
|
||||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
<ol class="wpdo-list-ol-tight">
|
||||||
<li>
|
<li>
|
||||||
<?php
|
<?php
|
||||||
printf(
|
printf(
|
||||||
/* translators: 1: settings tab anchor open, 2: settings tab anchor close */
|
/* translators: 1: settings tab anchor open, 2: settings tab anchor close */
|
||||||
esc_html__( 'Post mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Post entity)', '2meet-data-optimizer' ),
|
esc_html__( 'Post mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Post entity)', '2meet-data-optimizer' ),
|
||||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
'<code class="wpdo-code-white">',
|
||||||
'</code>'
|
'</code>'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<?php if ( ! $mode_optimal ) : ?>
|
<?php if ( ! $mode_optimal ) : ?>
|
||||||
<a href="<?php echo esc_url( $settings_tab_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
<a href="<?php echo esc_url( $settings_tab_url ); ?>" class="button button-small wpdo-ml-2">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -146,54 +150,54 @@ $post_type_options = array(
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<details style="margin-top:8px;">
|
<details class="wpdo-mt-1">
|
||||||
<summary style="cursor:pointer;color:#0073aa;font-weight:600;"><?php esc_html_e( '📐 模式 × 寫入路徑 → 預期結果矩陣', '2meet-data-optimizer' ); ?></summary>
|
<summary class="wpdo-summary-toggle"><?php esc_html_e( '📐 模式 × 寫入路徑 → 預期結果矩陣', '2meet-data-optimizer' ); ?></summary>
|
||||||
<table class="widefat" style="margin-top:8px;background:#fff;font-size:12px;">
|
<table class="widefat wpdo-mt-1 wpdo-table-inset">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#f0f0f0;">
|
<tr class="wpdo-tr-header">
|
||||||
<th><?php esc_html_e( 'Post Mode', '2meet-data-optimizer' ); ?></th>
|
<th><?php esc_html_e( 'Post Mode', '2meet-data-optimizer' ); ?></th>
|
||||||
<th>⚡ Fast</th>
|
<th>⚡ Fast</th>
|
||||||
<th>🐢 Realistic</th>
|
<th>🐢 Realistic</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr<?php echo 'disabled' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
<tr class="<?php echo esc_attr( 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||||
<td><code>disabled</code></td>
|
<td><code>disabled</code></td>
|
||||||
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
|
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
|
||||||
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
|
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr<?php echo 'dual_write' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
<tr class="<?php echo esc_attr( 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||||
<td><code>dual_write</code></td>
|
<td><code>dual_write</code></td>
|
||||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||||
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
|
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr<?php echo 'shadow_read' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
<tr class="<?php echo esc_attr( 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||||
<td><code>shadow_read</code></td>
|
<td><code>shadow_read</code></td>
|
||||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||||
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
|
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr<?php echo 'aeav_only' === $current_post_mode ? ' style="background:#e8f5e9;font-weight:600;"' : ''; ?>>
|
<tr class="<?php echo esc_attr( 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : '' ); ?>">
|
||||||
<td><code>aeav_only</code></td>
|
<td><code>aeav_only</code></td>
|
||||||
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
|
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
|
||||||
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
|
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="description" style="margin-top:6px;">
|
<p class="description wpdo-mt-1">
|
||||||
<?php esc_html_e( '範例以 nav_menu_item(5 keys/post)為基準。Fast 模式直接 $wpdb->insert,故意繞過 Hook Bus → 即使 mode=aeav_only 也會寫滿 wp_postmeta(用途:快速灌 fixture 給 Query Router benchmark)。驗證反 EAV 優化效果一律用 Realistic。', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '範例以 nav_menu_item(5 keys/post)為基準。Fast 模式直接 $wpdb->insert,故意繞過 Hook Bus → 即使 mode=aeav_only 也會寫滿 wp_postmeta(用途:快速灌 fixture 給 Query Router benchmark)。驗證反 EAV 優化效果一律用 Realistic。', '2meet-data-optimizer' ); ?>
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
<div class="wpdo-grid-2col">
|
||||||
|
|
||||||
<!-- Left: Configure & Start -->
|
<!-- Left: Configure & Start -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
<div class="wpdo-card">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||||
|
|
||||||
<table class="form-table" style="margin-top:0;">
|
<table class="form-table wpdo-mt-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:35%;"><label for="wpdo-pst-post-type"><?php esc_html_e( 'Post Type', '2meet-data-optimizer' ); ?></label></th>
|
<th class="wpdo-th-main"><label for="wpdo-pst-post-type"><?php esc_html_e( 'Post Type', '2meet-data-optimizer' ); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select id="wpdo-pst-post-type" class="regular-text">
|
<select id="wpdo-pst-post-type" class="regular-text">
|
||||||
<?php foreach ( $post_type_options as $pt => $info ) : ?>
|
<?php foreach ( $post_type_options as $pt => $info ) : ?>
|
||||||
@@ -226,11 +230,11 @@ $post_type_options = array(
|
|||||||
<tr>
|
<tr>
|
||||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<label style="display:block;margin-bottom:6px;">
|
<label class="wpdo-label-block">
|
||||||
<input type="radio" name="wpdo-pst-mode" value="fast" checked />
|
<input type="radio" name="wpdo-pst-mode" value="fast" checked />
|
||||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||||
</label>
|
</label>
|
||||||
<label style="display:block;">
|
<label class="wpdo-label-block">
|
||||||
<input type="radio" name="wpdo-pst-mode" value="realistic" />
|
<input type="radio" name="wpdo-pst-mode" value="realistic" />
|
||||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_post + update_post_meta(較慢,模擬生產路徑 + 觸發 Hook Bus → mode=dual_write+ 時 flat 表自動填入)', '2meet-data-optimizer' ); ?>
|
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_post + update_post_meta(較慢,模擬生產路徑 + 觸發 Hook Bus → mode=dual_write+ 時 flat 表自動填入)', '2meet-data-optimizer' ); ?>
|
||||||
</label>
|
</label>
|
||||||
@@ -256,11 +260,11 @@ $post_type_options = array(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Cleanup + Re-run benchmark -->
|
<!-- Right: Cleanup + Re-run benchmark -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
<div class="wpdo-card">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p>
|
<p>
|
||||||
<?php esc_html_e( '目前 stress test post 數:', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '目前 stress test post 數:', '2meet-data-optimizer' ); ?>
|
||||||
<strong id="wpdo-pst-count-mirror" style="font-size:18px;color:#dc3545;">
|
<strong id="wpdo-pst-count-mirror" class="wpdo-count-danger">
|
||||||
<?php echo esc_html( number_format_i18n( $test_post_count ) ); ?>
|
<?php echo esc_html( number_format_i18n( $test_post_count ) ); ?>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -273,7 +277,7 @@ $post_type_options = array(
|
|||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr style="margin:18px 0;" />
|
<hr class="wpdo-hr-section" />
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<button type="button" class="button" id="wpdo-pst-rerun-bench" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
|
<button type="button" class="button" id="wpdo-pst-rerun-bench" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
|
||||||
@@ -287,52 +291,52 @@ $post_type_options = array(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress section (live) -->
|
<!-- Progress section (live) -->
|
||||||
<div id="wpdo-pst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div style="margin-bottom:10px;">
|
<div class="wpdo-bar-section">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
<div class="wpdo-bar-header">
|
||||||
<span>
|
<span>
|
||||||
<strong id="wpdo-pst-pg-status"><?php echo esc_html( $wpdo_pst_stat ); ?></strong>
|
<strong id="wpdo-pst-pg-status"><?php echo esc_html( $wpdo_pst_stat ); ?></strong>
|
||||||
· <code id="wpdo-pst-pg-post-type"><?php echo esc_html( (string) ( $state['post_type'] ?? '' ) ); ?></code>
|
· <code id="wpdo-pst-pg-post-type"><?php echo esc_html( (string) ( $state['post_type'] ?? '' ) ); ?></code>
|
||||||
· <span id="wpdo-pst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
· <span id="wpdo-pst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||||
</span>
|
</span>
|
||||||
<span id="wpdo-pst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
<span id="wpdo-pst-pg-pct" class="wpdo-bar-pct"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
<div class="wpdo-bar-track">
|
||||||
<div id="wpdo-pst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
<div id="wpdo-pst-pg-bar" class="wpdo-bar-fill" style="--wpdo-bar-width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
<table class="wpdo-stats-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-pst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
<td class="wpdo-td-val"><span id="wpdo-pst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-pst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> posts/sec</td>
|
<td class="wpdo-td-val"><span id="wpdo-pst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> posts/sec</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-pst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
<td class="wpdo-td"><span id="wpdo-pst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-pst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
<td class="wpdo-td"><span id="wpdo-pst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-pst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
<td class="wpdo-td"><span id="wpdo-pst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-pst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
<td class="wpdo-td"><span id="wpdo-pst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benchmark report -->
|
<!-- Benchmark report -->
|
||||||
<div id="wpdo-pst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div id="wpdo-pst-bench-content"></div>
|
<div id="wpdo-pst-bench-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 7-group seed map info -->
|
<!-- 7-group seed map info -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
<div class="wpdo-card wpdo-mt-3">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '4. TMDO_Post_Stress_Tester seed map(7 個 post_type)', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '4. TMDO_Post_Stress_Tester seed map(7 個 post_type)', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p class="description">
|
<p class="description">
|
||||||
<?php esc_html_e( '每個 stress post 自動 seed 對應 group 的 canonical meta keys(v2.9.1 entity registry 定義)。', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '每個 stress post 自動 seed 對應 group 的 canonical meta keys(v2.9.1 entity registry 定義)。', '2meet-data-optimizer' ); ?>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
|
||||||
/**
|
/**
|
||||||
* Term Stress Test template (v2.13.0).
|
* Term Stress Test template (v2.13.0).
|
||||||
*
|
*
|
||||||
@@ -16,16 +15,22 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$status_str = (string) ( $state['status'] ?? 'idle' );
|
$status_str = (string) ( $state['status'] ?? 'idle' );
|
||||||
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
|
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
|
||||||
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'disabled';
|
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'disabled';
|
||||||
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
|
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
|
||||||
$mode_optimal = 'aeav_only' === $current_mode;
|
$mode_optimal = 'aeav_only' === $current_mode;
|
||||||
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
$mode_text_cls = 'aeav_only' === $current_mode ? 'wpdo-text-success' : ( 'shadow_read' === $current_mode ? 'wpdo-text-amber' : 'wpdo-text-danger' );
|
||||||
|
$mode_card_cls = $mode_optimal ? 'ok' : 'warn';
|
||||||
|
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$flat_hp_taxonomy = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_hp_taxonomy" );
|
$flat_hp_taxonomy = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_hp_taxonomy" );
|
||||||
@@ -37,7 +42,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
<div class="wpdo-term-stress-test-tab">
|
<div class="wpdo-term-stress-test-tab">
|
||||||
<h2><?php esc_html_e( 'Term Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
<h2><?php esc_html_e( 'Term Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||||
|
|
||||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
<div class="wpdo-notice-error">
|
||||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||||
<?php esc_html_e( '會建立大量測試 term 並 seed 對應 hp_taxonomy 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '會建立大量測試 term 並 seed 對應 hp_taxonomy 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,8 +52,8 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Diagnose snapshot -->
|
<!-- Diagnose snapshot -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
<div class="wpdo-card wpdo-mt-3">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Term Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '當前 Term Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||||
<table class="widefat striped">
|
<table class="widefat striped">
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php esc_html_e( 'wp_terms', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'wp_terms', '2meet-data-optimizer' ); ?></td>
|
||||||
@@ -58,7 +63,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
|
<td><strong class="<?php echo esc_attr( $mode_text_cls ); ?>"><?php echo esc_html( $current_mode ); ?></strong></td>
|
||||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||||
<td><strong>1:<?php echo esc_html( (string) ( $total_terms > 0 ? round( $total_termmeta / $total_terms, 2 ) : 0 ) ); ?></strong></td>
|
<td><strong>1:<?php echo esc_html( (string) ( $total_terms > 0 ? round( $total_termmeta / $total_terms, 2 ) : 0 ) ); ?></strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -70,14 +75,14 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 term 數', '2meet-data-optimizer' ); ?></td>
|
<td colspan="2"><?php esc_html_e( 'Stress 測試 term 數', '2meet-data-optimizer' ); ?></td>
|
||||||
<td colspan="2"><strong id="wpdo-tst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_term_count ) ); ?></strong></td>
|
<td colspan="2"><strong id="wpdo-tst-count" class="wpdo-count-danger"><?php echo esc_html( number_format_i18n( $test_term_count ) ); ?></strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 驗證反 EAV 優化指南 -->
|
<!-- 驗證反 EAV 優化指南 -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
<div class="wpdo-card wpdo-mode-card wpdo-mode-card--<?php echo esc_attr( $mode_card_cls ); ?>">
|
||||||
<h3 style="margin-top:0;font-size:14px;">
|
<h3 class="wpdo-mt-0 wpdo-h3-sm">
|
||||||
<?php if ( $mode_optimal ) : ?>
|
<?php if ( $mode_optimal ) : ?>
|
||||||
✅ <?php esc_html_e( 'Term Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
✅ <?php esc_html_e( 'Term Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
@@ -86,26 +91,26 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
printf(
|
printf(
|
||||||
/* translators: %s: current mode */
|
/* translators: %s: current mode */
|
||||||
esc_html__( 'Term Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
esc_html__( 'Term Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
|
'<code class="wpdo-code-white">' . esc_html( $current_mode ) . '</code>'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</h3>
|
</h3>
|
||||||
<p style="margin:8px 0 0 0;">
|
<p class="wpdo-mt-1">
|
||||||
<strong><?php esc_html_e( '想看 wp_termmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
<strong><?php esc_html_e( '想看 wp_termmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
||||||
</p>
|
</p>
|
||||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
<ol class="wpdo-list-ol-tight">
|
||||||
<li>
|
<li>
|
||||||
<?php
|
<?php
|
||||||
printf(
|
printf(
|
||||||
/* translators: 1: open code, 2: close code */
|
/* translators: 1: open code, 2: close code */
|
||||||
esc_html__( 'Term mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Term entity)', '2meet-data-optimizer' ),
|
esc_html__( 'Term mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Term entity)', '2meet-data-optimizer' ),
|
||||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
'<code class="wpdo-code-white">',
|
||||||
'</code>'
|
'</code>'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<?php if ( ! $mode_optimal ) : ?>
|
<?php if ( ! $mode_optimal ) : ?>
|
||||||
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small wpdo-ml-2">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -114,15 +119,15 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
<div class="wpdo-grid-2col">
|
||||||
|
|
||||||
<!-- Left: Configure & Start -->
|
<!-- Left: Configure & Start -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
<div class="wpdo-card">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||||
|
|
||||||
<table class="form-table" style="margin-top:0;">
|
<table class="form-table wpdo-mt-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:35%;"><label for="wpdo-tst-taxonomy"><?php esc_html_e( 'Taxonomy', '2meet-data-optimizer' ); ?></label></th>
|
<th class="wpdo-th-main"><label for="wpdo-tst-taxonomy"><?php esc_html_e( 'Taxonomy', '2meet-data-optimizer' ); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select id="wpdo-tst-taxonomy" class="regular-text">
|
<select id="wpdo-tst-taxonomy" class="regular-text">
|
||||||
<?php foreach ( $taxonomies as $slug => $label ) : ?>
|
<?php foreach ( $taxonomies as $slug => $label ) : ?>
|
||||||
@@ -144,11 +149,11 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
<tr>
|
<tr>
|
||||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<label style="display:block;margin-bottom:6px;">
|
<label class="wpdo-label-block">
|
||||||
<input type="radio" name="wpdo-tst-mode" value="fast" checked />
|
<input type="radio" name="wpdo-tst-mode" value="fast" checked />
|
||||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||||
</label>
|
</label>
|
||||||
<label style="display:block;">
|
<label class="wpdo-label-block">
|
||||||
<input type="radio" name="wpdo-tst-mode" value="realistic" />
|
<input type="radio" name="wpdo-tst-mode" value="realistic" />
|
||||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_term + update_term_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_term + update_term_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||||
</label>
|
</label>
|
||||||
@@ -174,11 +179,11 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Cleanup + Re-run benchmark -->
|
<!-- Right: Cleanup + Re-run benchmark -->
|
||||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
<div class="wpdo-card">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||||
<p>
|
<p>
|
||||||
<?php esc_html_e( '目前 stress test term 數:', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '目前 stress test term 數:', '2meet-data-optimizer' ); ?>
|
||||||
<strong id="wpdo-tst-count-mirror" style="font-size:18px;color:#dc3545;">
|
<strong id="wpdo-tst-count-mirror" class="wpdo-count-danger">
|
||||||
<?php echo esc_html( number_format_i18n( $test_term_count ) ); ?>
|
<?php echo esc_html( number_format_i18n( $test_term_count ) ); ?>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -191,7 +196,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr style="margin:18px 0;" />
|
<hr class="wpdo-hr-section" />
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<button type="button" class="button" id="wpdo-tst-rerun-bench" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
|
<button type="button" class="button" id="wpdo-tst-rerun-bench" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
|
||||||
@@ -202,46 +207,46 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress card (live) -->
|
<!-- Progress card (live) -->
|
||||||
<div id="wpdo-tst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div style="margin-bottom:10px;">
|
<div class="wpdo-bar-section">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
<div class="wpdo-bar-header">
|
||||||
<span>
|
<span>
|
||||||
<strong id="wpdo-tst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
<strong id="wpdo-tst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
||||||
· <code id="wpdo-tst-pg-taxonomy"><?php echo esc_html( (string) ( $state['taxonomy'] ?? '' ) ); ?></code>
|
· <code id="wpdo-tst-pg-taxonomy"><?php echo esc_html( (string) ( $state['taxonomy'] ?? '' ) ); ?></code>
|
||||||
· <span id="wpdo-tst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
· <span id="wpdo-tst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||||
</span>
|
</span>
|
||||||
<span id="wpdo-tst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
<span id="wpdo-tst-pg-pct" class="wpdo-bar-pct"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
<div class="wpdo-bar-track">
|
||||||
<div id="wpdo-tst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
<div id="wpdo-tst-pg-bar" class="wpdo-bar-fill" style="--wpdo-bar-width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
<table class="wpdo-stats-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-tst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
<td class="wpdo-td-val"><span id="wpdo-tst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-tst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> terms/sec</td>
|
<td class="wpdo-td-val"><span id="wpdo-tst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> terms/sec</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-tst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
<td class="wpdo-td"><span id="wpdo-tst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-tst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
<td class="wpdo-td"><span id="wpdo-tst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-tst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
<td class="wpdo-td"><span id="wpdo-tst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
<td class="wpdo-td-muted"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||||
<td style="padding:6px;"><span id="wpdo-tst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
<td class="wpdo-td"><span id="wpdo-tst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benchmark report -->
|
<!-- Benchmark report -->
|
||||||
<div id="wpdo-tst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div id="wpdo-tst-bench-content"></div>
|
<div id="wpdo-tst-bench-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
|
||||||
/**
|
/**
|
||||||
* TMDO_CLI_Member — Member flat-table CLI subcommands.
|
* TMDO_CLI_Member — Member flat-table CLI subcommands.
|
||||||
*
|
*
|
||||||
@@ -10,13 +9,17 @@
|
|||||||
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
|
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
|
||||||
* wp wpdo member-cutover — Switch user mode to aeav_only.
|
* wp wpdo member-cutover — Switch user mode to aeav_only.
|
||||||
* wp wpdo member-points-check — Show balance + recent ledger for a user.
|
* wp wpdo member-points-check — Show balance + recent ledger for a user.
|
||||||
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
|
* wp wpdo member-sso-sync — (deprecated) Sync Hub membership claims to flat table.
|
||||||
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
|
* wp wpdo member-force-logout — (deprecated) Set token_expires_at to past → re-auth.
|
||||||
*
|
*
|
||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
* @since 2.5.5
|
* @since 2.5.5
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -32,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) {
|
|||||||
*/
|
*/
|
||||||
class TMDO_CLI_Member {
|
class TMDO_CLI_Member {
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid group names managed by this CLI.
|
|
||||||
*
|
|
||||||
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
|
|
||||||
* the legacy WP/WC/HP wp_usermeta keys.
|
|
||||||
*/
|
|
||||||
private const VALID_GROUPS = array(
|
|
||||||
'membership',
|
|
||||||
'activity',
|
|
||||||
'profile',
|
|
||||||
'sso',
|
|
||||||
'core_profile',
|
|
||||||
'social',
|
|
||||||
'commerce',
|
|
||||||
'hp_user',
|
|
||||||
'admin_prefs',
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── member-audit ─────────────────────────────────────────────────────────
|
// ── member-audit ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +55,7 @@ class TMDO_CLI_Member {
|
|||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
|
|
||||||
$items = array();
|
$items = array();
|
||||||
foreach ( self::VALID_GROUPS as $group ) {
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'user' ) as $group ) {
|
||||||
$table = $wpdb->prefix . 'wpdo_user_' . $group;
|
$table = $wpdb->prefix . 'wpdo_user_' . $group;
|
||||||
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||||
@@ -108,7 +93,7 @@ class TMDO_CLI_Member {
|
|||||||
// ── member-backfill ───────────────────────────────────────────────────────
|
// ── member-backfill ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backfill wp_usermeta rows into the specified user flat-table group.
|
* Backfill wp_usermeta rows into one user flat-table group, or all of them.
|
||||||
*
|
*
|
||||||
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
|
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
|
||||||
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
|
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
|
||||||
@@ -116,7 +101,10 @@ class TMDO_CLI_Member {
|
|||||||
* ## OPTIONS
|
* ## OPTIONS
|
||||||
*
|
*
|
||||||
* [--group=<group>]
|
* [--group=<group>]
|
||||||
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
|
* : Registered user group to backfill (e.g. membership, activity, core_profile). Default: membership.
|
||||||
|
*
|
||||||
|
* [--all]
|
||||||
|
* : Backfill every registered user group (restored orphan groups excluded).
|
||||||
*
|
*
|
||||||
* [--batch-size=<n>]
|
* [--batch-size=<n>]
|
||||||
* : Rows per batch (default 500, max 2000).
|
* : Rows per batch (default 500, max 2000).
|
||||||
@@ -131,21 +119,38 @@ class TMDO_CLI_Member {
|
|||||||
*
|
*
|
||||||
* wp wpdo member-backfill --group=membership --dry-run
|
* wp wpdo member-backfill --group=membership --dry-run
|
||||||
* wp wpdo member-backfill --group=membership
|
* wp wpdo member-backfill --group=membership
|
||||||
* wp wpdo member-backfill --group=sso --reset
|
* wp wpdo member-backfill --all
|
||||||
*
|
*
|
||||||
* @param array $args Positional arguments.
|
* @param array $args Positional arguments.
|
||||||
* @param array $assoc_args Named arguments.
|
* @param array $assoc_args Named arguments.
|
||||||
*/
|
*/
|
||||||
public function member_backfill( $args, $assoc_args ): void {
|
public function member_backfill( $args, $assoc_args ): void {
|
||||||
$group = (string) ( $assoc_args['group'] ?? 'membership' );
|
|
||||||
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
|
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
|
||||||
$dry_run = isset( $assoc_args['dry-run'] );
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
$reset = isset( $assoc_args['reset'] );
|
$reset = isset( $assoc_args['reset'] );
|
||||||
|
$valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' );
|
||||||
|
|
||||||
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
|
if ( isset( $assoc_args['all'] ) ) {
|
||||||
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
|
if ( isset( $assoc_args['group'] ) ) {
|
||||||
|
WP_CLI::error( 'Use either --group or --all, not both.' );
|
||||||
|
}
|
||||||
|
$groups = $valid;
|
||||||
|
} else {
|
||||||
|
$groups = array( (string) ( $assoc_args['group'] ?? 'membership' ) );
|
||||||
|
if ( ! in_array( $groups[0], $valid, true ) ) {
|
||||||
|
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ( $groups as $group ) {
|
||||||
|
$this->backfill_group( $group, $batch_size, $dry_run, $reset );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill one user group and report the result.
|
||||||
|
*/
|
||||||
|
private function backfill_group( string $group, int $batch_size, bool $dry_run, bool $reset ): void {
|
||||||
if ( $reset ) {
|
if ( $reset ) {
|
||||||
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
|
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
|
||||||
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
|
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
|
||||||
@@ -295,8 +300,8 @@ class TMDO_CLI_Member {
|
|||||||
|
|
||||||
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
|
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
|
||||||
|
|
||||||
if ( isset( $result['error'] ) ) {
|
if ( is_wp_error( $result ) ) {
|
||||||
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
|
WP_CLI::error( 'Cutover failed: ' . $result->get_error_message() );
|
||||||
}
|
}
|
||||||
|
|
||||||
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
|
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
|
||||||
@@ -353,6 +358,9 @@ class TMDO_CLI_Member {
|
|||||||
/**
|
/**
|
||||||
* Sync Hub membership claims into the user flat table.
|
* Sync Hub membership claims into the user flat table.
|
||||||
*
|
*
|
||||||
|
* Deprecated since 1.2.0: the user `sso` group is no longer registered, so
|
||||||
|
* its flat table is never created on new installs. Will be removed.
|
||||||
|
*
|
||||||
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
|
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
|
||||||
* from usermeta and writes them into the sso group flat table.
|
* from usermeta and writes them into the sso group flat table.
|
||||||
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
|
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
|
||||||
@@ -378,6 +386,7 @@ class TMDO_CLI_Member {
|
|||||||
* @param array $assoc_args Named arguments.
|
* @param array $assoc_args Named arguments.
|
||||||
*/
|
*/
|
||||||
public function member_sso_sync( $args, $assoc_args ): void {
|
public function member_sso_sync( $args, $assoc_args ): void {
|
||||||
|
WP_CLI::warning( 'member-sso-sync is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
|
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
|
||||||
@@ -463,6 +472,9 @@ class TMDO_CLI_Member {
|
|||||||
/**
|
/**
|
||||||
* Force re-authentication by setting token_expires_at to a past datetime.
|
* Force re-authentication by setting token_expires_at to a past datetime.
|
||||||
*
|
*
|
||||||
|
* Deprecated since 1.2.0: works on the user `sso` flat table, which is no
|
||||||
|
* longer registered. Will be removed.
|
||||||
|
*
|
||||||
* The Spoke silent-refresh logic detects an expired token_expires_at and
|
* The Spoke silent-refresh logic detects an expired token_expires_at and
|
||||||
* redirects the user to re-authenticate against the Hub. If the Hub has
|
* redirects the user to re-authenticate against the Hub. If the Hub has
|
||||||
* the account blocked, the user is logged out from all Spokes.
|
* the account blocked, the user is logged out from all Spokes.
|
||||||
@@ -487,6 +499,7 @@ class TMDO_CLI_Member {
|
|||||||
* @param array $assoc_args Named arguments.
|
* @param array $assoc_args Named arguments.
|
||||||
*/
|
*/
|
||||||
public function member_force_logout( $args, $assoc_args ): void {
|
public function member_force_logout( $args, $assoc_args ): void {
|
||||||
|
WP_CLI::warning( 'member-force-logout is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
|
||||||
if ( ! isset( $assoc_args['confirm'] ) ) {
|
if ( ! isset( $assoc_args['confirm'] ) ) {
|
||||||
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
|
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-33
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
|
||||||
/**
|
/**
|
||||||
* TMDO_CLI_Post — Post entity CLI subcommands (v2.9.0+).
|
* TMDO_CLI_Post — Post entity CLI subcommands (v2.9.0+).
|
||||||
*
|
*
|
||||||
@@ -16,6 +15,10 @@
|
|||||||
* @since 2.9.0
|
* @since 2.9.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -195,29 +198,20 @@ class TMDO_CLI_Post {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Backfill one post entity group from wp_postmeta into its flat table
|
* Backfill one post entity group from wp_postmeta into its flat table
|
||||||
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
|
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). Groups with
|
||||||
|
* JSON-typed fields are backfilled row by row instead.
|
||||||
*
|
*
|
||||||
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
|
* Idempotent — safe to re-run.
|
||||||
* row-by-row backfill phase in v2.9.4+).
|
|
||||||
*
|
*
|
||||||
* ## OPTIONS
|
* ## OPTIONS
|
||||||
*
|
*
|
||||||
* --group=<group>
|
* --group=<group>
|
||||||
* : Entity group name. Required.
|
* : Registered post entity group name (e.g. wp_core, attachment,
|
||||||
* ---
|
* hp_listing_core). Required.
|
||||||
* options:
|
|
||||||
* - wp_core
|
|
||||||
* - attachment
|
|
||||||
* - wc_product
|
|
||||||
* - hp_listing_core
|
|
||||||
* - hp_request_core
|
|
||||||
* - hp_vendor_core
|
|
||||||
* - nav_menu_item
|
|
||||||
* ---
|
|
||||||
*
|
*
|
||||||
* ## EXAMPLES
|
* ## EXAMPLES
|
||||||
*
|
*
|
||||||
* wp wpdo post-migrate-group --group=wc_product
|
* wp wpdo post-migrate-group --group=hp_listing_core
|
||||||
* wp wpdo post-migrate-group --group=hp_listing_core
|
* wp wpdo post-migrate-group --group=hp_listing_core
|
||||||
*
|
*
|
||||||
* @param array $args Positional arguments (unused).
|
* @param array $args Positional arguments (unused).
|
||||||
@@ -256,6 +250,94 @@ class TMDO_CLI_Post {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── post-reconcile-groups ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move post values written under legacy routing into their post type's
|
||||||
|
* group table, then switch on post_type-scoped routing.
|
||||||
|
*
|
||||||
|
* Needed once on sites that already hold post flat rows (the installer
|
||||||
|
* leaves tmdo_post_scoped_routing off there). Resumable: an interrupted
|
||||||
|
* run continues from its cursor.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count the values that would be copied / overwritten; write nothing.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per batch. Default 500.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo post-reconcile-groups --dry-run
|
||||||
|
* wp tmdo post-reconcile-groups
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Positional arguments (unused).
|
||||||
|
* @param array<string, string> $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function post_reconcile_groups( $args, $assoc_args ): void {
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
$report = TMDO_Post_Group_Reconciler::run( $dry_run, (int) ( $assoc_args['batch'] ?? 500 ) );
|
||||||
|
|
||||||
|
if ( empty( $report['moves'] ) ) {
|
||||||
|
WP_CLI::log( 'No shared post keys to move.' );
|
||||||
|
}
|
||||||
|
foreach ( $report['moves'] as $m ) {
|
||||||
|
WP_CLI::log( sprintf( '%s → %s %s: %d value(s)%s', $m['source'], $m['target'], $m['key'], $m['copied'], $dry_run ? sprintf( ', %d would overwrite a different value', $m['overwritten'] ) : '' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $dry_run ) {
|
||||||
|
WP_CLI::success( '[dry-run] Nothing written. Re-run without --dry-run to copy and switch on scoped routing.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── post-status-sync ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add post_status / post_date copies (and their index) to every post
|
||||||
|
* group table and fill them from wp_posts.
|
||||||
|
*
|
||||||
|
* Existing tables only get the columns here, never in a web request:
|
||||||
|
* ALTER on a large table can take seconds. Afterwards status changes keep
|
||||||
|
* the copies current. Re-run after bulk writes that bypass WordPress
|
||||||
|
* (e.g. post-reconcile-groups, post migrations).
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count rows whose copies are missing or stale; change nothing.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per UPDATE. Default 1000.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo post-status-sync --dry-run
|
||||||
|
* wp tmdo post-status-sync
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Positional arguments (unused).
|
||||||
|
* @param array<string, string> $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function post_status_sync( $args, $assoc_args ): void {
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
$report = TMDO_Post_Status_Sync::run( $dry_run, (int) ( $assoc_args['batch'] ?? 1000 ) );
|
||||||
|
|
||||||
|
if ( empty( $report ) ) {
|
||||||
|
WP_CLI::log( 'No post group tables.' );
|
||||||
|
}
|
||||||
|
foreach ( $report as $group => $r ) {
|
||||||
|
WP_CLI::log( sprintf( '%s: %d row(s), %d missing or stale%s', $group, $r['rows'], $r['mismatched'], $dry_run ? '' : sprintf( ' → %s', TMDO_Post_Status_Sync::state( $group ) ) ) );
|
||||||
|
}
|
||||||
|
if ( $dry_run ) {
|
||||||
|
WP_CLI::success( '[dry-run] Nothing changed.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( 'post_status / post_date copies are in sync.' );
|
||||||
|
}
|
||||||
|
|
||||||
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,6 +430,10 @@ class TMDO_CLI_Post {
|
|||||||
* Safe to run with the filter enabled; the filter prevents new postmeta
|
* Safe to run with the filter enabled; the filter prevents new postmeta
|
||||||
* writes from re-bloating the table.
|
* writes from re-bloating the table.
|
||||||
*
|
*
|
||||||
|
* With --options it removes instead the rows the filter wrote to wp_options
|
||||||
|
* (`_transient_wpdo_hp_pm_*`) before 1.2.0 / AddOn 0.3.0 kept them in the
|
||||||
|
* object cache; works without the AddOn active.
|
||||||
|
*
|
||||||
* ## OPTIONS
|
* ## OPTIONS
|
||||||
*
|
*
|
||||||
* [--dry-run]
|
* [--dry-run]
|
||||||
@@ -356,10 +442,17 @@ class TMDO_CLI_Post {
|
|||||||
* [--confirm]
|
* [--confirm]
|
||||||
* : Required to actually DELETE rows.
|
* : Required to actually DELETE rows.
|
||||||
*
|
*
|
||||||
|
* [--options]
|
||||||
|
* : Clean the AddOn's rows in wp_options instead of wp_postmeta.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per DELETE with --options. Default 5000.
|
||||||
|
*
|
||||||
* ## EXAMPLES
|
* ## EXAMPLES
|
||||||
*
|
*
|
||||||
* wp wpdo cleanup-hp-transients --dry-run
|
* wp wpdo cleanup-hp-transients --dry-run
|
||||||
* wp wpdo cleanup-hp-transients --confirm
|
* wp wpdo cleanup-hp-transients --confirm
|
||||||
|
* wp wpdo cleanup-hp-transients --options --dry-run
|
||||||
*
|
*
|
||||||
* @param array $args Positional arguments (unused).
|
* @param array $args Positional arguments (unused).
|
||||||
* @param array $assoc_args Named arguments.
|
* @param array $assoc_args Named arguments.
|
||||||
@@ -379,6 +472,11 @@ class TMDO_CLI_Post {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( isset( $assoc_args['options'] ) ) {
|
||||||
|
self::cleanup_hp_transient_options( $dry_run, max( 1, (int) ( $assoc_args['batch'] ?? 5000 ) ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
||||||
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
|
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
|
||||||
}
|
}
|
||||||
@@ -412,6 +510,39 @@ class TMDO_CLI_Post {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count or delete the HivePress AddOn's transient rows in wp_options, in
|
||||||
|
* batches (dev36 held 1.15 M of them).
|
||||||
|
*
|
||||||
|
* @param bool $dry_run Count only.
|
||||||
|
* @param int $batch Rows per DELETE.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function cleanup_hp_transient_options( bool $dry_run, int $batch ): void {
|
||||||
|
global $wpdb;
|
||||||
|
// TMDO_Hivepress_Transient_Filter::TRANSLATED_NAMESPACE (AddOn), repeated
|
||||||
|
// here so the leftovers can be removed with the AddOn gone.
|
||||||
|
$ns = $wpdb->esc_like( 'wpdo_hp_pm_' );
|
||||||
|
$where = $wpdb->prepare( 'option_name LIKE %s OR option_name LIKE %s', '\_transient\_' . $ns . '%', '\_transient\_timeout\_' . $ns . '%' );
|
||||||
|
|
||||||
|
if ( $dry_run ) {
|
||||||
|
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE {$where}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
WP_CLI::success( sprintf( '[dry-run] %d HivePress transient row(s) in wp_options would be deleted. Re-run with --confirm.', $count ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
do {
|
||||||
|
$rows = (int) $wpdb->query( "DELETE FROM {$wpdb->options} WHERE {$where} LIMIT {$batch}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$deleted += $rows;
|
||||||
|
} while ( $rows === $batch );
|
||||||
|
|
||||||
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||||
|
TMDO_Logger::info( 'cleanup_hp_transient_options', array( 'deleted' => $deleted ) );
|
||||||
|
}
|
||||||
|
WP_CLI::success( sprintf( 'Deleted %d HivePress transient row(s) from wp_options.', $deleted ) );
|
||||||
|
}
|
||||||
|
|
||||||
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
|
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -813,23 +944,7 @@ class TMDO_CLI_Post {
|
|||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
private static function group_post_type_for_cli( string $group ): ?string {
|
private static function group_post_type_for_cli( string $group ): ?string {
|
||||||
switch ( $group ) {
|
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||||
case 'attachment':
|
|
||||||
return 'attachment';
|
|
||||||
case 'wc_product':
|
|
||||||
return 'product';
|
|
||||||
case 'hp_listing_core':
|
|
||||||
return 'hp_listing';
|
|
||||||
case 'hp_request_core':
|
|
||||||
return 'hp_request';
|
|
||||||
case 'hp_vendor_core':
|
|
||||||
return 'hp_vendor';
|
|
||||||
case 'nav_menu_item':
|
|
||||||
return 'nav_menu_item';
|
|
||||||
case 'wp_core':
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,3 +965,6 @@ WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchm
|
|||||||
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
|
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
|
||||||
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
||||||
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
||||||
|
WP_CLI::add_command( 'wpdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo post-status-sync', array( new TMDO_CLI_Post(), 'post_status_sync' ) );
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
|
||||||
/**
|
/**
|
||||||
* TMDO_CLI_Term_Comment — Term + Comment entity CLI subcommands (v2.12.0+).
|
* TMDO_CLI_Term_Comment — Term + Comment entity CLI subcommands (v2.12.0+).
|
||||||
*
|
*
|
||||||
@@ -19,6 +18,10 @@
|
|||||||
* @since 2.12.0
|
* @since 2.12.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -426,7 +429,7 @@ class TMDO_CLI_Term_Comment {
|
|||||||
WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
|
WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
|
||||||
WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
|
WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
|
||||||
WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
|
WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_term_hp_taxonomy', 'wpdo_term_hp_taxonomy' );
|
self::print_group_rows( 'term' );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
|
self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
|
||||||
|
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
@@ -434,14 +437,14 @@ class TMDO_CLI_Term_Comment {
|
|||||||
WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
|
WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
|
||||||
WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
|
WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
|
||||||
WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
|
WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_hp_review', 'wpdo_comment_hp_review' );
|
self::print_group_rows( 'comment' );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
|
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
|
||||||
|
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
WP_CLI::log( '── Filter chain status ──' );
|
WP_CLI::log( '── Filter chain status ──' );
|
||||||
self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' );
|
self::print_filter_status( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter (Phase 1)' );
|
||||||
self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' );
|
self::print_filter_status( 'TMDO_WC_Term_Count_Filter', 'WC term count filter (Phase 3)' );
|
||||||
self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' );
|
self::print_filter_status( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket (Phase 4)' );
|
||||||
|
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
|
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
|
||||||
@@ -467,6 +470,21 @@ class TMDO_CLI_Term_Comment {
|
|||||||
return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
|
return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the row count of every registered group table of an entity.
|
||||||
|
*
|
||||||
|
* @param string $entity_type 'term' or 'comment'.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function print_group_rows( string $entity_type ): void {
|
||||||
|
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $type ) {
|
||||||
|
if ( $type === $entity_type ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
self::print_flat_row( $table, TMDO_TABLE_PREFIX . $entity_type . '_' . $group );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print a flat table's row count (or "(missing)" when not present).
|
* Print a flat table's row count (or "(missing)" when not present).
|
||||||
*
|
*
|
||||||
@@ -486,15 +504,19 @@ class TMDO_CLI_Term_Comment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print a filter's enabled/disabled status.
|
* Print a filter's enabled/disabled status, as the filter itself decides it
|
||||||
|
* (each has its own default).
|
||||||
*
|
*
|
||||||
* @param string $option_key Option storing the toggle value.
|
* @param string $filter_class Filter class with a static is_enabled().
|
||||||
* @param string $label Human-readable name.
|
* @param string $label Human-readable name.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function print_filter_status( string $option_key, string $label ): void {
|
private static function print_filter_status( string $filter_class, string $label ): void {
|
||||||
$enabled = (bool) get_option( $option_key, '1' );
|
if ( ! class_exists( $filter_class ) ) {
|
||||||
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) );
|
WP_CLI::log( sprintf( '%-32s%s', $label . ':', '— not installed' ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $filter_class::is_enabled() ? '✓ enabled' : '✗ disabled' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
|
// ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
|
||||||
@@ -644,7 +666,8 @@ class TMDO_CLI_Term_Comment {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$valid_groups = array( 'all', 'hp_taxonomy', 'hp_review' );
|
$registered = TMDO_Term_Comment_Backfill::registered_groups();
|
||||||
|
$valid_groups = array_merge( array( 'all' ), array_keys( $registered ) );
|
||||||
if ( ! in_array( $group, $valid_groups, true ) ) {
|
if ( ! in_array( $group, $valid_groups, true ) ) {
|
||||||
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
|
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
|
||||||
}
|
}
|
||||||
@@ -653,8 +676,7 @@ class TMDO_CLI_Term_Comment {
|
|||||||
if ( 'all' === $group ) {
|
if ( 'all' === $group ) {
|
||||||
$results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
|
$results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
|
||||||
} else {
|
} else {
|
||||||
$entity_type = 'hp_review' === $group ? 'comment' : 'term';
|
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $registered[ $group ], $group, $dry_run ) );
|
||||||
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $entity_type, $group, $dry_run ) );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render table.
|
// Render table.
|
||||||
|
|||||||
+282
-6
@@ -19,6 +19,8 @@
|
|||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -89,22 +91,29 @@ class TMDO_CLI_V2 {
|
|||||||
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
|
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
|
||||||
);
|
);
|
||||||
|
|
||||||
$flat_count = $flat_exists
|
// Flat rows holding at least one value (wp_core keeps an all-NULL row per post).
|
||||||
|
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
|
||||||
|
$has_value = implode( ' OR ', array_map( static fn( $k ) => '`' . TMDO_Schema_Manager::sanitize_column_name( $k ) . '` IS NOT NULL', $managed_keys ) );
|
||||||
|
$flat_count = $flat_exists && '' !== $has_value
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" )
|
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}` WHERE {$has_value}" )
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// EAV source: count distinct entity IDs that have at least one managed key.
|
// EAV source: count distinct entity IDs that have at least one managed key.
|
||||||
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
|
$eav_table = $eav_tables[ $entity_type ] ?? '';
|
||||||
$eav_table = $eav_tables[ $entity_type ] ?? '';
|
$eav_count = 0;
|
||||||
$eav_count = 0;
|
|
||||||
|
|
||||||
if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
|
if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
|
||||||
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
|
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
|
||||||
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
|
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
|
||||||
// $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only.
|
// $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only.
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
$sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})";
|
$sql = "SELECT COUNT(DISTINCT m.`{$id_col}`) FROM `{$eav_table}` m WHERE m.`meta_key` IN ({$placeholders})";
|
||||||
|
// A group of some post types only: other posts holding its keys are not its rows.
|
||||||
|
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( 'post', $group_name ) : array();
|
||||||
|
if ( $post_types ) {
|
||||||
|
$sql .= " AND m.post_id IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "') )";
|
||||||
|
}
|
||||||
$eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
$eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +159,265 @@ class TMDO_CLI_V2 {
|
|||||||
WP_CLI::success( "Hook Bus: {$state}" );
|
WP_CLI::success( "Hook Bus: {$state}" );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an entity's bridge mode. Goes through TMDO_Mode_Manager::set(): unsafe
|
||||||
|
* jumps and promotions refused by the Promotion Guard (aeav_only on
|
||||||
|
* HivePress / WooCommerce sites) fail and leave the mode unchanged.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <entity>
|
||||||
|
* : post | user | term | comment
|
||||||
|
*
|
||||||
|
* <mode>
|
||||||
|
* : disabled | dual_write | shadow_read | aeav_only
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo bridge-mode user dual_write
|
||||||
|
* wp tmdo bridge-mode post disabled
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Entity type and mode.
|
||||||
|
* @param array<string, string> $assoc_args Unused.
|
||||||
|
*/
|
||||||
|
public function bridge_mode( $args, $assoc_args ): void {
|
||||||
|
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
||||||
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
|
$mode = (string) ( $args[1] ?? '' );
|
||||||
|
$from = TMDO_Mode_Manager::get( $entity );
|
||||||
|
|
||||||
|
$result = TMDO_Mode_Manager::set( $entity, $mode );
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
WP_CLI::error( $result->get_error_message() );
|
||||||
|
}
|
||||||
|
WP_CLI::success( "{$entity}: {$from} → {$mode}" );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare an entity's flat tables with native meta, optionally repairing them.
|
||||||
|
*
|
||||||
|
* Reports per key how many values are missing from flat (miss), differ
|
||||||
|
* (diff) or exist only in flat (extra), and records the outcome per group
|
||||||
|
* in the wpdo_drift_status option. Read-only unless --fix is given.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <entity>
|
||||||
|
* : post | user | term | comment
|
||||||
|
*
|
||||||
|
* [--group=<group>]
|
||||||
|
* : Only this group. Default: every backfillable group.
|
||||||
|
*
|
||||||
|
* [--sample=<n>]
|
||||||
|
* : Check n random entities per group instead of all of them.
|
||||||
|
*
|
||||||
|
* [--fix]
|
||||||
|
* : Rebuild drifted flat rows from native meta (native is never written),
|
||||||
|
* delete rows of deleted entities / other post types, then check again.
|
||||||
|
* Not combinable with --sample.
|
||||||
|
*
|
||||||
|
* [--format=<format>]
|
||||||
|
* : table or json. Default: table.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo drift-check post
|
||||||
|
* wp tmdo drift-check user --group=activity
|
||||||
|
* wp tmdo drift-check post --sample=500
|
||||||
|
* wp tmdo drift-check post --fix
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Entity type.
|
||||||
|
* @param array<string, string> $assoc_args Options.
|
||||||
|
*/
|
||||||
|
public function drift_check( $args, $assoc_args ): void {
|
||||||
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
|
$group = (string) ( $assoc_args['group'] ?? '' );
|
||||||
|
$sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) );
|
||||||
|
$fix = isset( $assoc_args['fix'] );
|
||||||
|
if ( $fix && $sample ) {
|
||||||
|
WP_CLI::error( '--fix repairs every entity; it cannot be combined with --sample.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ( $fix ) {
|
||||||
|
$result = TMDO_Drift_Check::fix( $entity, $group );
|
||||||
|
$report = $result['after'];
|
||||||
|
WP_CLI::log( sprintf( 'Fixed: %d row(s) rebuilt from native, %d cleared, %d stray row(s) deleted.', $result['fixed']['rebuilt'], $result['fixed']['cleared'], $result['fixed']['deleted_rows'] ) );
|
||||||
|
} else {
|
||||||
|
$report = TMDO_Drift_Check::run( $entity, $group, $sample );
|
||||||
|
}
|
||||||
|
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||||
|
WP_CLI::error( $e->getMessage() );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) {
|
||||||
|
WP_CLI::line( (string) wp_json_encode( $report ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = array();
|
||||||
|
foreach ( $report['groups'] as $group => $result ) {
|
||||||
|
foreach ( $result['keys'] as $key => $counts ) {
|
||||||
|
if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) {
|
||||||
|
$rows[] = array_merge(
|
||||||
|
array(
|
||||||
|
'group' => $group,
|
||||||
|
'key' => $key,
|
||||||
|
),
|
||||||
|
$counts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $rows ) {
|
||||||
|
\WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $report['total'];
|
||||||
|
$summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] );
|
||||||
|
if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) {
|
||||||
|
WP_CLI::warning( $summary );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( $summary );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an entity's flat-table values back to native meta — the way out of
|
||||||
|
* aeav_only. Resumable; re-run after an interruption.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <entity>
|
||||||
|
* : post | user | term | comment
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count the flat rows per group and how their values compare with native
|
||||||
|
* meta (missing / differs / same / default skipped); write nothing.
|
||||||
|
*
|
||||||
|
* [--overwrite]
|
||||||
|
* : Flat values replace differing native values. Default in aeav_only.
|
||||||
|
*
|
||||||
|
* [--only-missing]
|
||||||
|
* : Only fill native rows that do not exist; never replace. Default in every
|
||||||
|
* other mode, where native meta is the source of truth.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per batch. Default 500.
|
||||||
|
*
|
||||||
|
* [--demote]
|
||||||
|
* : After a complete run, switch the entity to dual_write (both copies kept in step).
|
||||||
|
*
|
||||||
|
* [--group=<group>]
|
||||||
|
* : Only this group.
|
||||||
|
*
|
||||||
|
* [--retire]
|
||||||
|
* : With --group, after the write-back: forget the group (registry_meta row)
|
||||||
|
* and rename its flat table to <table>_retired_<Ymd>. Refused while a plugin
|
||||||
|
* still registers the group or native meta differs from the flat table.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo bridge-restore-native user --dry-run
|
||||||
|
* wp tmdo bridge-restore-native user --demote
|
||||||
|
* wp tmdo bridge-restore-native user --group=sso --retire
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Positional arguments.
|
||||||
|
* @param array<string, string> $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function bridge_restore_native( $args, $assoc_args ): void {
|
||||||
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
|
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||||
|
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--overwrite|--only-missing] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
|
||||||
|
}
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
|
||||||
|
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
|
||||||
|
WP_CLI::error( '--retire needs --group=<group>.' );
|
||||||
|
}
|
||||||
|
if ( isset( $assoc_args['overwrite'] ) && isset( $assoc_args['only-missing'] ) ) {
|
||||||
|
WP_CLI::error( 'Use either --overwrite or --only-missing, not both.' );
|
||||||
|
}
|
||||||
|
$policy = isset( $assoc_args['overwrite'] ) ? 'overwrite' : ( isset( $assoc_args['only-missing'] ) ? 'only_missing' : '' );
|
||||||
|
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group, $policy );
|
||||||
|
|
||||||
|
WP_CLI::log( "Policy: {$report['policy']}" );
|
||||||
|
foreach ( $report['groups'] as $group => $g ) {
|
||||||
|
WP_CLI::log(
|
||||||
|
sprintf(
|
||||||
|
'%s/%s: %d row(s) — native missing %d, differs %d, same %d, default skipped %d%s',
|
||||||
|
$entity,
|
||||||
|
$group,
|
||||||
|
$g['rows'],
|
||||||
|
$g['missing'],
|
||||||
|
$g['differs'],
|
||||||
|
$g['same'],
|
||||||
|
$g['default_skipped'],
|
||||||
|
$dry_run ? '' : sprintf( '; %d native value(s) written', $g['written'] )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ( $dry_run ) {
|
||||||
|
WP_CLI::success( '[dry-run] Nothing written.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( isset( $assoc_args['retire'] ) ) {
|
||||||
|
$retired = TMDO_Native_Restorer::retire( $entity, $group );
|
||||||
|
if ( 'retired' !== $retired['status'] ) {
|
||||||
|
WP_CLI::error( sprintf( 'Not retired (%s%s). Written values stay in native meta.', $retired['status'], $retired['mismatches'] ? ", {$retired['mismatches']} value(s) differ" : '' ) );
|
||||||
|
}
|
||||||
|
WP_CLI::success( "{$entity}/{$group} retired; flat table kept as {$retired['archived_table']}." );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find flat text / textarea values stored as the literal "Array" (pre-1.1.0
|
||||||
|
* type caster) and rewrite them from native meta.
|
||||||
|
*
|
||||||
|
* Native rows that are "Array" too cannot be repaired here; they are listed
|
||||||
|
* so they can be restored from a backup.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--entity=<entity>]
|
||||||
|
* : post | user | term | comment. Default: all four.
|
||||||
|
*
|
||||||
|
* [--confirm]
|
||||||
|
* : Write the repairs. Without it the command only reports.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo repair-array-literals
|
||||||
|
* wp tmdo repair-array-literals --entity=user --confirm
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Positional arguments (unused).
|
||||||
|
* @param array<string, string> $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function repair_array_literals( $args, $assoc_args ): void {
|
||||||
|
$entities = isset( $assoc_args['entity'] ) ? array( (string) $assoc_args['entity'] ) : TMDO_Entity_Registry::VALID_ENTITY_TYPES;
|
||||||
|
$apply = isset( $assoc_args['confirm'] );
|
||||||
|
|
||||||
|
foreach ( $entities as $entity ) {
|
||||||
|
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||||
|
WP_CLI::error( "Unknown entity: {$entity}" );
|
||||||
|
}
|
||||||
|
$report = TMDO_Array_Literal_Repair::run( $entity, $apply );
|
||||||
|
WP_CLI::log( sprintf( '%s: %d "Array" value(s) in flat tables, %d repairable from native, %d repaired.', $entity, $report['found'], $report['repairable'], $report['repaired'] ) );
|
||||||
|
foreach ( $report['unrepairable'] as $row ) {
|
||||||
|
WP_CLI::log( sprintf( ' unrepairable %s/%s #%d (%s)', $row['group'], $row['key'], $row['id'], $row['reason'] ) );
|
||||||
|
}
|
||||||
|
foreach ( $report['native_polluted'] as $row ) {
|
||||||
|
WP_CLI::warning( sprintf( 'native meta is "Array": %s %s #%d — restore it from a backup', $entity, $row['key'], $row['id'] ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI::success( $apply ? 'Repair finished.' : '[dry-run] Nothing written. Re-run with --confirm to repair.' );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit per-module state + shadow_read flags.
|
* Audit per-module state + shadow_read flags.
|
||||||
*
|
*
|
||||||
@@ -782,6 +1050,14 @@ WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status'
|
|||||||
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
|
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
|
||||||
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
||||||
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
||||||
|
WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
|
||||||
|
WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
|
||||||
|
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
|
||||||
|
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
|
||||||
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
||||||
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
||||||
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
|
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
|
||||||
|
|||||||
+143
-91
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,6 @@ class TMDO_CLI {
|
|||||||
|
|
||||||
$compat = TMDO_Compatibility::check();
|
$compat = TMDO_Compatibility::check();
|
||||||
WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
|
WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
|
||||||
WP_CLI::log( 'HP Custom Tables: ' . self::format_hpct_status( $compat ) );
|
|
||||||
WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
|
WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
|
|
||||||
@@ -55,10 +56,10 @@ class TMDO_CLI {
|
|||||||
|
|
||||||
WP_CLI::log( 'Module States:' );
|
WP_CLI::log( 'Module States:' );
|
||||||
|
|
||||||
$hpct_flags = TMDO_Feature_Flags::hpct_modules();
|
$addon_flags = TMDO_Feature_Flags::addon_modules();
|
||||||
if ( ! empty( $hpct_flags ) ) {
|
if ( ! empty( $addon_flags ) ) {
|
||||||
WP_CLI::log( ' [HPCT Modules]' );
|
WP_CLI::log( ' [AddOn Modules]' );
|
||||||
foreach ( $hpct_flags as $module => $state ) {
|
foreach ( $addon_flags as $module => $state ) {
|
||||||
WP_CLI::log( " {$module}: {$state}" );
|
WP_CLI::log( " {$module}: {$state}" );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +94,92 @@ class TMDO_CLI {
|
|||||||
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
|
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the flat table of every backfillable group of every entity type,
|
||||||
|
* plus the installer's migration status and points ledger tables.
|
||||||
|
*
|
||||||
|
* @return bool Every table exists.
|
||||||
|
*/
|
||||||
|
public static function check_entity_tables(): bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$tables = array( $wpdb->prefix . 'wpdo_migration_status', $wpdb->prefix . 'wpdo_user_points_ledger' );
|
||||||
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||||
|
foreach ( TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ) as $group ) {
|
||||||
|
$tables[] = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = true;
|
||||||
|
foreach ( $tables as $t ) {
|
||||||
|
if ( TMDO_IS_SQLITE ) {
|
||||||
|
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
|
||||||
|
} else {
|
||||||
|
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
|
||||||
|
}
|
||||||
|
if ( $exists ) {
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
|
||||||
|
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
|
||||||
|
} else {
|
||||||
|
WP_CLI::warning( " [MISSING] {$t} — run `wp wpdo install` to create." );
|
||||||
|
$ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report Promotion Guard violations and the recorded drift status of every
|
||||||
|
* entity that writes flat tables or has been checked (informational).
|
||||||
|
*/
|
||||||
|
public static function report_guard_and_drift(): void {
|
||||||
|
WP_CLI::log( '' );
|
||||||
|
$violations = TMDO_Promotion_Guard::violations();
|
||||||
|
if ( empty( $violations ) ) {
|
||||||
|
WP_CLI::log( ' [OK] Promotion Guard: no violation' );
|
||||||
|
}
|
||||||
|
foreach ( $violations as $entity_type => $integrations ) {
|
||||||
|
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
|
||||||
|
}
|
||||||
|
$deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) );
|
||||||
|
if ( $deferred ) {
|
||||||
|
WP_CLI::warning( ' [SCHEMA] groups using native meta until their table upgrade and the drift resync after it (tmdo_drift_resync) are done: ' . implode( ', ', $deferred ) . '.' );
|
||||||
|
}
|
||||||
|
$pending = TMDO_Schema_Manager::pending_indexes();
|
||||||
|
if ( $pending ) {
|
||||||
|
WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = get_option( TMDO_Drift_Check::OPTION, array() );
|
||||||
|
$status = is_array( $status ) ? $status : array();
|
||||||
|
WP_CLI::log( 'Drift (last `wp tmdo drift-check` or daily sample):' );
|
||||||
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||||
|
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||||
|
// Records of groups no longer registered can be neither re-checked nor fixed.
|
||||||
|
$last = array_intersect_key( (array) ( $status[ $entity_type ] ?? array() ), array_flip( $groups ) );
|
||||||
|
if ( empty( $groups ) || ( empty( $last ) && ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( empty( $last ) ) {
|
||||||
|
WP_CLI::log( " {$entity_type}: never checked — run `wp tmdo drift-check {$entity_type}`" );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$drifting = array();
|
||||||
|
foreach ( $last as $group => $s ) {
|
||||||
|
if ( (int) $s['miss'] + (int) $s['diff'] + (int) $s['extra'] > 0 ) {
|
||||||
|
$drifting[] = sprintf( '%s miss=%d diff=%d extra=%d', $group, $s['miss'], $s['diff'], $s['extra'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$oldest = min( array_column( $last, 'checked_at' ) );
|
||||||
|
if ( $drifting ) {
|
||||||
|
WP_CLI::warning( " {$entity_type}: " . implode( '; ', $drifting ) . " (checked {$oldest} UTC or later) — `wp tmdo drift-check {$entity_type} --fix`" );
|
||||||
|
} else {
|
||||||
|
WP_CLI::log( sprintf( ' [OK] %s: %d group(s) clean (oldest check %s UTC)', $entity_type, count( $last ), $oldest ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run health check on all tables.
|
* Run health check on all tables.
|
||||||
*
|
*
|
||||||
@@ -194,30 +281,12 @@ class TMDO_CLI {
|
|||||||
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
|
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check user entity flat tables (v2.5.5+).
|
// Entity flat tables, from the registry (was a hard-coded user list that
|
||||||
$user_entity_tables = array(
|
// named the never-registered sso group).
|
||||||
$wpdb->prefix . 'wpdo_user_membership',
|
if ( ! self::check_entity_tables() ) {
|
||||||
$wpdb->prefix . 'wpdo_user_activity',
|
$all_ok = false;
|
||||||
$wpdb->prefix . 'wpdo_user_profile',
|
|
||||||
$wpdb->prefix . 'wpdo_user_sso',
|
|
||||||
$wpdb->prefix . 'wpdo_user_points_ledger',
|
|
||||||
$wpdb->prefix . 'wpdo_migration_status',
|
|
||||||
);
|
|
||||||
foreach ( $user_entity_tables as $t ) {
|
|
||||||
if ( TMDO_IS_SQLITE ) {
|
|
||||||
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
|
|
||||||
} else {
|
|
||||||
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
|
|
||||||
}
|
|
||||||
if ( $exists ) {
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
||||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
|
|
||||||
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
|
|
||||||
} else {
|
|
||||||
WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." );
|
|
||||||
$all_ok = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self::report_guard_and_drift();
|
||||||
|
|
||||||
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
|
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
|
||||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||||
@@ -328,6 +397,40 @@ class TMDO_CLI {
|
|||||||
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
|
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Entity group name collisions ─────────────────────────────────────────
|
||||||
|
$collisions = TMDO_Entity_Registry::get_collisions();
|
||||||
|
if ( empty( $collisions ) ) {
|
||||||
|
WP_CLI::log( ' [OK] No entity group name collisions' );
|
||||||
|
}
|
||||||
|
foreach ( $collisions as $c ) {
|
||||||
|
WP_CLI::warning(
|
||||||
|
sprintf(
|
||||||
|
' [COLLISION] %s/%s registered twice with different fields (second ignored) — added: %s; removed: %s; changed: %s',
|
||||||
|
$c['entity_type'],
|
||||||
|
$c['group'],
|
||||||
|
implode( ', ', $c['added'] ) ?: '-',
|
||||||
|
implode( ', ', $c['removed'] ) ?: '-',
|
||||||
|
implode( ', ', $c['changed'] ) ?: '-'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$all_ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orphaned entity groups (restored from wpdo_registry_meta) ───────────
|
||||||
|
foreach ( TMDO_Entity_Registry::get_restored_groups() as $r ) {
|
||||||
|
WP_CLI::warning(
|
||||||
|
sprintf(
|
||||||
|
' [ORPHAN] %s/%s — no active plugin registers it; restored %d field(s) from wpdo_registry_meta%s%s. Reactivate the owner or write the data back before removing it.',
|
||||||
|
$r['entity_type'],
|
||||||
|
$r['group'],
|
||||||
|
$r['fields'],
|
||||||
|
$r['skipped'] ? ' (skipped, owned by another group: ' . implode( ', ', $r['skipped'] ) . ')' : '',
|
||||||
|
$r['native'] ? ' (left on native meta, which has newer data: ' . implode( ', ', $r['native'] ) . ')' : ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$all_ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check recent errors.
|
// Check recent errors.
|
||||||
$errors = TMDO_Logger::get_recent( '', 5 );
|
$errors = TMDO_Logger::get_recent( '', 5 );
|
||||||
if ( ! empty( $errors ) ) {
|
if ( ! empty( $errors ) ) {
|
||||||
@@ -632,46 +735,6 @@ class TMDO_CLI {
|
|||||||
WP_CLI::success( "Module {$module} disabled (idle)." );
|
WP_CLI::success( "Module {$module} disabled (idle)." );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import settings from HP Custom Tables.
|
|
||||||
*
|
|
||||||
* ## EXAMPLES
|
|
||||||
*
|
|
||||||
* wp wpdo import-hpct
|
|
||||||
*
|
|
||||||
* @param array $args Positional arguments.
|
|
||||||
* @param array $assoc_args Associative arguments.
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @subcommand import-hpct
|
|
||||||
*/
|
|
||||||
public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
|
||||||
if ( TMDO_HPCT_Import::is_imported() ) {
|
|
||||||
WP_CLI::warning( 'HPCT settings have already been imported.' );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! TMDO_HPCT_Import::can_import() ) {
|
|
||||||
WP_CLI::error( 'HP Custom Tables plugin not detected or already imported.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show preview.
|
|
||||||
$preview_data = TMDO_HPCT_Import::preview();
|
|
||||||
if ( ! empty( $preview_data['modules'] ) ) {
|
|
||||||
WP_CLI::log( 'Import preview:' );
|
|
||||||
WP_CLI\Utils\format_items( 'table', $preview_data['modules'], array( 'module', 'hpct_status', 'wpdo_state' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
WP_CLI::confirm( 'Proceed with import?' );
|
|
||||||
|
|
||||||
$result = TMDO_HPCT_Import::run();
|
|
||||||
if ( is_wp_error( $result ) ) {
|
|
||||||
WP_CLI::error( $result->get_error_message() );
|
|
||||||
}
|
|
||||||
|
|
||||||
WP_CLI::success( 'HPCT settings imported successfully. You can now deactivate HP Custom Tables.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run performance benchmark.
|
* Run performance benchmark.
|
||||||
*
|
*
|
||||||
@@ -796,12 +859,12 @@ class TMDO_CLI {
|
|||||||
if ( ! empty( $warm_posts ) ) {
|
if ( ! empty( $warm_posts ) ) {
|
||||||
// Seed warm zone entries so reads are non-trivial.
|
// Seed warm zone entries so reads are non-trivial.
|
||||||
foreach ( $warm_posts as $pid ) {
|
foreach ( $warm_posts as $pid ) {
|
||||||
TMDO_Zone_Warm::set( (int) $pid, TMDO_Listing_Stats::VIEW_KEY, '1', DAY_IN_SECONDS );
|
TMDO_Zone_Warm::set( (int) $pid, TMDO_Zone_Warm::VIEW_KEY, '1', DAY_IN_SECONDS );
|
||||||
}
|
}
|
||||||
|
|
||||||
$start = microtime( true );
|
$start = microtime( true );
|
||||||
foreach ( $warm_posts as $pid ) {
|
foreach ( $warm_posts as $pid ) {
|
||||||
TMDO_Zone_Warm::get( (int) $pid, TMDO_Listing_Stats::VIEW_KEY );
|
TMDO_Zone_Warm::get( (int) $pid, TMDO_Zone_Warm::VIEW_KEY );
|
||||||
}
|
}
|
||||||
$warm_ms = ( microtime( true ) - $start ) * 1000;
|
$warm_ms = ( microtime( true ) - $start ) * 1000;
|
||||||
|
|
||||||
@@ -1146,12 +1209,17 @@ class TMDO_CLI {
|
|||||||
WP_CLI::log( "Deleted {$warm_deleted} expired warm entries." );
|
WP_CLI::log( "Deleted {$warm_deleted} expired warm entries." );
|
||||||
|
|
||||||
if ( $archive_expired ) {
|
if ( $archive_expired ) {
|
||||||
WP_CLI::log( 'Archiving expired listing fields to Zone D...' );
|
// Listing archival is HivePress-specific and ships in that AddOn.
|
||||||
$archived = TMDO_Listing_Stats::archive_expired_listings();
|
if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
|
||||||
WP_CLI::log( "Archived {$archived} fields from expired listings." );
|
WP_CLI::warning( '--archive-expired needs 2meet-data-optimizer-hivepress-addon; skipping.' );
|
||||||
|
} else {
|
||||||
|
WP_CLI::log( 'Archiving expired listing fields to Zone D...' );
|
||||||
|
$archived = TMDO_Listing_Stats::archive_expired_listings();
|
||||||
|
WP_CLI::log( "Archived {$archived} fields from expired listings." );
|
||||||
|
|
||||||
$stats = TMDO_Zone_Archive::stats();
|
$stats = TMDO_Zone_Archive::stats();
|
||||||
WP_CLI::log( "Zone D total: {$stats['total_rows']} rows, {$stats['compressed_rows']} compressed." );
|
WP_CLI::log( "Zone D total: {$stats['total_rows']} rows, {$stats['compressed_rows']} compressed." );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WP_CLI::success( 'Cleanup complete.' );
|
WP_CLI::success( 'Cleanup complete.' );
|
||||||
@@ -1384,22 +1452,6 @@ class TMDO_CLI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the HPCT status string for display.
|
|
||||||
*
|
|
||||||
* @param array $compat Compatibility check result array.
|
|
||||||
* @return string Human-readable HPCT status.
|
|
||||||
*/
|
|
||||||
private static function format_hpct_status( array $compat ): string {
|
|
||||||
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
|
|
||||||
return 'Active (Imported)';
|
|
||||||
}
|
|
||||||
if ( $compat['hpct_active'] ) {
|
|
||||||
return 'Active (Not imported — run `wp wpdo import-hpct`)';
|
|
||||||
}
|
|
||||||
return 'Not found';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the appropriate migration instance for a module name.
|
* Create the appropriate migration instance for a module name.
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "phpunit --configuration phpunit.xml",
|
"test": "phpunit --configuration phpunit.xml",
|
||||||
"test:integration": "phpunit --configuration phpunit-integration.xml",
|
"test:integration": "phpunit --configuration phpunit-integration.xml",
|
||||||
"phpcs": "phpcs --standard=WordPress includes/ admin/ cli/ modules/ 2meet-data-optimizer.php uninstall.php",
|
"phpcs": "phpcs --standard=phpcs.xml",
|
||||||
"stan": "phpstan analyse --no-progress",
|
"stan": "phpstan analyse --no-progress",
|
||||||
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
|
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 規模效能基準:原生 postmeta vs Hot 表 vs Entity 表
|
||||||
|
|
||||||
|
**量測日期**:2026-09-25 **分支**:`hardening/2.0` **目標規模**:vendor 100,000、listing 1,000,000
|
||||||
|
|
||||||
|
## 如何重現
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DB 帳密沿用整合測試的 TMDO_TEST_DB_* 環境變數;資料庫名稱必須以 _bench 結尾
|
||||||
|
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000 # 約 7.5 分鐘
|
||||||
|
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php --iterations=20 # 約 20 分鐘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 環境與資料
|
||||||
|
|
||||||
|
| 項目 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 資料庫 | MariaDB 10.11.14(本機,InnoDB 預設設定) |
|
||||||
|
| 主機 | 8 核、31 GB RAM、NVMe |
|
||||||
|
| 資料 | 100,000 vendor(users + hp_vendor posts)、1,000,000 listing(90% publish) |
|
||||||
|
| 原生 meta | postmeta 9,549,796 筆(951 MB)、usermeta 600,000 筆 |
|
||||||
|
| Entity 表 | `wpdo_post_hp_listing_core` 1,000,000 筆(379 MB) |
|
||||||
|
| Hot 表 | `wpdo_hot_hp_listing` 1,000,000 筆(229 MB) |
|
||||||
|
|
||||||
|
SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個條件一個 INNER JOIN、數值用 CAST、`SQL_CALC_FOUND_ROWS`),Hot 是 `TMDO_Query_Router`,Entity 是 `TMDO_Post_Query_Router`。每個情境 20 次,另有 3 次暖機不計入,參數隨機但可重現(`--seed=7`)。寫入在交易內執行後 rollback。
|
||||||
|
|
||||||
|
> 20 次取樣下 p95 等於最大值,僅供參考。
|
||||||
|
|
||||||
|
## 結果
|
||||||
|
|
||||||
|
| 情境 | 形式 | p50 ms | p95 ms | 結果筆數 | 使用索引 |
|
||||||
|
|---|---|---:|---:|---:|---|
|
||||||
|
| 1. 價格區間搜尋(命中約 20%) | 原生 | **14,695** | 14,890 | 180,292 | postmeta:post_id |
|
||||||
|
| | Hot | 558 | 3,605 | 180,329 | h:ALL |
|
||||||
|
| | Entity | 652 | 3,817 | 180,300 | e:ALL |
|
||||||
|
| 2. 價格區間 + verified=1 | 原生 | **17,094** | 17,250 | 54,197 | postmeta:post_id ×2 |
|
||||||
|
| | Hot | 277 | 299 | 54,109 | `idx_hp_verified_hp_price`(複合) |
|
||||||
|
| | Entity | 337 | 340 | 54,180 | e:ALL |
|
||||||
|
| 3. 某 vendor 名下的 listing | `post_parent`(HivePress 實際做法) | 0.19 | 0.25 | 7 | posts:post_parent |
|
||||||
|
| | 原生 meta `hp_vendor` | **14,432** | 14,599 | 8 | postmeta:post_id |
|
||||||
|
| | Hot | **3,405** | 3,651 | 10 | 無可用索引 |
|
||||||
|
| | Entity | 0.28 | 0.33 | 8 | `idx_hp_vendor` |
|
||||||
|
| 4. 單筆 listing 讀全部 meta | 原生 | 0.12 | 0.15 | 9 列 | postmeta:post_id |
|
||||||
|
| | Hot / Entity | 0.09 | 0.11–0.14 | 1 列 | 主鍵 |
|
||||||
|
| 5. 列表頁預載 20 筆 meta | 原生 | 0.59 | 0.61 | 181 列 | postmeta:post_id |
|
||||||
|
| | Hot / Entity | 0.21–0.22 | 0.23–0.26 | 20 列 | 主鍵 |
|
||||||
|
| 6. 單一 vendor 讀使用者 meta | 原生 | 0.11 | 0.14 | 6 列 | usermeta:user_id |
|
||||||
|
| | Entity | 0.08 | 0.11 | 1 列 | 主鍵 |
|
||||||
|
| 7. 寫入:更新一筆價格 | 原生 | 0.12 | 0.16 | — | — |
|
||||||
|
| | Entity | 0.10 | 0.13 | — | — |
|
||||||
|
| | dual_write(兩邊都寫) | 0.22 | 0.26 | — | — |
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
|
||||||
|
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
|
||||||
|
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
|
||||||
|
- **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。
|
||||||
|
- **PR-029**:副本 ready 且無 NULL 時,單一 post type、明確狀態、依日期排序的搜尋改為 INNER JOIN、`post_status IN (…)`、`ORDER BY` 副本 `post_date`、不 GROUP BY(bench 變體 `entity+copy`,`--iterations=5`)。仍帶 `SQL_CALC_FOUND_ROWS` 時:情境 1 p50 670→770 ms、**p95 3,739→773 ms**;情境 2 p50 356→448 ms(變慢,因為算總數仍要掃完所有符合列)。拿掉 `SQL_CALC_FOUND_ROWS` 後同形狀情境 1 只要 **8 ms**(走 `idx_post_status_date`、LIMIT 提早結束)→ 完整效益要搭配 PR-030。
|
||||||
|
- **PR-030**:路由後的搜尋不再帶 `SQL_CALC_FOUND_ROWS`;總數改由同一查詢的 `COUNT(*)` 回答,存在物件快取,key 綁 `last_changed(posts)`(核心存文、Hook Bus 扁平寫入都會推進)。1M 實測:快取命中時整頁 **8 ms**(原 694 / 363 ms);快取失效後第一次為頁面 8 ms + COUNT 608 / 344 ms,與原本相當。任何 post 寫入都讓全部計數失效(WP last_changed 語意),寫入頻繁的站命中率較低。
|
||||||
|
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
|
||||||
|
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
|
||||||
|
- **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。
|
||||||
|
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
|
||||||
|
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
|
||||||
|
|
||||||
|
## 前台與寫入鏈實測(dev36,Release 2.1,2026-09-26)
|
||||||
|
|
||||||
|
上面的數字是純 SQL。這一節是真實站台(dev36:HivePress 1.7.31 + Marketplace、WooCommerce 11.1.2、Redis object cache、listing 約 10 萬筆)上的整頁與整條寫入鏈,決策背景見 `adr-004-search-index-mode-for-hp-wc.md`。
|
||||||
|
|
||||||
|
**前台搜尋**(`tests/live/tools/fe.sh`:每個 URL 5 次取中位數,`X-S36-Ms` = 整個請求到 `template_redirect`)。「routed」是 post 模式 shadow_read + 核心 2.1 router + HivePress AddOn 0.2.0;「1.0.x」是整合測試當時的 router(報告 `notes/tmdo-integration-test-dev36-2026-09-26.md`)。
|
||||||
|
|
||||||
|
| URL | 原生 ms | 1.0.x ms | 2.1 routed ms | ids 與原生相同 |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| listing 價格區間 | 614 | 496 | **323** | 是 |
|
||||||
|
| listing 價格區間 + `_sort=price__asc` | 975 | 1,062 | **435** | 是 |
|
||||||
|
| listing 時薪區間 + `_sort=hourly_rate__desc` | 850 | — | **367** | 是 |
|
||||||
|
| listing `_sort=rating__desc` | 1,697 | 1,720 | **866** | 是 |
|
||||||
|
| listing 關鍵字 + 價格區間 | 754 | — | 764 | 是 |
|
||||||
|
| vendor 時薪區間 | 102 | — | 97 | 是 |
|
||||||
|
| WC 商店價格篩選 / 價格排序 | 94 / 111 | 88–94 | 109 / 88 | 是(WC 用 lookup 表,不經 router) |
|
||||||
|
|
||||||
|
(R-2.1 驗收,扁平表已從原生回填、含 PR-062c;兩輪量測見 `notes/tmdo-acceptance-2.1-2026-09-26.md`。)
|
||||||
|
|
||||||
|
- HivePress 搜尋仍帶 tax_query(排除一個 term),WordPress 因此加上 `GROUP BY posts.ID`,排序需要 temporary + filesort;這是目前 311–428 ms 的主要成本,尚未達到 R-2.1 的 300 ms 目標。
|
||||||
|
- PR-062b 之前,routed 搜尋因 `posts_request` 改寫而失去 WordPress 的 split query,改撈整列 `posts.*`:同一條 SQL 撈 `*` 要 590 ms,只撈 ID 為 312 ms。
|
||||||
|
- PR-062c 之前,依扁平欄位排序的 routed 查詢由 `wp_posts`(type / status 索引,約 56k 列)驅動、逐筆查扁平表再以 temporary table 排序;拿掉 GROUP BY 或只換 tie-break 都無效(約 300 ms),改由扁平表驅動並把 tie-break 改讀 `post_id` 後只剩單表 filesort:`meta_value_num` 263→147 ms、具名排序 250→142 ms、HP 時薪篩選 + 排序 300→36 ms(只計 SQL)。依日期排序的搜尋由扁平表驅動反而變慢(74→137 ms),所以 flat-first 只用在扁平欄位排序。
|
||||||
|
- 量測 gotcha:symlink 切換(`plugins/` → `plugins-dev`)後,PHP-FPM 的 opcache(`revalidate_path=0`)仍執行舊目標的程式,WP-CLI 不受影響;前台數字要先確認實際載入的檔案。
|
||||||
|
|
||||||
|
**WP_Query**(`tests/live/tools/qbench.php`,`fields=ids`,20 筆一頁):價格區間 296→76 ms、價格 + verified 360→53 ms、rating ≥ 4.5 248→33 ms、rate + `_thumbnail_id` EXISTS 24→9 ms;OR、多 post type、無 type 數值比較等維持原生,結果與原生相同。
|
||||||
|
|
||||||
|
**寫入鏈開銷**(單一程序,disabled 與 dual_write 交替兩輪取平均):
|
||||||
|
|
||||||
|
| 寫入 | disabled | dual_write | 增加 |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| HP listing | 754 ms / 348 q | 976 ms / 434 q | +29% |
|
||||||
|
| WC product | 215 ms / 129 q | 489 ms / 206 q | +127% |
|
||||||
|
| vendor(user + post) | 444 ms / 162 q | 630 ms / 230 q | +42% |
|
||||||
|
|
||||||
|
WC product 的成本來自 `wc_product` 群組,Release 2.2 移除 WC 扁平化後消失。
|
||||||
|
|
||||||
|
## 優化候選(待使用者確認後排入 backlog)
|
||||||
|
|
||||||
|
| # | 候選 | 針對 | 預期效果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| O-1 | Entity 表把 `post_status`、`post_date` 一併存進來,並建 `(hp_status, post_date)` 等複合索引,讓搜尋不必 JOIN 回 `posts` 排序 | 情境 1、2 | 寬範圍搜尋改為走索引排序,避免 filesort |
|
||||||
|
| O-2 | Entity 表支援群組層級的複合索引宣告,例如 `(hp_verified, hp_price)`、`(hp_featured, hp_price)` | 情境 2 | 追上或超過 Hot 表 |
|
||||||
|
| O-3 | Hot 表的索引規則補上外鍵型 bigint 欄位(例如 `hp_vendor`) | 情境 3 | 3.4 秒降到 1 ms 以下 |
|
||||||
|
| O-4 | 提供總數快取,或讓路由後的查詢可以不跑 `SQL_CALC_FOUND_ROWS`(MySQL 8 已棄用) | 情境 1、2 | 省去每次搜尋都要算出全部符合列的成本 |
|
||||||
|
| O-5 | 評估 Hot 表與 Entity 表的整併,避免同一份資料存兩份 | 容量、寫入 | 省下約 229 MB 並減少一次寫入 |
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
# Entity Adapter Cookbook
|
||||||
|
|
||||||
|
How partner plugins integrate with `2meet-data-optimizer v1.0.0+` to gain anti-EAV
|
||||||
|
benefits without modifying their existing data model.
|
||||||
|
|
||||||
|
This is the practical guide. For the canonical vocabulary see `CONTEXT.md`; for the
|
||||||
|
two standing architectural decisions see `docs/adr-001-post-entity-source-of-truth.md`
|
||||||
|
and `docs/adr-002-dual-write-naming-collision.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ TL;DR Decision Tree (read this first — v2.1.2 reordered)
|
||||||
|
|
||||||
|
The most common mistake is picking Tier 1-3 when Tier 5 was the right answer.
|
||||||
|
Ask these questions in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
Q1. Does the partner plugin have its OWN custom tables / lookup tables
|
||||||
|
that already provide anti-EAV? (HPOS, wc_product_meta_lookup,
|
||||||
|
BuddyPress activity tables, EDD payments, etc.)
|
||||||
|
|
||||||
|
├── YES → 🟢 TIER 5 (integrate, don't duplicate). STOP HERE.
|
||||||
|
│ Register awareness only. Do not migrate.
|
||||||
|
│ See: TMDO_WooCommerce reference.
|
||||||
|
│
|
||||||
|
└── NO → continue to Q2.
|
||||||
|
|
||||||
|
Q2. Is this a brand-new plugin you control end-to-end?
|
||||||
|
|
||||||
|
├── YES → 🟢 TIER 4 (greenfield, anti-EAV from day 1).
|
||||||
|
│ Use `wp tmdo register-stub <slug>` for boilerplate.
|
||||||
|
│ See: 2meet-inquiries reference.
|
||||||
|
│
|
||||||
|
└── NO (existing plugin with postmeta) → continue to Q3.
|
||||||
|
|
||||||
|
Q3. Is the relevant postmeta key heavily queried (filter / sort / search)?
|
||||||
|
Benchmark: > 10k rows OR > 3-condition meta_query OR sort by meta_value.
|
||||||
|
|
||||||
|
├── YES → 🟢 TIER 1-3 (migrate to a Zone).
|
||||||
|
│ Tier 1 (5 min) for read-only optimization.
|
||||||
|
│ Tier 2 (30 min) for full dual-write.
|
||||||
|
│ Tier 3 (1-2 days) for new entity type.
|
||||||
|
│
|
||||||
|
└── NO → 🟢 LEAVE AS POSTMETA. Premature optimization.
|
||||||
|
Re-evaluate when scale crosses Q3 thresholds.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Tier 5 is FIRST**: at v2.1.2 audit time, every mature plugin we surveyed
|
||||||
|
(WC / BuddyPress potential / EDD potential / GravityForms) has its own anti-EAV.
|
||||||
|
Defaulting to Tier 1-3 risks the catastrophic "two sources of truth" failure
|
||||||
|
mode. See `docs/INTEGRATION_PATTERN_DECISION.md` for the principle.
|
||||||
|
|
||||||
|
### How to tell if a partner plugin already has anti-EAV (Tier 5 candidate)
|
||||||
|
|
||||||
|
Check these signals in order — any ONE is sufficient for Tier 5:
|
||||||
|
|
||||||
|
| Signal | Where to look | Examples |
|
||||||
|
|--------|---------------|----------|
|
||||||
|
| **Lookup tables** with name pattern `*_lookup` / `*_meta_lookup` / `*_index` | `SHOW TABLES LIKE 'wp_{prefix}_%lookup%'` | `wp_wc_product_meta_lookup`, `wp_wc_customer_lookup` |
|
||||||
|
| **HPOS-style migration toggle** (custom table replaces postmeta) | Plugin's settings → "High Performance" or "Custom Tables" feature | WooCommerce HPOS, EDD 3.0 payments |
|
||||||
|
| **Dedicated columns instead of meta** in main entity table | `DESC wp_{plugin}_entities` shows `price`, `status` etc. as columns | BuddyPress activity table, MemberPress subscriptions |
|
||||||
|
| **Plugin's own search/filter API** that bypasses `meta_query` | `wc_get_products()`, `bp_activity_get()`, `edd_get_payments()` | WC, BuddyPress, EDD all have native APIs |
|
||||||
|
| **`*_stats` / `*_aggregate` tables** for analytics queries | `wp_wc_order_stats`, `wp_*_lookup` | WC analytics, MonsterInsights |
|
||||||
|
| **db_version option** that hits `dbDelta` migration on plugin update | `wp option get {plugin}_db_version` returns a non-trivial version | Indicates the plugin has its own schema migration story |
|
||||||
|
|
||||||
|
If you see **2+ signals** → definitely Tier 5.
|
||||||
|
If you see **0 signals** but the plugin has heavy postmeta usage → Tier 1-3.
|
||||||
|
If you see **0 signals** and postmeta is light → leave it (Q3 = NO).
|
||||||
|
|
||||||
|
**Quick command-line audit**:
|
||||||
|
```bash
|
||||||
|
# List custom tables for a plugin
|
||||||
|
wp db query "SHOW TABLES LIKE 'wp_{prefix}_%'"
|
||||||
|
|
||||||
|
# Count postmeta keys the plugin owns (low = likely Tier 5; high = candidate Tier 1-3)
|
||||||
|
wp db query "SELECT COUNT(DISTINCT meta_key) FROM wp_postmeta WHERE meta_key LIKE '\\_{prefix}_%'"
|
||||||
|
|
||||||
|
# If both numbers are non-trivial → Tier 5 is correct (plugin uses both, but
|
||||||
|
# its tables are the truth and postmeta is legacy/secondary).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three integration tiers
|
||||||
|
|
||||||
|
Pick the one that matches your plugin's data model:
|
||||||
|
|
||||||
|
### Tier 1 — `TMDO_API` facade(最少改動,5 分鐘)
|
||||||
|
|
||||||
|
If your plugin reads/writes `*_meta()` directly today, swap to the facade. This
|
||||||
|
gives you future-proofing for free — the day the field migrates to a zone or
|
||||||
|
entity adapter, your plugin needs zero changes.
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```php
|
||||||
|
$token = get_post_meta( $vendor_id, 'tmeetic_ical_token', true );
|
||||||
|
update_post_meta( $vendor_id, 'tmeetic_ical_token', $new_token );
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```php
|
||||||
|
$token = class_exists( 'TMDO_API' )
|
||||||
|
? TMDO_API::get_field( $vendor_id, 'tmeetic_ical_token' )
|
||||||
|
: get_post_meta( $vendor_id, 'tmeetic_ical_token', true );
|
||||||
|
|
||||||
|
if ( class_exists( 'TMDO_API' ) ) {
|
||||||
|
TMDO_API::set_field( $vendor_id, 'tmeetic_ical_token', $new_token );
|
||||||
|
} else {
|
||||||
|
update_post_meta( $vendor_id, 'tmeetic_ical_token', $new_token );
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Real example:** `2meet-courses/includes/class-2meetic-ical.php` (Wave 2 改造).
|
||||||
|
|
||||||
|
**Cross-entity:**
|
||||||
|
```php
|
||||||
|
TMDO_API::get_entity( 'user', $user_id, 'points' );
|
||||||
|
TMDO_API::get_entity( 'term', $term_id, 'usage_count' );
|
||||||
|
TMDO_API::get_entity( 'comment', $comment_id, 'helpful_count' );
|
||||||
|
TMDO_API::set_entity( 'user', $uid, 'points', 50 );
|
||||||
|
```
|
||||||
|
|
||||||
|
**Helper:**
|
||||||
|
```php
|
||||||
|
TMDO_API::is_field_registered( 'post', 'hp_price' ); // bool
|
||||||
|
TMDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' ); // 'zone_hot' | 'postmeta' | ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier 2 — Schema Registry 註冊(中等改動,30 分鐘)
|
||||||
|
|
||||||
|
If your plugin owns specific meta_keys that benefit from Hot zone (search/filter),
|
||||||
|
Cold zone (display/JSON), or Warm zone (TTL counter) treatment.
|
||||||
|
|
||||||
|
**Implementation:** create one integration class.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// my-plugin/includes/class-tmdo-myplugin.php
|
||||||
|
final class TMDO_MyPlugin {
|
||||||
|
public static function register(): void {
|
||||||
|
// Detect partner plugin (2meet-data-optimizer) — no-op when absent.
|
||||||
|
if ( ! class_exists( 'TMDO_Schema_Registry' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add_action( 'wpdo_register_fields', array( __CLASS__, 'register_fields' ) );
|
||||||
|
add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_tables' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_fields( TMDO_Schema_Registry $registry ): void {
|
||||||
|
$registry->register_many( 'my-plugin', array(
|
||||||
|
// Hot zone (search/filter): flat 1NF column with index.
|
||||||
|
array(
|
||||||
|
'post_type' => 'my_post_type',
|
||||||
|
'meta_key' => 'my_price',
|
||||||
|
'zone' => 'hot',
|
||||||
|
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
||||||
|
'column' => 'my_price',
|
||||||
|
'indexed' => true,
|
||||||
|
),
|
||||||
|
// Cold zone (description / JSON / display).
|
||||||
|
array(
|
||||||
|
'post_type' => 'my_post_type',
|
||||||
|
'meta_key' => 'my_description',
|
||||||
|
'zone' => 'cold',
|
||||||
|
'cache_group' => 'wpdo_cold_my_post_type',
|
||||||
|
'cache_ttl' => HOUR_IN_SECONDS,
|
||||||
|
),
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_tables( TMDO_Custom_Table_Registry $registry ): void {
|
||||||
|
$registry->register( 'my-plugin', array(
|
||||||
|
'table_name' => 'my_custom_table',
|
||||||
|
'primary_key' => 'id',
|
||||||
|
'post_type_link' => 'my_post_type',
|
||||||
|
'doctor_callback' => array( __CLASS__, 'doctor_my_table' ),
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function doctor_my_table(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}my_custom_table`" );
|
||||||
|
return array( 'ok' => true, 'message' => "rows: {$count}" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In your plugin's bootstrap:
|
||||||
|
add_action( 'plugins_loaded', array( 'TMDO_MyPlugin', 'register' ), 5 );
|
||||||
|
```
|
||||||
|
|
||||||
|
**Real examples:**
|
||||||
|
- `2meet-data-optimizer/includes/integrations/class-tmdo-infocards.php` — 9 fields + 3 tables
|
||||||
|
- `2meet-data-optimizer/includes/integrations/class-tmdo-bookings.php` — 7 tables only
|
||||||
|
|
||||||
|
### Tier 3 — Custom Entity Adapter(大改動,1-2 天)
|
||||||
|
|
||||||
|
If you need cross-entity behaviour (e.g. counter that works on user / term /
|
||||||
|
comment uniformly), use the demo entity counter pattern.
|
||||||
|
|
||||||
|
**Real example:** `2meet-data-optimizer/includes/integrations/class-tmdo-demo-entity-counter.php`.
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```php
|
||||||
|
final class My_Counter {
|
||||||
|
public const MODULE = 'entity_my_counter';
|
||||||
|
public const TABLE = 'wpdo_my_counters';
|
||||||
|
|
||||||
|
public static function install_table(): void {
|
||||||
|
global $wpdb;
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
dbDelta( "CREATE TABLE {$wpdb->prefix}" . self::TABLE . " (
|
||||||
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
entity_type varchar(20) NOT NULL,
|
||||||
|
entity_id bigint(20) unsigned NOT NULL,
|
||||||
|
counter_key varchar(100) NOT NULL,
|
||||||
|
counter_value bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
updated_at datetime NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ui_entity_counter (entity_type, entity_id, counter_key),
|
||||||
|
KEY idx_lookup (entity_type, counter_key, counter_value)
|
||||||
|
) {$wpdb->get_charset_collate()};" );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set( string $entity_type, int $entity_id, string $key, int $value ): void {
|
||||||
|
// Always write native (durability anchor).
|
||||||
|
TMDO_API::set_entity( $entity_type, $entity_id, $key, $value );
|
||||||
|
|
||||||
|
// Conditional dual-write to zone table.
|
||||||
|
if ( TMDO_Feature_Flags::is_write_active( self::MODULE ) ) {
|
||||||
|
TMDO_DB::upsert(
|
||||||
|
$GLOBALS['wpdb']->prefix . self::TABLE,
|
||||||
|
array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'entity_id' => $entity_id,
|
||||||
|
'counter_key' => $key,
|
||||||
|
'counter_value' => $value,
|
||||||
|
'updated_at' => current_time( 'mysql' ),
|
||||||
|
),
|
||||||
|
array( 'counter_value', 'updated_at' ),
|
||||||
|
array( 'entity_type', 'entity_id', 'counter_key' ),
|
||||||
|
array( '%s', '%d', '%s', '%d', '%s' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get( string $entity_type, int $entity_id, string $key ): int {
|
||||||
|
if ( TMDO_Feature_Flags::is_read_custom( self::MODULE ) ) {
|
||||||
|
// Read from zone table; fallback to native if row missing.
|
||||||
|
$val = $GLOBALS['wpdb']->get_var( $GLOBALS['wpdb']->prepare(
|
||||||
|
"SELECT counter_value FROM `{$GLOBALS['wpdb']->prefix}" . self::TABLE . "`
|
||||||
|
WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
|
||||||
|
$entity_type, $entity_id, $key
|
||||||
|
) );
|
||||||
|
if ( null !== $val ) {
|
||||||
|
return (int) $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (int) TMDO_API::get_entity( $entity_type, $entity_id, $key );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function top_n( string $entity_type, string $key, int $n = 10 ): array {
|
||||||
|
// Killer query that postmeta cannot do efficiently.
|
||||||
|
return $GLOBALS['wpdb']->get_results( $GLOBALS['wpdb']->prepare(
|
||||||
|
"SELECT entity_id, counter_value FROM `{$GLOBALS['wpdb']->prefix}" . self::TABLE . "`
|
||||||
|
WHERE entity_type = %s AND counter_key = %s
|
||||||
|
ORDER BY counter_value DESC LIMIT %d",
|
||||||
|
$entity_type, $key, $n
|
||||||
|
), ARRAY_A );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FSM lifecycle reference
|
||||||
|
|
||||||
|
For Tier 3 entity adapters, drive the 7+1 state machine via CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install schema (one-time)
|
||||||
|
wp tmdo doctor # verify base tables
|
||||||
|
|
||||||
|
# 2. Initial state: idle (do not register feature flag)
|
||||||
|
wp tmdo mode-audit | grep entity_my_counter # should show 'idle'
|
||||||
|
|
||||||
|
# 3. Begin dual_write — both native + zone get writes
|
||||||
|
wp tmdo mode-set entity_my_counter dual_write
|
||||||
|
|
||||||
|
# 4. Run backfill (if you have existing data)
|
||||||
|
# (custom script or wp tmdo migrate)
|
||||||
|
|
||||||
|
# 5. Verify with shadow_read for 7 days
|
||||||
|
wp tmdo mode-set entity_my_counter verify
|
||||||
|
wp tmdo shadow-enable entity_my_counter
|
||||||
|
|
||||||
|
# Check for diffs
|
||||||
|
wp eval 'echo (int) $GLOBALS["wpdb"]->get_var("SELECT COUNT(*) FROM {$GLOBALS[\"wpdb\"]->prefix}wpdo_shadow_diffs WHERE entity_type=\"user\"");'
|
||||||
|
|
||||||
|
# 6. Cutover — reads switch to zone
|
||||||
|
wp tmdo shadow-disable entity_my_counter
|
||||||
|
wp tmdo mode-set entity_my_counter cutover
|
||||||
|
|
||||||
|
# 7. After 7 more days, cleanup native rows
|
||||||
|
wp tmdo mode-set entity_my_counter cleanup
|
||||||
|
|
||||||
|
# 8. Final state — no fallback, zone is source of truth
|
||||||
|
wp tmdo mode-set entity_my_counter complete
|
||||||
|
|
||||||
|
# Rollback at any time
|
||||||
|
wp tmdo mode-set entity_my_counter idle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-EAV lint exemptions
|
||||||
|
|
||||||
|
Some patterns require direct SQL by design (one-off migration scans, cron token
|
||||||
|
expiry checks). Mark them with `phpcs:ignore` so `wp tmdo lint --strict` accepts them:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// phpcs:ignore WPDO.AntiEAV.PostmetaScan -- Cron sweeps postmeta to find expiring IG tokens.
|
||||||
|
$ids = $wpdb->get_col( $wpdb->prepare(
|
||||||
|
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'tmeetic_ig_token_expiry' AND CAST(meta_value AS UNSIGNED) < %d",
|
||||||
|
time() + 31 * DAY_IN_SECONDS
|
||||||
|
) );
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available rules:**
|
||||||
|
- `WPDO.AntiEAV.PostmetaScan` — cross-postmeta scan in migration / cron
|
||||||
|
- `WPDO.AntiEAV.UsermetaScan` — same, usermeta
|
||||||
|
- `WPDO.AntiEAV.PostmetaFallback` — fallback path during graceful degradation
|
||||||
|
|
||||||
|
The exemption stays line-local. The lint cannot be silenced for an entire file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification checklist (before merge)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Lint passes strict
|
||||||
|
wp tmdo lint --plugin=$(pwd) --strict
|
||||||
|
|
||||||
|
# 2. Conflict scan clean
|
||||||
|
wp tmdo conflict-scan
|
||||||
|
|
||||||
|
# 3. Doctor check (your custom tables registered + healthy)
|
||||||
|
wp tmdo doctor
|
||||||
|
|
||||||
|
# 4. Tests pass (if your plugin has them)
|
||||||
|
./vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-world results (dev10, 2026-04-25)
|
||||||
|
|
||||||
|
Production benchmarks measured with 50 hp_listing posts:
|
||||||
|
|
||||||
|
| Path | Latency (n=100) | Speedup |
|
||||||
|
|---|---|---|
|
||||||
|
| Native `get_post_meta()` | 205ms | 1.0x baseline |
|
||||||
|
| `TMDO_Listing_Stats::get_view_count()` (Zone B Warm + fallback) | 81ms | **2.51x** |
|
||||||
|
| `TMDO_Demo_Entity_Counter::top_n()` (Zone via 1 LEFT JOIN) | 0.19ms / call | **postmeta cannot do efficiently** |
|
||||||
|
|
||||||
|
The `top_n` example is the killer use case — sorting 1000s of users by point
|
||||||
|
count via postmeta requires a full meta_value scan + filesort. The zone table
|
||||||
|
serves it from a covering index in sub-millisecond.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 4: Greenfield plugin — `2meet-inquiries` (v0.5.0)
|
||||||
|
|
||||||
|
The cleanest case: a plugin written **from scratch** to be anti-EAV from day 1.
|
||||||
|
Use this as a template for all new 2meet-* plugins.
|
||||||
|
|
||||||
|
### Why this is the gold standard
|
||||||
|
|
||||||
|
| Pattern | What `2meet-inquiries` does | What `bookings`/`courses`/`infocards` had to retrofit |
|
||||||
|
|---|---|---|
|
||||||
|
| Large structured config | `wp_2mqi_forms.config_json LONGTEXT` (custom table) | Originally postmeta `_eh_inquiry_config` (anti-EAV violation) |
|
||||||
|
| Hot-zone meta on hp_vendor | `wpdo_register_fields` — `_tmqi_default_form_id` (bigint, indexed) | Some retrofitted via Schema_Registry Hot Wave 1; others still WP_Query-driven |
|
||||||
|
| Sensitive PII | `customer_email_enc BLOB` + `customer_email_hash CHAR(64)` for indexed lookup | Originally separate plugins each rolled own AES wrapper |
|
||||||
|
| Audit-trail rows | Dedicated `wp_2mqi_responses` table (1 row per submission) | Older plugins used `wp_postmeta` rows-per-field → EAV blow-up |
|
||||||
|
| Analytics events | Dedicated `wp_2mqi_analytics` (event_type, stage_index, session_id) | N/A — most plugins didn't have analytics, would've gone to postmeta if they did |
|
||||||
|
| Webhook config | `wp_options` per vendor (autoload=no, low cardinality) | Same |
|
||||||
|
|
||||||
|
### Anatomy: 4 custom tables, 0 plugin-owned postmeta keys
|
||||||
|
|
||||||
|
```
|
||||||
|
wp_2mqi_forms — form definitions (config_json + counters + slug)
|
||||||
|
wp_2mqi_responses — submitted inquiries (encrypted PII + payload_json)
|
||||||
|
wp_2mqi_drafts — in-progress submissions (token + 14-day expire)
|
||||||
|
wp_2mqi_analytics — funnel events (view, stage_*, submit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus 2 `hp_vendor` fields registered to Schema_Registry Hot zone:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$registry->register_many( '2meet-inquiries', array(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_vendor',
|
||||||
|
'meta_key' => '_tmqi_default_form_id',
|
||||||
|
'zone' => 'hot',
|
||||||
|
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||||
|
'column' => '_tmqi_default_form_id',
|
||||||
|
'indexed' => true,
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_vendor',
|
||||||
|
'meta_key' => '_tmqi_inquiries_enabled',
|
||||||
|
'zone' => 'hot',
|
||||||
|
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||||
|
'column' => '_tmqi_inquiries_enabled',
|
||||||
|
'indexed' => true,
|
||||||
|
),
|
||||||
|
) );
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bootstrap pattern (recommended)
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class TMQI_Plugin {
|
||||||
|
use TMDO_Anti_EAV_Aware; // ← strict contract; fails to load without it
|
||||||
|
|
||||||
|
public function run(): void {
|
||||||
|
// Register tables on the canonical action.
|
||||||
|
add_action( 'wpdo_register_fields', array( __CLASS__, 'register_wpdo_fields' ) );
|
||||||
|
add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_custom_tables' ) );
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_wpdo_fields(): void { /* hot-zone fields */ }
|
||||||
|
public static function register_custom_tables( $registry = null ): void {
|
||||||
|
TMQI_WPDO_Integration::register_tables( $registry );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ground rules followed
|
||||||
|
|
||||||
|
✅ **No `update_post_meta()` calls anywhere** — even for hp_vendor metas, we call `TMDO_API::set_field()`
|
||||||
|
✅ **No direct `SELECT FROM wp_postmeta`** — `wpdo lint --strict` exits 0
|
||||||
|
✅ **All large JSON in custom tables** — `config_json` and `payload_json` columns, never postmeta
|
||||||
|
✅ **Sensitive data encrypted** — reuse `TMEETIC_Crypto::encrypt()` (don't roll your own)
|
||||||
|
✅ **Indexed search on encrypted columns** — store SHA-256 hash alongside ciphertext
|
||||||
|
✅ **Single `do_action( 'tmqi/submitted' )`** — downstream notifiers, analytics, webhook all hook here
|
||||||
|
|
||||||
|
### Verification (run on dev10 right now)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Custom tables registered
|
||||||
|
wp eval 'echo count(TMDO_Custom_Table_Registry::instance()->for_provider("2meet-inquiries"));'
|
||||||
|
# → 4
|
||||||
|
|
||||||
|
# Strict lint passes
|
||||||
|
wp tmdo lint --plugin=$(wp plugin path 2meet-inquiries) --strict
|
||||||
|
# → Success: Anti-EAV lint passed
|
||||||
|
|
||||||
|
# Conflict scan
|
||||||
|
wp tmdo conflict-scan
|
||||||
|
# → 0 conflicts detected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Takeaway for Wave 2/3 retrofits
|
||||||
|
|
||||||
|
When refactoring an existing plugin to be anti-EAV, the question is not "how do
|
||||||
|
we shoehorn this into postmeta less?" — it's "**what does the data look like if
|
||||||
|
we redesign it like 2meet-inquiries from day 1?**" Then plot a migration path.
|
||||||
|
|
||||||
|
For most plugins the answer is: **replace one big postmeta key with one custom
|
||||||
|
table row, and register a small number of hot-zone hp_vendor fields for search.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 5: Integrating with a plugin that has its OWN anti-EAV — `TMDO_WooCommerce` (v2.1.0)
|
||||||
|
|
||||||
|
The hardest case: WC core already has anti-EAV (HPOS for orders, `wp_wc_product_meta_lookup`
|
||||||
|
for products). WPDO's job is to **integrate, not duplicate**.
|
||||||
|
|
||||||
|
### Why this is different from Tiers 1-4
|
||||||
|
|
||||||
|
| Aspect | Tier 1-4 (we own the data) | Tier 5 (WC owns it) |
|
||||||
|
|--------|---------------------------|----------------------|
|
||||||
|
| Custom tables | We define + create | WC defines + creates |
|
||||||
|
| Hot-zone fields | Migrated from postmeta to our Hot zone | Already in WC's lookup tables; we just **register awareness** |
|
||||||
|
| Doctor probes | We control existence + schema | We probe but don't fix |
|
||||||
|
| Schema drift | Our migration tooling | WC's update_db_*() handles |
|
||||||
|
| Conflict | None (single owner) | **Risk: shadow lookup tables** |
|
||||||
|
|
||||||
|
### Anti-pattern: ❌ DON'T duplicate WC's lookup tables
|
||||||
|
|
||||||
|
```php
|
||||||
|
// WRONG — creates a parallel system that drifts from WC's truth
|
||||||
|
$schema->register( 'woocommerce', array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_key' => '_price',
|
||||||
|
'zone' => 'hot',
|
||||||
|
// ... migrate _price into our wpdo_hot_product table
|
||||||
|
) );
|
||||||
|
// Now `_price` lives in BOTH wp_wc_product_meta_lookup AND our hot zone.
|
||||||
|
// Updates touch one but not the other. Catastrophe.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Right pattern: ✅ Register awareness, defer to WC's anti-EAV
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In TMDO_WooCommerce::register_custom_tables():
|
||||||
|
foreach ( WC_CORE_TABLES as $name => $meta ) {
|
||||||
|
$registry->register( 'woocommerce', array(
|
||||||
|
'table_name' => $name, // wp_wc_product_meta_lookup
|
||||||
|
'description' => $meta['description'],
|
||||||
|
'doctor_callback' => array( __CLASS__, 'doctor_check' ),
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// In TMDO_WooCommerce::register_schema_fields():
|
||||||
|
// Register postmeta keys WC STILL uses (the ones not yet migrated to lookups).
|
||||||
|
// When `_price` is also in wc_product_meta_lookup, registering doesn't migrate
|
||||||
|
// to OUR hot zone — it's just a hint to TMDO_API consumers about "this is hot".
|
||||||
|
$schema->register_many( 'woocommerce', array(
|
||||||
|
array( 'post_type' => 'product', 'meta_key' => '_price', 'zone' => 'hot', ... ),
|
||||||
|
// ...
|
||||||
|
) );
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision tree for new partner integrations
|
||||||
|
|
||||||
|
```
|
||||||
|
Does the partner plugin store data in postmeta?
|
||||||
|
├── NO → already on custom tables → Tier 4 (register tables + done)
|
||||||
|
└── YES → does the partner have its own lookup/cache table?
|
||||||
|
├── NO → Tier 1-3 (we manage migration)
|
||||||
|
└── YES → Tier 5: register awareness only, never duplicate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier 5 checklist for `TMDO_WooCommerce`
|
||||||
|
|
||||||
|
- [x] Register all 20 `wp_wc_*` tables to Custom_Table_Registry
|
||||||
|
- [x] Add `doctor_callback` that probes existence + row count (not schema diff)
|
||||||
|
- [x] Register hot-zone postmeta fields (legacy path only) — WC's lookup is the truth
|
||||||
|
- [x] Detect HPOS state via `OrderUtil::custom_orders_table_usage_is_enabled()`
|
||||||
|
- [x] When HPOS off → register order postmeta hot fields (`_order_total`, etc.)
|
||||||
|
- [x] When HPOS on → DON'T register order postmeta (would be stale)
|
||||||
|
- [x] Customer usermeta hot fields registered unconditionally (WC always uses usermeta for these)
|
||||||
|
- [x] Subscription product fields conditional on `WC_Subscriptions` OR `wc-linepay-subscription`
|
||||||
|
- [x] Own custom table (`wpdo_wc_commissions`) for vendor marketplace tracking
|
||||||
|
- [x] Admin notice recommends HPOS when legacy order count > threshold
|
||||||
|
- [x] Admin dashboard surfaces commission stats + table health
|
||||||
|
|
||||||
|
### What this DOESN'T do (and shouldn't)
|
||||||
|
|
||||||
|
- ❌ Migrate `_price` into our hot zone (WC already has wc_product_meta_lookup)
|
||||||
|
- ❌ Mirror `wc_orders` into our archive zone (WC handles its own archive)
|
||||||
|
- ❌ Intercept `update_post_meta` for product meta (interferes with WC's lookup sync)
|
||||||
|
- ❌ Create products / orders / customers (WC's domain)
|
||||||
|
|
||||||
|
### When to revisit
|
||||||
|
|
||||||
|
- WC drops a lookup table (unlikely but possible) → migrate that field path to Tier 3
|
||||||
|
- HPOS becomes default-on → audit our order postmeta registrations and remove
|
||||||
|
- New WC subextension introduces meta keys we should register → add to `register_subscription_fields()`
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Integration Pattern Decision — 2026-04-25
|
||||||
|
|
||||||
|
> **2026-09 更新:** 2meet 自家外掛回到「partner 自行註冊」,做法見 `PARTNER-REGISTRATION.md`;對應的 8 個 AddOn 已淘汰。
|
||||||
|
|
||||||
|
> **v1.0.0 後記(2026-07-31):本文結論已被架構拆分取代。**
|
||||||
|
> 當時的兩個選項是「集中在核心」vs「各外掛自帶 bridge」。v1.0.0 走的是第三條路:
|
||||||
|
> 每個夥伴外掛對應**一個獨立 AddOn 外掛**(`2meet-data-optimizer-<partner>-addon`,共 11 個),
|
||||||
|
> 核心完全不認識夥伴外掛。下文的 `class-tmdo-<partner>.php` 一律已搬進對應 AddOn。
|
||||||
|
> 本文保留是為了記錄「為什麼不選 per-plugin bridge 檔」這段推理 —— 該理由對 AddOn 邊界同樣適用。
|
||||||
|
|
||||||
|
**Trigger**: Step BB audit revealed two parallel patterns for partner plugin integration; need to commit to one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I found
|
||||||
|
|
||||||
|
| Plugin | Centralized in 2meet-data-optimizer? | Own bridge file? |
|
||||||
|
|--------|-----------------------------------|------------------|
|
||||||
|
| 2meet-infocards | ✅ `class-tmdo-infocards.php` | ❌ |
|
||||||
|
| 2meet-bookings | ✅ `class-tmdo-bookings.php` | ❌ |
|
||||||
|
| 2meet-quotation | ✅ `class-tmdo-quotation.php` | ❌ |
|
||||||
|
| 2meet-events | ✅ `class-tmdo-events.php` | ✅ `class-tmevents-wpdo-bridge.php` (**duplicate!**) |
|
||||||
|
| 2meet-collab | ✅ `class-tmdo-collab.php` | ❌ |
|
||||||
|
| 2meet-mobile-bridge | ✅ `class-tmdo-mobile-bridge.php` | ❌ |
|
||||||
|
| 2meet-playlist | ✅ `class-tmdo-playlist.php` | ❌ |
|
||||||
|
| 2meet-courses | ❌ | ✅ `class-2meetic-courses-wpdo.php` (NEW today, P step) |
|
||||||
|
| 2meet-inquiries | ❌ | ✅ `class-tmqi-wpdo-integration.php` (NEW from scratch) |
|
||||||
|
|
||||||
|
**Inconsistency**:
|
||||||
|
- 7 plugins are integrated centrally (legacy Wave 2 demo pattern)
|
||||||
|
- 2 new plugins (today) are integrated decentrally (cookbook Tier 4 pattern)
|
||||||
|
- 2meet-events has **both** (silent dedup by registry — works but smelly)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision: **Decentralized (own bridge) is the canonical pattern**
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
1. **Cookbook Tier 4 documents it as the standard** for new plugins (already published)
|
||||||
|
2. **Each plugin owns its own data contract** — no cross-plugin coupling in 2meet-data-optimizer
|
||||||
|
3. **Easier to ship** — partner plugin can update its registration without bumping 2meet-data-optimizer
|
||||||
|
4. **Simpler mental model** — "where do tables get registered? In the plugin that owns them"
|
||||||
|
|
||||||
|
### Why we're NOT migrating today
|
||||||
|
|
||||||
|
1. **Freeze**: Per `FREEZE_2026-04-25.md`, no risky refactors during freeze
|
||||||
|
2. **Working**: All 7 centralized integrations work; registry dedup handles the events double-pattern
|
||||||
|
3. **No vendor demand**: Nobody has reported confusion or bugs from the dual pattern
|
||||||
|
4. **Risk > reward**: Moving 7 classes touches 8 plugins, requires coordinated version bumps, breaks atomic rollback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration plan (when we DO migrate)
|
||||||
|
|
||||||
|
Trigger conditions (any one):
|
||||||
|
- A new partner plugin can't ship cleanly because of the centralized pattern
|
||||||
|
- A bug in the centralized integrations affects multiple plugins simultaneously
|
||||||
|
- We hit 10+ partner plugins (currently 9) and the 2meet-data-optimizer integrations dir is too crowded
|
||||||
|
|
||||||
|
### Steps (per plugin, ~0.5 day each)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Copy /2meet-data-optimizer/includes/integrations/class-tmdo-{slug}.php
|
||||||
|
to /{plugin-slug}/includes/integrations/class-{prefix}-wpdo.php
|
||||||
|
|
||||||
|
2. Rename class:
|
||||||
|
- TMDO_Bookings → TMB_WPDO
|
||||||
|
- TMDO_Quotation → TMQUO_WPDO (etc.)
|
||||||
|
- Keep registration logic identical
|
||||||
|
|
||||||
|
3. Wire in {plugin-slug} bootstrap (after main classes load):
|
||||||
|
require_once $dir . 'includes/integrations/class-{prefix}-wpdo.php';
|
||||||
|
{prefix}_WPDO::register();
|
||||||
|
|
||||||
|
4. In 2meet-data-optimizer:
|
||||||
|
- Remove require_once line
|
||||||
|
- Remove class name from the partner array (line ~208 of main file)
|
||||||
|
- Bump WPDO version (patch)
|
||||||
|
|
||||||
|
5. Verify:
|
||||||
|
wp eval 'echo count(TMDO_Custom_Table_Registry::instance()->for_provider("{slug}"));'
|
||||||
|
→ should still match the original count
|
||||||
|
|
||||||
|
6. Bump partner plugin version (minor, since it now has new dependency)
|
||||||
|
Update Requires Plugins header to mention 2meet-data-optimizer ≥ X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
### Special case: 2meet-events double-pattern
|
||||||
|
|
||||||
|
Already has both. Migration = remove the centralized `class-tmdo-events.php` (the bridge in 2meet-events stays). This is the **simplest first migration** because the partner plugin's own bridge is already proven.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this means for tomorrow's reader
|
||||||
|
|
||||||
|
If you're writing a NEW 2meet-* plugin: **follow Tier 4 pattern, put your integration class in your own plugin's `includes/integrations/` directory.**
|
||||||
|
|
||||||
|
If you're maintaining an EXISTING centralized integration in 2meet-data-optimizer: **leave it alone unless one of the migration triggers fires.**
|
||||||
|
|
||||||
|
If you see the dual pattern in 2meet-events and are confused: **it's intentional, registry dedups, will be cleaned up later.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-decisions (things explicitly NOT done)
|
||||||
|
|
||||||
|
- ❌ NOT moving 7 integrations today — too risky, freeze active
|
||||||
|
- ❌ NOT fixing the events double-pattern today — works fine, low priority
|
||||||
|
- ❌ NOT writing a "consolidation script" — premature optimization for a one-time migration
|
||||||
|
- ❌ NOT updating cookbook to mention the centralized pattern — would be confusing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to revisit this decision
|
||||||
|
|
||||||
|
Same as freeze conditions (`FREEZE_2026-04-25.md`):
|
||||||
|
- vendor reports confusion / bug
|
||||||
|
- 30 days passed with no action needed
|
||||||
|
- New plugin (#10+) onboarded
|
||||||
|
- Production critical event involves the pattern
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Partner 外掛自行註冊指南
|
||||||
|
|
||||||
|
> 適用:2meet 自家外掛(bookings、collab、hub、infocards、mobile-bridge、playlist、quotation、spoke)。
|
||||||
|
> 這 8 個外掛原本各有一個 `2meet-data-optimizer-<slug>-addon`,替它們向核心註冊資料表和欄位。
|
||||||
|
> 這些 AddOn 已淘汰:資料結構的定義應該和資料的擁有者放在一起,所以改由 partner 外掛自己註冊。
|
||||||
|
> 啟用中的 AddOn 會在後台顯示淘汰通知(`TMDO_Deprecated_Addons_Notice`)。
|
||||||
|
>
|
||||||
|
> HivePress、WooCommerce、LatePoint 是第三方外掛,仍然保留各自的 AddOn。
|
||||||
|
|
||||||
|
## 1. 掛上 trait
|
||||||
|
|
||||||
|
核心提供 `TMDO_Anti_EAV_Aware` trait(`includes/trait-tmdo-anti-eav-aware.php`)。trait 由核心載入,所以只有在核心存在時才宣告 bridge 類別,把它放在獨立檔案:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 2meet-bookings.php(partner 主檔)
|
||||||
|
add_action(
|
||||||
|
'plugins_loaded',
|
||||||
|
static function () {
|
||||||
|
if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
|
||||||
|
return; // 核心未啟用:什麼都不做。
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/includes/class-tmb-tmdo-bridge.php';
|
||||||
|
( new TMB_TMDO_Bridge() )->boot();
|
||||||
|
},
|
||||||
|
3
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
// includes/class-tmb-tmdo-bridge.php
|
||||||
|
final class TMB_TMDO_Bridge {
|
||||||
|
use TMDO_Anti_EAV_Aware;
|
||||||
|
|
||||||
|
protected function plugin_slug(): string {
|
||||||
|
return '2meet-bookings';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void {
|
||||||
|
$this->bind_anti_eav_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 覆寫需要的 on_register_*(),見下面兩節。
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**時機**:核心在 `plugins_loaded:4` 觸發 `wpdo_register_*`,並在 `init:1` 再觸發一次。
|
||||||
|
建議掛在 `plugins_loaded:3`,這樣第一次觸發就能收到你的註冊,`init` 之前的讀寫也會被正確路由。
|
||||||
|
掛在 `:5` 之後也能運作(會在 `init:1` 補上),只是 `init` 之前的請求看不到你的欄位。
|
||||||
|
|
||||||
|
**名稱**:請用 `TMDO_Anti_EAV_Aware`。`WPDO_Anti_EAV_Aware` 是舊的 wp-data-optimizer 3.x 的 trait,由 mu-plugin `wpdo-policy-enforcer.php` 從已退役的路徑載入,沒有後面提到的 `post_types` 參數。
|
||||||
|
|
||||||
|
## 2. 註冊自有資料表
|
||||||
|
|
||||||
|
讓 `wp wpdo doctor` / benchmark 認得你的資料表,並做 schema 漂移檢查。以 bookings 為例:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function on_register_custom_tables( TMDO_Custom_Table_Registry $r ): void {
|
||||||
|
$this->register_custom_table(
|
||||||
|
$r,
|
||||||
|
array(
|
||||||
|
'table_name' => '2mb_services', // 不含 $wpdb->prefix
|
||||||
|
'primary_key' => 'id',
|
||||||
|
'post_type_link' => null, // 若每列對應一個 post,填 post_type(playlist 用 'hp_vendor')
|
||||||
|
'expected_columns' => array(
|
||||||
|
'id' => 'BIGINT',
|
||||||
|
'vendor_id' => 'BIGINT',
|
||||||
|
'title' => 'VARCHAR',
|
||||||
|
// …照 CREATE TABLE 逐欄列出
|
||||||
|
),
|
||||||
|
'indexes' => array(
|
||||||
|
'vendor_id' => array( 'vendor_id' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
collab、playlist、quotation、mobile-bridge 的 AddOn 只做這件事:把各自 `includes/class-tmdo-<slug>.php` 裡 `register_custom_tables()` 的 `$tables` 陣列**原樣**搬進 partner 的 bridge 即可。
|
||||||
|
|
||||||
|
## 3. 註冊 entity 欄位群組(選用)
|
||||||
|
|
||||||
|
只有要把 meta 從 `wp_postmeta` / `wp_usermeta` 等 EAV 表移到扁平表時才需要:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function on_register_entity_fields(): void {
|
||||||
|
$this->register_entity_fields(
|
||||||
|
'post',
|
||||||
|
'tmb_service_core',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'tmb_price', 'type' => 'decimal', 'searchable' => true ),
|
||||||
|
array( 'key' => 'tmb_status', 'type' => 'enum', 'options' => array( 'open', 'closed' ) ),
|
||||||
|
),
|
||||||
|
array( 'post_types' => array( 'tmb_service' ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`post_types`(post 群組建議必填)**:群組只套用在這些 post type。多個群組共用同一個 meta key 時,會依 post 本身的類型決定寫到哪張表;不屬於這些 post type 的 post,這個 key 不受管理(照舊寫原生 meta)。沒有宣告時,共用 key 會被「最後註冊的群組」整個接走。
|
||||||
|
- **定義一旦上線就不要改**:欄位定義的 hash 存在 `wpdo_registry_meta`,改了會對已有資料的扁平表執行 ALTER。要新增欄位時,開新群組。
|
||||||
|
- **群組名稱不可和別人重複**:同名但定義不同時,後註冊的會被忽略,並在 Site Health、後台 Doctor 分頁和 `wp wpdo doctor` 顯示 `[COLLISION]`。
|
||||||
|
|
||||||
|
## 4. 從 AddOn 遷移
|
||||||
|
|
||||||
|
1. 在 partner 的 bridge 裡註冊和 AddOn **完全相同**的資料表與群組(直接複製 AddOn 的定義)。
|
||||||
|
2. 部署 partner 新版,AddOn 先保持啟用。`wp wpdo doctor` 不應出現 `[COLLISION]`;兩邊定義相同時,重複註冊會被靜默忽略。
|
||||||
|
3. 停用 AddOn。
|
||||||
|
4. 再跑一次 `wp wpdo doctor`:資料表仍在清單中,而且不應出現 `[ORPHAN]`。如果出現,表示有群組沒有被 partner 接手;該實體是 aeav_only 時,核心會依 `wpdo_registry_meta` 暫時還原它,讓資料可以讀取,但仍應補上註冊。
|
||||||
|
5. 確認沒有問題後,刪除 AddOn 外掛。
|
||||||
|
|
||||||
|
hub、infocards、spoke 的 partner 外掛本身已經會自己註冊,AddOn 只是重複註冊;停用前照第 2 步確認沒有 `[COLLISION]` 即可。
|
||||||
|
|
||||||
|
## 相關文件
|
||||||
|
|
||||||
|
- `ENTITY_ADAPTER_COOKBOOK.md`:整合深度 Tier 1–3(`TMDO_API` facade、Schema Registry、自訂 adapter)。
|
||||||
|
- `INTEGRATION_PATTERN_DECISION.md`:2026-04 的「partner 自帶 bridge」決策紀錄;本指南回到這個做法。
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# WAF 相容性 — NinjaFirewall
|
||||||
|
|
||||||
|
適用:2meet-data-optimizer v1.0.2+ / NinjaFirewall (WP Edition) 4.9
|
||||||
|
|
||||||
|
**結論:兩者可共存,且不需要開發 AddOn。** 本文記錄實測結果、symlink 多租戶部署的必要設定、WP SaaS 的開站流程與驗證清單,以及開發時必須遵守的三條約束。
|
||||||
|
|
||||||
|
> 只想開新站台的話,直接看 §3;踩到 `Cannot retrieve user options from database (#3)` 看 §2。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 兩種模式的差異
|
||||||
|
|
||||||
|
NinjaFirewall 只有兩種模式(「WP+」是付費版本,不是模式):
|
||||||
|
|
||||||
|
| | WordPress WAF | Full WAF |
|
||||||
|
|---|---|---|
|
||||||
|
| 載入方式 | mu-plugin `0-ninjafirewall.php` | `auto_prepend_file`(`.user.ini` / php.ini) |
|
||||||
|
| 執行時機 | WordPress 載入中 | **WordPress 載入之前** |
|
||||||
|
| 讀設定 | `get_option('nfw_options')` | 自行解析 wp-config.php,**原生 mysqli 直查 `{prefix}options`** |
|
||||||
|
| symlink 安全 | ✅ 站台身分由 WordPress 決定 | ❌ 自行以 `__DIR__` 推導(見 §2) |
|
||||||
|
|
||||||
|
這個差異是後續所有注意事項的根源:**Full WAF 完全繞過 WordPress**,因此 TMDO 的任何 PHP 攔截器對它都不存在,但反過來,TMDO 對 `wp_options` 的**持久性**改動會影響它。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Symlink / 多租戶部署(必讀)
|
||||||
|
|
||||||
|
WP SaaS 常以 symlink 共享 codebase:
|
||||||
|
|
||||||
|
```
|
||||||
|
tenant-a/wp-content/plugins -> /shared/plugins
|
||||||
|
tenant-b/wp-content/plugins -> /shared/plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
Full WAF 在 `lib/firewall.php:78` 這樣推導站台位置:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$nfw_['wp_content'] = dirname(dirname(dirname( __DIR__ )));
|
||||||
|
```
|
||||||
|
|
||||||
|
**PHP 的 `__DIR__` 會解析 symlink 到真實路徑**,於是每個租戶都被判定為「共享目錄所屬的那個站」。接著 `firewall.php:120-121` 讀到錯誤的 wp-config.php、連到錯誤的資料庫,撈不到該站的 `nfw_options`,最後在 `firewall.php:613` 回傳錯誤碼 6:
|
||||||
|
|
||||||
|
```
|
||||||
|
NinjaFirewall fatal error: Cannot retrieve user options from database (#3).
|
||||||
|
Review your installation, your site is not protected.
|
||||||
|
```
|
||||||
|
|
||||||
|
最後那句是字面意義的 —— 此時 WAF 已 `nfw_quit()`,**完全不做任何過濾**。
|
||||||
|
|
||||||
|
### 解法:以 DOCUMENT_ROOT 為錨點
|
||||||
|
|
||||||
|
`.htninja` 的搜尋位置是 `$_SERVER['DOCUMENT_ROOT']`(`firewall.php:47-48`),那是 web server 的 per-site 值,不受 symlink 影響。第二順位是 `dirname(DOCUMENT_ROOT)`,因此**一份檔案即可服務所有租戶**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// 置於各站 DOCUMENT_ROOT 的共同父目錄,例如 /var/www/Studio/.htninja
|
||||||
|
$nfw_site_root = rtrim( $_SERVER['DOCUMENT_ROOT'] ?? '', '/' );
|
||||||
|
|
||||||
|
if ( '' !== $nfw_site_root && is_file( $nfw_site_root . '/wp-config.php' ) ) {
|
||||||
|
if ( ! defined( 'NFW_LOG_DIR' ) ) {
|
||||||
|
define( 'NFW_LOG_DIR', $nfw_site_root . '/wp-content' ); // firewall.php:82-83
|
||||||
|
}
|
||||||
|
$wp_config = $nfw_site_root . '/wp-config.php'; // firewall.php:120-122
|
||||||
|
}
|
||||||
|
|
||||||
|
unset( $nfw_site_root );
|
||||||
|
```
|
||||||
|
|
||||||
|
覆寫時序:`.htninja` 於 L50 載入 → `NFW_LOG_DIR` 於 L82 生效 → `NFWSESSION_DIR` 跟著 log_dir → `$wp_config` 於 L120 生效。
|
||||||
|
|
||||||
|
**注意事項**
|
||||||
|
|
||||||
|
- **不要在 `.htninja` 使用 `return`** —— 回傳 `'ALLOW'` / `'BLOCK'` 會改變過濾行為(`firewall.php:54-71`)。
|
||||||
|
- `NFW_LOG_DIR` 對 **WP WAF 模式同樣重要**:`firewall.php:78` 在該模式下一樣會誤判,導致所有租戶的 firewall log、`cache/db_hash.N.php`、session 全寫進共享目錄、互相覆蓋。
|
||||||
|
- **每個租戶的 DB 都要有自己的 `nfw_options`**。修好路徑後 WAF 會連到正確的租戶資料庫,但該站若從未啟用過 NinjaFirewall,那一列不存在,照樣 #3。開站流程必須包含啟用步驟。
|
||||||
|
- `nfw_rules` 約 77KB、`autoload=auto`,每個租戶一份,規則更新也要逐站執行。
|
||||||
|
- 若租戶 DB 憑證不在 wp-config.php,可改用 `firewall.php:466-470`:在 `.htninja` 設 `$GLOBALS['nfw_mysqli']` 與 `$GLOBALS['nfw_table_prefix']` 直接提供連線。代價是每 request 多一條 mysqli 連線。
|
||||||
|
- **`.user.ini` 必須讓 php-fpm worker(通常是 www-data)可讀**,否則 `auto_prepend_file` 靜默失效、Full WAF 等同未安裝。`root:root 0640` 是常見的踩雷組合。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. WP SaaS 開站流程
|
||||||
|
|
||||||
|
以 symlink 共享 codebase 的多租戶環境,NinjaFirewall 的每一項設定都分成「平台層做一次」與「每站都要做」兩類。混淆這兩者是最常見的失誤來源。
|
||||||
|
|
||||||
|
### 3.1 平台層(全租戶共用,只做一次)
|
||||||
|
|
||||||
|
**A. 站台錨點 `.htninja`** — 見 §2。放在各站 DOCUMENT_ROOT 的共同父目錄(例如 `/var/www/Studio/.htninja`),內容以 `$_SERVER['DOCUMENT_ROOT']` 動態推導,因此一份即可服務所有租戶,新增租戶時不需修改。
|
||||||
|
|
||||||
|
**B. nginx 規則** — 這是 per-site 設定檔,但內容對所有站相同,建議做成 snippet 讓各站 `include`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# NinjaFirewall 的 log / cache / loader 目錄。
|
||||||
|
# 它自帶的 .htaccess 在 nginx 下完全無效,且 nginx 與 php-fpm 同為 www-data,
|
||||||
|
# 檔案權限無法區分「WAF 寫入」與「對外 serve」,因此只能在這裡封鎖。
|
||||||
|
# ^~ 是必要的:讓前綴匹配優先於 \.php$ 正則,否則 .php 請求會落到 PHP handler。
|
||||||
|
location ^~ /wp-content/nfwlog/ { deny all; }
|
||||||
|
|
||||||
|
# 所有 dotfile:.htaccess / .htninja / .user.ini / .env / .git ...
|
||||||
|
# 放行 .well-known,否則 Certbot 的 ACME challenge 會失敗、憑證無法續期。
|
||||||
|
location ~ /\.(?!well-known) { deny all; }
|
||||||
|
```
|
||||||
|
|
||||||
|
漏掉這段的後果:`nfwlog/` 下的 `readme.txt` 會被公開讀取(等於告訴掃描器這站跑 NinjaFirewall),未來的 `session/sess_*` 檔也不是 `.php`、同樣裸奔。`.php` 檔本身有雙層保護(引擎開頭的 `die('Forbidden')` + 檔內 `<?php exit; ?>`)不會外洩內容,但不該依賴它。
|
||||||
|
|
||||||
|
### 3.2 每個新租戶站台(順序不可顛倒)
|
||||||
|
|
||||||
|
**順序很重要**:先確保錨點與設定就緒,再安裝 Full WAF。順序顛倒會直接撞上 §2 的 `#3` 錯誤。
|
||||||
|
|
||||||
|
**Step 1 — 啟用外掛,建立該站自己的設定**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wp --path=/var/www/sites/<tenant> plugin activate ninjafirewall
|
||||||
|
wp --path=/var/www/sites/<tenant> option list --search='nfw*' --fields=option_name,autoload
|
||||||
|
# 必須看到 nfw_options 與 nfw_rules,否則後續一定 #3
|
||||||
|
```
|
||||||
|
|
||||||
|
`nfw_options` / `nfw_rules` 存在**該租戶自己的資料庫**,不會因為 codebase 共享而自動存在。這是 `#3` 最常見的成因 —— 路徑修對了,但那個站從沒跑過 installer。
|
||||||
|
|
||||||
|
**Step 2 — 確認目錄權限**
|
||||||
|
|
||||||
|
php-fpm worker(通常 www-data)必須能在 `nfwlog/` 建立與寫入檔案:
|
||||||
|
|
||||||
|
| 路徑 | 建議 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| `wp-content/nfwlog/` 及子目錄 | `2775` `wpdev:www-data` | setgid 確保新檔繼承 group;group 需 `w` 否則 WAF 寫不了 log |
|
||||||
|
| `nfwlog/` 內檔案 | `664` | 同上 |
|
||||||
|
| `nfwlog/session/` | 不用管 | 由 WAF 自建,它會用自己的嚴格權限(`0700`) |
|
||||||
|
| `.user.ini`(裝 Full WAF 後才有) | `664` `wpdev:www-data` | www-data 需可讀,否則 auto_prepend 靜默失效 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NFWLOG=/var/www/sites/<tenant>/wp-content/nfwlog
|
||||||
|
sudo find "$NFWLOG" -type d -exec chmod 2775 {} +
|
||||||
|
sudo find "$NFWLOG" -type f -exec chmod 664 {} +
|
||||||
|
```
|
||||||
|
|
||||||
|
若 `nfwlog/` 的 owner 本來就是 www-data(例如全程由後台建立),預設 `0755` 也可運作 —— owner 有寫入權。會出事的是「owner 是別人、group 只有 `r-x`」這種組合。
|
||||||
|
|
||||||
|
**Step 3 — 安裝 Full WAF(從後台)**
|
||||||
|
|
||||||
|
務必從 WordPress 後台操作,不要手工建檔:後台以 www-data 執行,權限天然正確;且官方安裝流程有 sandbox 驗證,會先確認 `auto_prepend_file` 真的生效才寫入設定,避免寫出讓整站 500 的 `.user.ini`。
|
||||||
|
|
||||||
|
裝完立刻檢查 `.user.ini` 權限(見上表)。`root:root 0640` 是常見的踩雷組合 —— PHP 讀不到,Full WAF 等同沒裝,而表面上一切正常。
|
||||||
|
|
||||||
|
**Step 4 — 套用 nginx 規則並 reload**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 開站後驗證(每站必跑)
|
||||||
|
|
||||||
|
四項缺一不可。只做前兩項會漏掉最隱蔽的失效模式。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SITE=https://<tenant>.example.com
|
||||||
|
NFWLOG=/var/www/sites/<tenant>/wp-content/nfwlog
|
||||||
|
|
||||||
|
# (1) 模式判別 —— 回 Forbidden 代表 Full WAF 生效(auto_prepend 已套用到所有 PHP 請求)
|
||||||
|
curl -sk "$SITE/wp-content/plugins/ninjafirewall/lib/i18n.php" | head -c 9; echo
|
||||||
|
|
||||||
|
# (2) 攔截能力 —— 應為 403
|
||||||
|
curl -sk -o /dev/null -w '%{http_code}\n' "$SITE/?t=../../etc/passwd"
|
||||||
|
|
||||||
|
# (3) 記錄能力 —— 最容易被漏掉的一項,log 必須增長
|
||||||
|
before=$(stat -c%s "$NFWLOG/firewall_$(date +%Y-%m).php")
|
||||||
|
curl -sk -o /dev/null "$SITE/?t=../../etc/passwd"
|
||||||
|
after=$(stat -c%s "$NFWLOG/firewall_$(date +%Y-%m).php")
|
||||||
|
[ "$after" -gt "$before" ] && echo "log OK" || echo "log FAIL — 檢查 nfwlog 權限"
|
||||||
|
|
||||||
|
# (4) web 封鎖與功能未損
|
||||||
|
for p in /wp-content/nfwlog/ /wp-content/nfwlog/readme.txt /.user.ini /.htaccess; do
|
||||||
|
printf "%-40s %s\n" "$p" "$(curl -sk -o /dev/null -w '%{http_code}' "$SITE$p")" # 全部應為 403
|
||||||
|
done
|
||||||
|
for p in / /wp-json/ /wp-admin/; do
|
||||||
|
printf "%-40s %s\n" "$p" "$(curl -sk -o /dev/null -w '%{http_code}' "$SITE$p")" # 200 / 200 / 302
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**第 (3) 項為什麼不能省**:攔截與記錄是兩件事。權限不足時 WAF 照樣回 403,但一筆記錄都寫不進去 —— 你得到一個沒有稽核軌跡的防火牆,事故調查時等於沒有。更嚴重的是同一個權限問題會讓 `session/` 建不出來,而 `wl_admin` 管理員白名單靠 `NFWSESSID` session 承載 `nfw_goodguy` 旗標,於是管理員實際上是被全規則掃描的 —— 這正是「後台操作偶爾莫名 403」的來源。
|
||||||
|
|
||||||
|
### 3.4 排查時的常見誤判
|
||||||
|
|
||||||
|
這幾項在實測中都出現過,全部會誤導判斷:
|
||||||
|
|
||||||
|
| 現象 | 直覺結論 | 實際 |
|
||||||
|
|---|---|---|
|
||||||
|
| 首頁 200、後台沒有錯誤訊息 | WAF 正常 | 完全不能推論。WAF 失效時是 `nfw_quit()` 靜默放行,不擋也不記 |
|
||||||
|
| 攻擊 payload 回 403 | Full WAF 生效 | 可能來自共享 mu-plugin 的 WP WAF。用 §3.3 第 (1) 項區分 |
|
||||||
|
| `nfwlog/` 下的檔案回 403 | nginx 規則生效 | 也可能是 nginx 以 www-data 開不了 root-only 檔案。修好權限後會突然變 200 |
|
||||||
|
| `/.well-known/` 回 403 | dotfile 規則誤擋了 ACME | 多半是目錄存在但 autoindex off。用實檔測:`/.well-known/acme-challenge/<file>` 若回 404(WordPress 頁面)而非 403,代表規則有正確放行 |
|
||||||
|
| `wp-login.php` 回 404 | nginx 改壞了 | 檢查是否啟用 `wps-hide-login` 之類的登入頁隱藏外掛 |
|
||||||
|
| `wp tmdo doctor` 全綠 | WAF 與外掛相容 | WP-CLI 完全豁免 WAF(`firewall.php:18-23`),CLI 結果不能當相容性證據,必須另外走 HTTP 驗證 |
|
||||||
|
|
||||||
|
### 3.5 每租戶獨立的維運負擔
|
||||||
|
|
||||||
|
symlink 共享的是 codebase,**不是設定與狀態**。以下每一項都是 per-tenant:
|
||||||
|
|
||||||
|
- **`nfw_options` / `nfw_rules`** 存在各自的 `wp_options`。`nfw_rules` 約 77 KB 且 `autoload=auto`,N 個租戶就是 N 份。
|
||||||
|
- **規則更新**要逐站執行,沒有集中派送機制。
|
||||||
|
- **log / cache / session** 各自獨立(前提是 §2 的 `NFW_LOG_DIR` 已正確設定,否則全部寫進共享目錄互相覆蓋)。
|
||||||
|
- **停用租戶站台時**,`nfwlog/readme.txt` 明示:解除安裝後要等 5 分鐘再刪除該目錄,否則站台可能崩潰。
|
||||||
|
|
||||||
|
規劃階段就要把這些算進成本 —— 特別是「規則更新要逐站跑」這點,租戶數量上去之後需要自動化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 開發約束(三條)
|
||||||
|
|
||||||
|
### 4.1 不得重導向 WAF 的設定選項
|
||||||
|
|
||||||
|
`TMDO_Options_Manager::register_settings_group()` 以 `pre_update_option_{key}` 回傳 `$old_value`,讓選項不再落地 `wp_options`。而 Full WAF 是用原生 mysqli 直查該表 —— 一旦 `nfw_options` / `nfw_rules` 被重導向,WAF 會讀不到設定而**靜默停止防護:不報錯、不寫 log**。
|
||||||
|
|
||||||
|
v1.0.2 起由 `PROTECTED_OPTIONS` 常數擋下,測試見 `tests/unit/OptionsManagerProtectedTest.php`。新增任何「WordPress 載入前就會以原生 SQL 讀取 `wp_options`」的第三方元件時,必須把它的設定鍵加進該清單。
|
||||||
|
|
||||||
|
**autoload 最佳化不受此限** —— `optimize_autoload()` 只改 `autoload` 欄位、不刪列,而 Full WAF 那句 `SELECT *` 不看 autoload。
|
||||||
|
|
||||||
|
### 4.2 不得把 option 名稱字串放進 GET/POST
|
||||||
|
|
||||||
|
規則 **322**(`lev=3`,CRITICAL):
|
||||||
|
|
||||||
|
```
|
||||||
|
(^|\S['"])nfw_(?:options|rules)\b
|
||||||
|
```
|
||||||
|
|
||||||
|
任何 GET/POST 值含 `nfw_options` 或 `nfw_rules` 字串即 **403**(why: "Attempt to modify NinjaFirewall settings")。
|
||||||
|
|
||||||
|
這對「列出 autoload 清單讓使用者勾選清理」這類 UI 是直接的地雷 —— 而 `nfw_rules` 正好是最大的 autoload 項目之一,必然出現在清單裡。若要做這種介面,用索引或 hash 當作 POST 值,或直接走 CLI。
|
||||||
|
|
||||||
|
### 4.3 不得以 base64 傳送含 SQL 語意的 payload
|
||||||
|
|
||||||
|
`nfw_check_b64()`(`firewall.php:1412-1448`,`post_b64` 選項,預設開)會把每個 POST 值 base64 解碼後再比對。明文的 SQL 規則多半需要「以數字/引號開頭」或「以註解結尾」才命中,**base64 版只要「含有」就 CRITICAL 403** —— 涵蓋 `SELECT...FROM...WHERE`、`INSERT INTO`、`UNION SELECT`、`UPDATE...SET`、以及序列化物件 `O:n:"..."`。
|
||||||
|
|
||||||
|
換言之,**編碼會讓事情變糟,不是變好**。硬編碼白名單只有 `fpd_print_order` 與 `g-recaptcha-response` 兩個欄位名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 風險矩陣(實測結果)
|
||||||
|
|
||||||
|
於 dev30(**Full WAF**、`wl_admin=1`、`no_restapi=0`、`admin_ajax` 未設)實測。**同一組項目在 WP WAF 模式下結果完全相同** —— 兩種模式都跑過:
|
||||||
|
|
||||||
|
| 項目 | 結果 |
|
||||||
|
|---|---|
|
||||||
|
| `wp tmdo status` / `wp tmdo doctor` | ✅ 不受影響(CLI 豁免) |
|
||||||
|
| `GET /wp-json/wpdo/v1/listings`(**無 cookie**) | ✅ 200,且回的是 JSON 而非 WAF 的 HTML 403 頁 |
|
||||||
|
| `?hp_price_min=100&hp_featured=1` 動態欄位 filter | ✅ 200 |
|
||||||
|
| 首頁 / REST 根 | ✅ 200 |
|
||||||
|
| 後台 `/wp-admin/` | ✅ 302(導向登入) |
|
||||||
|
| firewall log 中的 TMDO 相關攔截 | ✅ **0 筆**(log 內全部攔截皆為刻意送出的測試 payload) |
|
||||||
|
|
||||||
|
最後一項是判斷相容性的核心證據:不是「沒看到錯誤」,而是逐筆檢查 firewall log 的攔截記錄,確認沒有任何一筆來自本外掛的正常操作。
|
||||||
|
|
||||||
|
仍需留意的情境:
|
||||||
|
|
||||||
|
| 風險 | 觸發條件 | 緩解 |
|
||||||
|
|---|---|---|
|
||||||
|
| Full WAF 靜默失效 | WAF 設定選項被重導向 | §4.1(已由 `PROTECTED_OPTIONS` 擋下) |
|
||||||
|
| 規則 322 誤擋 | option 名稱字串進 GET/POST | §4.2 |
|
||||||
|
| migration 中斷 | `run_sync_loop()` 的 110 秒同步 POST(`class-tmdo-migration-orchestrator.php:443` `set_time_limit(120)`)被代理切斷;鎖 `LOCK_TTL_SEC=1800` 才釋放 | 大站用 `force_async=true` 或走 CLI |
|
||||||
|
| REST 回 HTML 而非 JSON | 非 administrator 角色,或以 Application Password / JWT 呼叫(無 `NFWSESSID` cookie)→ 不在 `wl_admin` 白名單。403 頁面是 HTML(`firewall.php:1585-1590`),client 端看到的是「JSON parse error」這種難查的錯 | 程式化呼叫走 CLI;或確認帶 cookie |
|
||||||
|
| admin-ajax 被當 bot | `admin_ajax` 選項開啟時,缺 `HTTP_ACCEPT` / `Accept-Language` / UA 不含 `Mozilla` 的請求回 **404**(`firewall.php:1760-1802`) | curl / server-to-server 呼叫需帶完整 header;預設此選項未開 |
|
||||||
|
|
||||||
|
**WP-CLI 完全豁免**:`firewall.php:18-23` 對 `defined('WP_CLI') && WP_CLI && PHP_SAPI === 'cli'` 直接 `return`。TMDO 全部 63 個 CLI 子命令零風險 —— 這也是 migration、snapshot restore、backfill 等重操作建議走 CLI 的另一個理由。
|
||||||
|
|
||||||
|
**管理員幾乎豁免**:`wl_admin=1` 時,帶 `NFWSESSID` cookie 的 administrator 只跑 3 條規則就 `nfw_quit(20)`(`firewall.php:233-252`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 為什麼不需要 AddOn
|
||||||
|
|
||||||
|
| 理由 | 證據 |
|
||||||
|
|---|---|
|
||||||
|
| 無對外 hook API | 全 codebase 零個 `apply_filters('nfw_*')` / `do_action('nfw_*')` |
|
||||||
|
| 官方相容手段都在部署層 | `.htninja`、`exclude_waf_list` UI、wp-config 常數 —— 沒有一項是 plugin 程式碼掛得上的 |
|
||||||
|
| 常見衝突模式在 TMDO 全不存在 | 無 loopback self-POST、無 `db.php`/`object-cache.php` drop-in、無 wp-config 改動、無 `auto_prepend` 操作、無 `$_FILES` 上傳 |
|
||||||
|
|
||||||
|
NinjaFirewall 處理「合法外掛送 SQL 被擋」的官方做法是**在引擎裡硬編碼白名單**(例:`firewall.php:1434-1439` 的 JetPack 例外),需向 NinTechNet 回報才會納入,第三方無法自行擴充。
|
||||||
|
|
||||||
|
因此本外掛的相容性工作全部落在**核心的三條約束**(§4)與**部署設定**(§2),沒有 AddOn 的著力點。
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# ADR-001: Post Entity Source-of-Truth Contract
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-05-15
|
||||||
|
**Deciders:** wpdev
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two code paths can intercept `update_post_metadata` / `add_post_metadata`:
|
||||||
|
|
||||||
|
1. **TMDO_Sync_Bridge** — the original Zone interceptor that dual-writes to
|
||||||
|
Hot (Zone A) and Cold (Zone C) flat tables.
|
||||||
|
2. **TMDO_Hook_Bus** — the Entity Bridge write path added in v2.9.x that writes
|
||||||
|
to `wp_wpdo_post_*` flat tables via Entity Registry groups.
|
||||||
|
|
||||||
|
When both are active without a clear contract, a single `update_post_meta()` call
|
||||||
|
can fan out to three distinct write paths (wp_postmeta + Zone table + entity flat
|
||||||
|
table), producing divergent row counts and confusing `TMDO_API::trace_storage()`
|
||||||
|
output.
|
||||||
|
|
||||||
|
The defensive patch `TMDO_Sync_Bridge::is_owned_by_entity_bridge()` (v2.9.2)
|
||||||
|
was added to prevent double-writes but left the authoritative contract undocumented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**When `post` entity mode is `dual_write`, `shadow_read`, or `aeav_only`,
|
||||||
|
Entity Bridge (TMDO_Hook_Bus) is the sole source of truth for keys registered
|
||||||
|
in TMDO_Entity_Registry under the `post` entity type.**
|
||||||
|
|
||||||
|
Sync_Bridge defers to Entity Bridge for those keys via `is_owned_by_entity_bridge()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// TMDO_Sync_Bridge — intercept_update() guard:
|
||||||
|
if ( self::is_owned_by_entity_bridge( $meta_key ) ) {
|
||||||
|
return $check; // pass-through — Entity Bridge owns this key
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_owned_by_entity_bridge()` returns `true` when both conditions hold:
|
||||||
|
- `TMDO_Mode_Manager::writes_to_flat('post')` — mode is at least dual_write
|
||||||
|
- `TMDO_Entity_Registry::get_field('post', $meta_key)` — key is registered
|
||||||
|
|
||||||
|
Keys **not** in Entity Registry continue to be owned by Sync_Bridge (Zone path).
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
| Condition | Owner |
|
||||||
|
|-----------|-------|
|
||||||
|
| post mode = `disabled` or `idle` | wp_postmeta (no interception) |
|
||||||
|
| post mode ≥ `dual_write` AND key in Entity Registry | **Entity Bridge** |
|
||||||
|
| post mode ≥ `dual_write` AND key not in Entity Registry | **Sync_Bridge** (Zone) |
|
||||||
|
| post mode = `aeav_only` | Entity Bridge for registered keys; unregistered keys fallthrough to wp_postmeta |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Good:**
|
||||||
|
- Developers adding a new post meta key can determine its owner in O(1): check
|
||||||
|
whether the key is in `wpdo_register_fields` under `post` entity. If yes →
|
||||||
|
Entity Bridge owns it; if no → Zone Sync_Bridge handles it.
|
||||||
|
- `TMDO_API::trace_storage()` output reflects this: Entity Bridge keys show the
|
||||||
|
flat entity table; Zone keys show the zone table.
|
||||||
|
|
||||||
|
**Bad / Watch out for:**
|
||||||
|
- A key registered in **both** Schema_Registry (Zone) and Entity_Registry (Entity
|
||||||
|
Bridge groups) will be captured by Entity Bridge and silently dropped by
|
||||||
|
Sync_Bridge. The duplicate registration is a misconfiguration — caught by
|
||||||
|
`TMDO_Sync_Bridge` guard and validated by `wp tmdo conflict-scan`.
|
||||||
|
- If `TMDO_Mode_Manager` or `TMDO_Entity_Registry` are unavailable (e.g. very
|
||||||
|
early bootstrap), `is_owned_by_entity_bridge()` returns `false` and all writes
|
||||||
|
fall through to the Zone path — safe degradation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `includes/interceptors/class-tmdo-sync-bridge.php` — `is_owned_by_entity_bridge()` (v2.9.2)
|
||||||
|
- `includes/engine/class-tmdo-hook-bus.php` — Entity Bridge write path
|
||||||
|
- `TMDO_API::trace_storage()` — human-readable storage path diagnostics
|
||||||
|
- `wp tmdo conflict-scan` — detects keys registered in both paths
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR-002: Acknowledge 'dual_write' naming collision between Mode_Manager and Feature_Flags FSMs
|
||||||
|
|
||||||
|
**Status:** Accepted — deferred rename
|
||||||
|
**Date:** 2026-05-15
|
||||||
|
**Deciders:** wpdev
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two distinct FSMs in the codebase both use the string `'dual_write'`:
|
||||||
|
|
||||||
|
| FSM | Class | Constant | Stored in | Semantics |
|
||||||
|
|-----|-------|----------|-----------|-----------|
|
||||||
|
| Entity Bridge | `TMDO_Mode_Manager` | `MODE_DUAL_WRITE` | `wpdo_bridge_modes` | Writes go to both UAE flat table AND `wp_*meta` |
|
||||||
|
| Zone Migration | `TMDO_Feature_Flags` | `STATUS_DUAL_WRITE` | `wpdo_features` | Writes go to both Zone A/B/C table AND `wp_postmeta` |
|
||||||
|
|
||||||
|
The two FSMs are orthogonal — a post type can be in Zone `dual_write`
|
||||||
|
(actively migrating) while the Entity Bridge is in `aeav_only` mode, or vice
|
||||||
|
versa. The collision was introduced when the Entity Bridge FSM was added in
|
||||||
|
v2.5.x alongside the pre-existing Zone Migration FSM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Defer the rename. Document the collision instead.**
|
||||||
|
|
||||||
|
Renaming either constant (e.g. Mode_Manager → `bridge_dual`) would require:
|
||||||
|
|
||||||
|
1. Updating ~85 call sites across 30+ files.
|
||||||
|
2. Writing a DB migration to translate stored option values (`'dual_write'` →
|
||||||
|
`'bridge_dual'` in `wp_options['wpdo_bridge_modes']`).
|
||||||
|
3. Updating all WP-CLI commands that accept mode strings as user input.
|
||||||
|
4. Updating all admin UI dropdowns and confirmation messages.
|
||||||
|
5. Handling sites that run the old code against a DB that has already been
|
||||||
|
migrated (or the reverse — new code on an un-migrated DB).
|
||||||
|
|
||||||
|
The risk of introducing bugs via a mechanical rename outweighs the naming
|
||||||
|
improvement at the current stage of the project.
|
||||||
|
|
||||||
|
The collision is mitigated by:
|
||||||
|
- A disambiguating docblock in `class-tmdo-mode-manager.php` (added 2026-05-15)
|
||||||
|
- This ADR, which explains the overlap to future developers
|
||||||
|
- The two FSMs operating on different option keys and being unreachable from
|
||||||
|
each other's code paths
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Future rename path**: When a DB migration is warranted (e.g. alongside
|
||||||
|
another schema change), rename `MODE_DUAL_WRITE → 'bridge_dual'` and add a
|
||||||
|
migration in `TMDO_Installer::maybe_upgrade()` that rewrites the stored string.
|
||||||
|
- **Linter**: If PHPStan or a custom rule ever flags string literal comparisons
|
||||||
|
across FSMs, this ADR is the canonical explanation for why the overlap is
|
||||||
|
intentional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `includes/engine/class-tmdo-mode-manager.php` — naming note in class docblock
|
||||||
|
- `includes/class-tmdo-feature-flags.php` — Zone FSM (7 states)
|
||||||
|
- P1-10 from full-review report (2026-05-15)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR-003:Hot zone 與 Entity 表收斂
|
||||||
|
|
||||||
|
- **狀態**:已接受(2026-09-26,選項 A)
|
||||||
|
- **日期**:2026-09-26
|
||||||
|
- **相關**:`BENCHMARK-SCALE.md`(O-3、O-5)、backlog PR-025〜PR-030
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
post 的高頻欄位有兩套扁平化機制,資料可能存兩份:
|
||||||
|
|
||||||
|
| | Hot zone | Entity bridge |
|
||||||
|
|---|---|---|
|
||||||
|
| 表 | `wp_wpdo_hot_{post_type}`(每個 post type 一張) | `wp_wpdo_post_{group}`(每個群組一張) |
|
||||||
|
| 註冊 | `TMDO_Schema_Registry`(zone = hot) | `TMDO_Entity_Registry::register_group()` |
|
||||||
|
| 寫入 | `TMDO_Sync_Bridge`(zone 模組進入 dual_write 後) | `TMDO_Hook_Bus`(實體 mode ≥ dual_write) |
|
||||||
|
| 查詢改寫 | `TMDO_Query_Router`(`hot_*` 模組 cutover 後) | `TMDO_Post_Query_Router`(post mode ≥ shadow_read) |
|
||||||
|
| 生命週期 | 每個 zone 模組 7 狀態 FSM | 每個實體 4 模式(disabled → aeav_only) |
|
||||||
|
| 涵蓋實體 | 只有 post | post、user、term、comment |
|
||||||
|
| 可完全取代原生 meta | 否(cutover 後原生仍保留) | 是(aeav_only) |
|
||||||
|
| 其他 | 單欄 `indexed`、自訂 `data_type` | post_type 範圍(PR-033)、schema hash、衝突偵測、孤兒還原、回寫原生(PR-017/018/031) |
|
||||||
|
|
||||||
|
Warm(瀏覽數 TTL)、Cold(JSON blob)、Archive(已刪除文章的 meta)是 Entity 沒有的能力,**不在本決策範圍**,保持原狀。
|
||||||
|
|
||||||
|
## 事實
|
||||||
|
|
||||||
|
**效能**(100k vendor / 1M listing,`BENCHMARK-SCALE.md`):
|
||||||
|
|
||||||
|
| 情境 | Hot | Entity |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 價格區間搜尋 p50 | 558 ms | 652 ms |
|
||||||
|
| 價格區間 + verified p50 | **277 ms**(有複合索引) | 337 ms |
|
||||||
|
| vendor 名下 listing p50 | 3,405 ms(無索引) | **0.28 ms** |
|
||||||
|
| 單筆 / 列表預載 | 相同 | 相同 |
|
||||||
|
| 容量 | 229 MB | 379 MB |
|
||||||
|
|
||||||
|
唯一 Hot 領先的是有複合索引的情境;Entity 支援複合索引後(PR-027)即可追平。
|
||||||
|
|
||||||
|
**各站使用現況**(2026-09-26 唯讀查詢):
|
||||||
|
|
||||||
|
| 站 | Hot / Cold 表 | zone 模組 | post entity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| dev20 / dev21 / dev22 | 無 | 全 idle | aeav_only(dev21 約 400 萬列) |
|
||||||
|
| dev23 / 24 / 30 / 31 / 40 | 無 | 全 idle | disabled |
|
||||||
|
| dev34(舊 WPDO 1.3.37) | hot_hp_listing 486、hot_hp_vendor 233 | cutover | disabled |
|
||||||
|
| dev10 DB(舊版) | hot_hp_listing 491、hot_hp_vendor 231 | cutover | aeav_only(約 3,900 列) |
|
||||||
|
|
||||||
|
- **沒有任何 2meet-DO 站在用 Hot zone**;有扁平資料的站全部走 Entity。
|
||||||
|
- 只有兩個舊系統的站有 Hot 資料,而它們在 cutover 仍雙寫 postmeta,Hot 表只是副本(PR-022 已確認以 postmeta 為準)。
|
||||||
|
|
||||||
|
**只由 Hot zone 註冊、Entity 群組沒有涵蓋的 HP 欄位**:listing / request 的 `hp_drafted`;bookings 的 `hp_start_time`、`hp_end_time`、`hp_quantity`;memberships 的 `hp_expire_period`、`hp_primary`、`hp_expired_time`。
|
||||||
|
|
||||||
|
## 選項
|
||||||
|
|
||||||
|
**A. 收斂到 Entity(建議)**
|
||||||
|
post 高頻欄位只用 Entity。Hot zone 凍結:不再新增註冊,程式碼暫留只為讀取舊資料,確認沒有站使用後再移除。
|
||||||
|
|
||||||
|
- 優點:一套路由、一份資料;Entity 能力是 Hot 的超集;現況已經全部在用 Entity,不需要搬資料。
|
||||||
|
- 代價:Hot 獨有的欄位(上面那幾個)要改成新的 Entity 群組;情境 2 要靠 PR-027 的複合索引追平。
|
||||||
|
|
||||||
|
**B. 兩套並存**
|
||||||
|
維持現狀。代價是兩套寫入和查詢路徑都要維護,同一個 key 可能存兩份,O-3 之類的最佳化要做兩次。
|
||||||
|
|
||||||
|
**C. 收斂到 Hot**
|
||||||
|
不可行:Hot 只支援 post,也沒有 aeav_only、範圍宣告、回寫工具。
|
||||||
|
|
||||||
|
## 建議(選項 A)的後續步驟
|
||||||
|
|
||||||
|
1. **PR-026(Hot 表 bigint 索引)標為 `[!]` 不做**:沒有站在用 Hot zone。
|
||||||
|
2. **PR-027(Entity 複合索引)照常做**:這是情境 2 追平 Hot 的方式。
|
||||||
|
3. **新增 PR(HP AddOn)**:把 Hot 獨有的欄位改註冊成新的 Entity 群組(例如 `hp_booking_core`、`hp_membership_core`,以及 listing / request 的補充群組);既有群組的定義不能改。
|
||||||
|
4. **新增 PR(HP AddOn)**:HP adapter 停止註冊 hot 欄位;核心對 zone = hot 的新註冊記錄 deprecated 警告。
|
||||||
|
5. **之後**:dev34(PR-022)和 dev10 DB(PR-023)遷出後,再評估移除 `TMDO_Zone_Hot`、`TMDO_Sync_Bridge` 的 hot 路徑與 `TMDO_Query_Router`。
|
||||||
|
|
||||||
|
## 決定
|
||||||
|
|
||||||
|
**選項 A:收斂到 Entity。**(使用者 2026-09-26 決定)
|
||||||
|
|
||||||
|
Hot zone 凍結,依上面的後續步驟執行;backlog 已同步調整(PR-026 標為 `[!]`,新增 HP AddOn 的兩個 PR)。
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# ADR-004:HivePress / WooCommerce 站採「搜尋索引模式」
|
||||||
|
|
||||||
|
- **狀態**:已接受(2026-09-26)
|
||||||
|
- **日期**:2026-09-26
|
||||||
|
- **相關**:dev36 整合測試報告(`notes/tmdo-integration-test-dev36-2026-09-26.md`)、ADR-001、ADR-003、backlog PR-052〜PR-063(Release 2.1)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
2026-09-26 在 dev36(HivePress 1.7.31 + Marketplace + 10 個擴充、WooCommerce 11.1.2、Redis、10 萬筆 listing)做整合與壓力測試,確認:
|
||||||
|
|
||||||
|
1. **HivePress model 與 WC data store 用空 key 讀 meta**(`get_post_meta( $id )`),1.0.x 的 Hook Bus 對空 key 放行、只讀原生。所以 aeav_only 或 cleanup 之後,HP 的 verified / rating、WC 的價格與顧客資料讀成空值(dev20 / dev21 盤點:WC 商品 0/20 可購買)。
|
||||||
|
2. **WC 以直接 SQL 更新 `_stock`、`total_sales`**,扁平表跟不上。
|
||||||
|
3. **shadow_read 會遺失寫入**:WP 的 `update_metadata()` 經 `get_metadata_raw` 讀舊值,攔截器從扁平表回答,新舊相同就不寫原生。
|
||||||
|
4. **Router 在 OR、LIKE、多 post type、跨 group、具名 orderby 上結果錯誤**,HivePress 的 `_sort` 沒被改寫,前台搜尋幾乎沒有變快。
|
||||||
|
5. **陣列值被存成 `"Array"`**。
|
||||||
|
|
||||||
|
帶 key 的單筆讀取本來就沒有速度優勢:`update_meta_cache()` 一次載入全部 key,之後都是記憶體讀取。扁平表真正的價值在**搜尋與篩選**(`BENCHMARK-SCALE.md` 結論 1、2)。
|
||||||
|
|
||||||
|
## 決策(使用者 2026-09-26)
|
||||||
|
|
||||||
|
1. **HP / WC 站採「搜尋索引模式」**:原生 meta 是唯一真實資料來源,扁平表是它的搜尋索引。模式最高到 `shadow_read`;`aeav_only` 與 cleanup 由 Promotion Guard 擋下。
|
||||||
|
2. **移除 WC 扁平化**(Release 2.2:core 的 `post/wc_product` 群組、WC AddOn 的 user `hot` 群組與 hot-zone 欄位)。
|
||||||
|
3. **既有 aeav_only 站(dev20 / 21 / 22)先唯讀盤點**,處置由使用者決定(`notes/ops-aeav-sites-audit-2026-09-26.md`)。
|
||||||
|
4. **分兩版**:2.1 = 止血 + 資料正確性 + Router 與 HP 排序;2.2 = 範圍收斂 + 漂移偵測 + CLI 與診斷。
|
||||||
|
|
||||||
|
## 設計(Release 2.1)
|
||||||
|
|
||||||
|
### 寫入:扁平表跟隨原生
|
||||||
|
|
||||||
|
- **dual_write / shadow_read** 下,pre-filter(`update_/add_/delete_{type}_metadata`)只做路由判斷後回 `null`,讓 WordPress 自己寫原生。
|
||||||
|
- 扁平表在 `added_ / updated_ / deleted_{type}_meta` action(priority 1)裡**從原生重新同步**:直接 SQL 讀原生第一筆 row(`ORDER BY meta_id LIMIT 1`,不經 filter),扁平值 = `to_db( maybe_unserialize( v ) )`,原生沒有 row 時設為 NULL(`TMDO_Hook_Bus::sync_from_native()`)。
|
||||||
|
- `delete_metadata( …, $delete_all )`:`deleted_` action 拿到的 object id 是 0,所以在 `delete_{type}_meta` 先記下 object ids。
|
||||||
|
- `update_metadata_by_mid()` 改 key:新舊兩個 key 都重新同步。
|
||||||
|
- 原生沒有值時只把既有列的欄位設為 NULL,**不新增列**:刪除實體時 adapter 先刪扁平列,WordPress 才逐筆刪 meta。
|
||||||
|
- **aeav_only** 保留原本的 pre-filter short-circuit(非 HP / WC 站仍可使用)。
|
||||||
|
|
||||||
|
### 讀取:shadow_read = 讀原生、抽樣比對扁平
|
||||||
|
|
||||||
|
`intercept_get` 在 shadow_read 回 `$check`(讓 WordPress 讀原生),並以 `tmdo_shadow_compare_sample_rate`(預設 0.1)抽樣比對扁平值、記錄差異。查詢加速不受影響,因為 Router 仍由 `reads_from_flat()`(shadow_read 以上)控制。
|
||||||
|
|
||||||
|
### Promotion Guard
|
||||||
|
|
||||||
|
`TMDO_Promotion_Guard`,由 `TMDO_Mode_Manager::set()`、`TMDO_Post_Migration::cleanup()` 與 `TMDO_Phase_Cleanup::execute()` 呼叫:
|
||||||
|
|
||||||
|
- HivePress 啟用時,post / user / term / comment 不得升到 aeav_only 或執行 cleanup;WooCommerce 啟用時,post / user 不得。
|
||||||
|
- 回傳 `WP_Error( 'promotion_guarded' )`;特殊情況用 filter `tmdo_promotion_guard_allow` 放行。
|
||||||
|
- 已經在 aeav_only 的站**讀取時不強制降級**(降級會讀到空的原生),只在後台顯示 notice。處置用 `wp tmdo bridge-restore-native <entity> --demote`。
|
||||||
|
|
||||||
|
### 回寫原生(bridge-restore-native)
|
||||||
|
|
||||||
|
依模式決定策略:aeav_only 預設 `overwrite`(寫入只到扁平表、原生已過期),其他模式預設 `only_missing`(原生可能較新,例如 WC 直接 SQL 寫庫存)。相同的值不重寫;扁平值等於欄位 default 且原生沒有 row 時不寫(不捏造預設值)。
|
||||||
|
|
||||||
|
### 序列化值
|
||||||
|
|
||||||
|
`text` / `textarea` 遇到陣列或物件時 `maybe_serialize()`,讀出時 `TMDO_Safe_Unserialize`;`text`(VARCHAR(255))超長存 NULL 並記 `text_overflow`。既有的 `"Array"` 由 `wp tmdo repair-array-literals` 從原生修復。
|
||||||
|
|
||||||
|
### Router:只在語意與原生相同時路由
|
||||||
|
|
||||||
|
`TMDO_Post_Query_Router`(`pre_get_posts` priority 1000,排在外掛於預設 priority 加的條件之後):
|
||||||
|
|
||||||
|
- **全有或全無**:任何一個已註冊 key 的 clause 不能路由,整個查詢交回 WordPress。不能路由的情況:
|
||||||
|
- top-level OR
|
||||||
|
- 含已註冊 key 的巢狀 group(排序形狀除外)
|
||||||
|
- 多個 post type
|
||||||
|
- 白名單以外的 compare
|
||||||
|
- type 與欄位語意不同:無 type(CHAR)的數值範圍比較、decimal 欄位的 CHAR 比較、text 欄位用 NUMERIC
|
||||||
|
- **LIKE** 包成 `%esc_like%`。
|
||||||
|
- **每個 group 各自 LEFT JOIN**;扁平表一文章一列(UNIQUE `post_id`),不加 GROUP BY。
|
||||||
|
- 狀態 / 日期副本路由遇到 NOT EXISTS 時停用:INNER JOIN 會漏掉沒有扁平列的文章。
|
||||||
|
- **排序**(`wpdo_post_sort`):
|
||||||
|
- **HivePress `_sort` 形狀**:OR 裡剛好是同 key、同 type 的 NOT EXISTS + EXISTS,其中一個具名,並且是唯一的 orderby。這個 group 從 meta_query 移除,改成 LEFT JOIN 扁平表並 `ORDER BY col, ID DESC`;ASC 時 NULL 排最前,與原生相同。
|
||||||
|
- **具名 clause** 作為唯一的 orderby。
|
||||||
|
- **`meta_key` + `meta_value_num`**(字串欄位用 `meta_value`),加上 `col IS NOT NULL`(原生是 INNER JOIN)。
|
||||||
|
- 欄位排序不等於 `CAST( meta_value AS type )` 時退回原生。
|
||||||
|
- **flat-first**:依扁平欄位排序、且 routed 條件已要求扁平列存在(同一 group、非 NOT EXISTS)、沒有 `post__in` / `author` 等高選擇性條件時,改寫成 `SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts`,ORDER BY 的 `wp_posts.ID` 改讀等值的 `post_id`,排序只在一張表完成。依日期排序的搜尋不套用(由 posts 驅動較快)。filter `tmdo_router_flat_first` 可關閉。
|
||||||
|
- **維持 WordPress 的 split query**:`posts_request` 移除 `SQL_CALC_FOUND_ROWS`(總數改由快取的 COUNT 回答)會讓 WordPress 以為 request 被改過而撈整列。`split_the_query` 對 routed 查詢恢復 ID-first,`posts_request_ids` 再移除一次 `SQL_CALC_FOUND_ROWS`。
|
||||||
|
|
||||||
|
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
|
||||||
|
|
||||||
|
## 設計(Release 2.2)
|
||||||
|
|
||||||
|
### 範圍收斂
|
||||||
|
|
||||||
|
- core 不再註冊 `post/wc_product`;WooCommerce AddOn 0.2.0 不再註冊 product / order / subscription 的 zone 欄位與 user `hot` 群組。WooCommerce 的 meta 只存在原生。既有表以 `bridge-restore-native post --group=wc_product --retire` 封存。
|
||||||
|
|
||||||
|
### 漂移偵測與修復(`TMDO_Drift_Check`)
|
||||||
|
|
||||||
|
- `wp tmdo drift-check <entity>` 以值比對扁平與原生(第一筆原生 row → 安全 unserialize → `Type_Caster::to_db`),逐 key 回報 miss / diff / extra,結果寫入 `wpdo_drift_status`。
|
||||||
|
- `--fix` 從原生重建漂移列,刪除已刪實體與非本 group post type 的列,不寫原生。
|
||||||
|
- **aeav_only 不適用**:那時扁平才是真實資料,原生本來就過期。`--fix` 拒絕、每日抽樣略過、Router 不因漂移退回原生。
|
||||||
|
- **Router**:上次檢查有漂移的 group 不路由,整個查詢維持原生;從未檢查的 group 照常路由(決策 2026-09-27:升級後不變慢)。
|
||||||
|
- **每日抽樣**:`wpdo_daily_health_check`,每 group 500 筆。
|
||||||
|
- **Hook Bus 關閉期間的寫入**:entity 從 disabled 進入 dual_write / shadow_read、或 Hook Bus flag 由關轉開時,60 秒後 `tmdo_drift_resync` 執行全量 `--fix`。不用 `post_modified_gmt` 增量,因為 WooCommerce 直接 SQL 寫 meta 不會更新它。
|
||||||
|
- **Auto-promoter** 以 `clean_since()`(2 天內乾淨的檢查)取代 shadow diff 數量;Promotion Guard 仍擋 HP / WC 站。
|
||||||
|
|
||||||
|
### 查詢
|
||||||
|
|
||||||
|
- 純排序搜尋(HivePress `_sort`、沒有路由篩選)在該 post type 每篇都有扁平列時改由扁平表驅動;Router 在 `wp_insert_post` 替新文章建空列,覆蓋率快取 15 分鐘。
|
||||||
|
|
||||||
|
## 後果
|
||||||
|
|
||||||
|
**好處**
|
||||||
|
|
||||||
|
- 資料正確性回到 WordPress 原生語意:HP / WC 的空 key 讀取、直接 SQL 寫入都不再造成資料不一致或遺失。
|
||||||
|
- 查詢加速保留,而且擴及 HivePress 前台的篩選與排序(實測見 `BENCHMARK-SCALE.md`「前台與寫入鏈實測」)。
|
||||||
|
- 任何時候都可以降回 disabled,原生資料完整。
|
||||||
|
|
||||||
|
**代價與限制**
|
||||||
|
|
||||||
|
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
|
||||||
|
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
|
||||||
|
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 起由 `drift-check`(每日抽樣、Router 避開漂移中的 group)與重新啟用時的全量 `--fix` 處理;兩次抽樣之間的漂移仍可能短暫回傳過期結果,大量匯入後可手動執行 `drift-check <entity> --fix`。
|
||||||
|
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
|
||||||
|
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
|
||||||
|
- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。
|
||||||
|
|
||||||
|
## 不在範圍內
|
||||||
|
|
||||||
|
- aeav_only 的空 key 讀取合併(B02):HP / WC 站不再使用 aeav_only,是否實作依其他站需求決定。
|
||||||
|
- Hot zone 的移除(ADR-003 的後續步驟)。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Anti-EAV Strict Lint
|
||||||
|
|
||||||
|
# Drop this file into each partner plugin's `.github/workflows/anti-eav-lint.yml`.
|
||||||
|
# Required: `2meet-data-optimizer` v1.0.0+ available either via:
|
||||||
|
# (a) Composer dev dependency on the plugin repo, OR
|
||||||
|
# (b) Side-by-side checkout in the same workspace.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master, develop ]
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
anti-eav-lint:
|
||||||
|
name: Anti-EAV Strict Lint (wp tmdo lint --strict)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout this plugin
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: this-plugin
|
||||||
|
|
||||||
|
- name: Checkout 2meet-data-optimizer
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: wpdev/2meet-data-optimizer
|
||||||
|
path: 2meet-data-optimizer
|
||||||
|
ref: v1.0.0
|
||||||
|
|
||||||
|
- name: Setup PHP 8.3
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
tools: composer:v2, wp-cli
|
||||||
|
|
||||||
|
- name: Install 2meet-data-optimizer dependencies
|
||||||
|
working-directory: 2meet-data-optimizer
|
||||||
|
run: composer install --no-interaction --no-dev --prefer-dist
|
||||||
|
|
||||||
|
- name: Bootstrap minimal WP for wp-cli
|
||||||
|
run: |
|
||||||
|
# Install a throwaway WordPress so wp-cli has runtime context.
|
||||||
|
wp core download --path=/tmp/wp --skip-content
|
||||||
|
wp config create --path=/tmp/wp --dbname=wp_lint --dbuser=root --dbpass=root --dbhost=127.0.0.1
|
||||||
|
# Symlink 2meet-data-optimizer into the wp-content/plugins dir so its CLI loads.
|
||||||
|
mkdir -p /tmp/wp/wp-content/plugins
|
||||||
|
ln -s "$GITHUB_WORKSPACE/2meet-data-optimizer" /tmp/wp/wp-content/plugins/2meet-data-optimizer
|
||||||
|
|
||||||
|
- name: Run wp tmdo lint --strict
|
||||||
|
run: |
|
||||||
|
cd /tmp/wp
|
||||||
|
wp --skip-themes --skip-plugins=all tmdo lint \
|
||||||
|
--plugin="$GITHUB_WORKSPACE/this-plugin" \
|
||||||
|
--strict \
|
||||||
|
--max-autoload=30
|
||||||
|
# Exit non-zero on:
|
||||||
|
# - Direct SELECT FROM wp_postmeta / wp_usermeta / wp_termmeta / wp_commentmeta
|
||||||
|
# - update_post_meta() on a field already registered to the TMDO Schema Registry
|
||||||
|
# - autoload=yes options exceeding --max-autoload (default 30)
|
||||||
|
# - meta_query with ≥3 conditions but no wpdo_register_fields
|
||||||
|
# Bypass: phpcs:ignore WPDO.AntiEAV.<rule> -- <reason>
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for FSM state advisor
|
|
||||||
/**
|
/**
|
||||||
* TMDO_FSM_Advisor — Recommends the next FSM state per module (v2.4.0 M12).
|
* TMDO_FSM_Advisor — Recommends the next FSM state per module (v2.4.0 M12).
|
||||||
*
|
*
|
||||||
@@ -22,6 +21,10 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for FSM state advisor
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -282,7 +284,6 @@ class TMDO_Module_Detector {
|
|||||||
$active = match ( $plugin ) {
|
$active = match ( $plugin ) {
|
||||||
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
|
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
|
||||||
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
||||||
'hpct' => TMDO_Compatibility::is_hpct_active(),
|
|
||||||
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
|
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
|
||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -27,52 +29,10 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
class TMDO_Module_Rules {
|
class TMDO_Module_Rules {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default rules for the 15 known modules. Use `get()` to apply filter.
|
* Default rules for the 8 known modules. Use `get()` to apply filter.
|
||||||
*/
|
*/
|
||||||
private const DEFAULT_RULES = array(
|
private const DEFAULT_RULES = array(
|
||||||
// ─── HPCT modules(HivePress + HPCT)────────────────────────────
|
// ─── AddOn modules ──────────────────────────────────────────────
|
||||||
'reviews' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_review',
|
|
||||||
'min_post_count' => 100,
|
|
||||||
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
|
|
||||||
),
|
|
||||||
'messages' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_message_thread',
|
|
||||||
'min_post_count' => 50,
|
|
||||||
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
|
|
||||||
),
|
|
||||||
'favorites' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_favorite',
|
|
||||||
'min_post_count' => 200,
|
|
||||||
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
|
|
||||||
),
|
|
||||||
'memberships' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_membership',
|
|
||||||
'min_post_count' => 50,
|
|
||||||
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
|
|
||||||
),
|
|
||||||
'statistics' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_listing',
|
|
||||||
'min_post_count' => 500,
|
|
||||||
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
|
|
||||||
),
|
|
||||||
'requests' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_request',
|
|
||||||
'min_post_count' => 50,
|
|
||||||
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
|
|
||||||
),
|
|
||||||
'listing_meta' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_listing',
|
|
||||||
'min_post_count' => 100,
|
|
||||||
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
|
|
||||||
),
|
|
||||||
'wc_orders' => array(
|
'wc_orders' => array(
|
||||||
'compat_required' => array( 'woocommerce' ),
|
'compat_required' => array( 'woocommerce' ),
|
||||||
'post_type_required' => 'shop_order',
|
'post_type_required' => 'shop_order',
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* @since 0.1.0
|
* @since 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* @since 0.1.0
|
* @since 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-113
@@ -9,127 +9,129 @@
|
|||||||
* @package TMDO
|
* @package TMDO
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 公開合約類別(Phase 1 凍結,不可移除)─────────────────────────────
|
// ── 公開合約類別(Phase 1 凍結,不可移除)─────────────────────────────
|
||||||
$tmdo_class_aliases = array(
|
$tmdo_class_aliases = array(
|
||||||
'TMDO_API' => 'WPDO_API',
|
'TMDO_API' => 'WPDO_API',
|
||||||
'TMDO_Schema_Registry' => 'WPDO_Schema_Registry',
|
'TMDO_Schema_Registry' => 'WPDO_Schema_Registry',
|
||||||
'TMDO_Entity_Registry' => 'WPDO_Entity_Registry',
|
'TMDO_Entity_Registry' => 'WPDO_Entity_Registry',
|
||||||
'TMDO_Custom_Table_Registry' => 'WPDO_Custom_Table_Registry',
|
'TMDO_Custom_Table_Registry' => 'WPDO_Custom_Table_Registry',
|
||||||
'TMDO_Feature_Flags' => 'WPDO_Feature_Flags',
|
'TMDO_Feature_Flags' => 'WPDO_Feature_Flags',
|
||||||
'TMDO_DB' => 'WPDO_DB',
|
'TMDO_DB' => 'WPDO_DB',
|
||||||
'TMDO_Crypto' => 'WPDO_Crypto',
|
'TMDO_Crypto' => 'WPDO_Crypto',
|
||||||
'TMDO_Logger' => 'WPDO_Logger',
|
'TMDO_Logger' => 'WPDO_Logger',
|
||||||
'TMDO_Capability' => 'WPDO_Capability',
|
'TMDO_Capability' => 'WPDO_Capability',
|
||||||
'TMDO_Safe_Unserialize' => 'WPDO_Safe_Unserialize',
|
'TMDO_Safe_Unserialize' => 'WPDO_Safe_Unserialize',
|
||||||
// Engine / Migration / Adapters(second-tier,部分 sister plugin 已直接呼叫)
|
// Engine / Migration / Adapters(second-tier,部分 sister plugin 已直接呼叫)
|
||||||
'TMDO_Hook_Bus' => 'WPDO_Hook_Bus',
|
'TMDO_Hook_Bus' => 'WPDO_Hook_Bus',
|
||||||
'TMDO_Mode_Manager' => 'WPDO_Mode_Manager',
|
'TMDO_Mode_Manager' => 'WPDO_Mode_Manager',
|
||||||
'TMDO_Migration_Engine' => 'WPDO_Migration_Engine',
|
'TMDO_Migration_Engine' => 'WPDO_Migration_Engine',
|
||||||
'TMDO_Migration_Orchestrator' => 'WPDO_Migration_Orchestrator',
|
'TMDO_Migration_Orchestrator' => 'WPDO_Migration_Orchestrator',
|
||||||
'TMDO_Entity_Migration_Engine' => 'WPDO_Entity_Migration_Engine',
|
'TMDO_Entity_Migration_Engine' => 'WPDO_Entity_Migration_Engine',
|
||||||
'TMDO_Entity_Health' => 'WPDO_Entity_Health',
|
'TMDO_Entity_Health' => 'WPDO_Entity_Health',
|
||||||
'TMDO_Schema_Manager' => 'WPDO_Schema_Manager',
|
'TMDO_Schema_Manager' => 'WPDO_Schema_Manager',
|
||||||
'TMDO_Sync_Bridge' => 'WPDO_Sync_Bridge',
|
'TMDO_Sync_Bridge' => 'WPDO_Sync_Bridge',
|
||||||
'TMDO_Query_Router' => 'WPDO_Query_Router',
|
'TMDO_Query_Router' => 'WPDO_Query_Router',
|
||||||
'TMDO_Post_Query_Router' => 'WPDO_Post_Query_Router',
|
'TMDO_Post_Query_Router' => 'WPDO_Post_Query_Router',
|
||||||
'TMDO_Hook_Bus_Bridge' => 'WPDO_Hook_Bus_Bridge',
|
'TMDO_Hook_Bus_Bridge' => 'WPDO_Hook_Bus_Bridge',
|
||||||
'TMDO_Conflict_Monitor' => 'WPDO_Conflict_Monitor',
|
'TMDO_Conflict_Monitor' => 'WPDO_Conflict_Monitor',
|
||||||
'TMDO_Compatibility' => 'WPDO_Compatibility',
|
'TMDO_Compatibility' => 'WPDO_Compatibility',
|
||||||
'TMDO_Installer' => 'WPDO_Installer',
|
'TMDO_Installer' => 'WPDO_Installer',
|
||||||
'TMDO_Cache_Layer' => 'WPDO_Cache_Layer',
|
'TMDO_Cache_Layer' => 'WPDO_Cache_Layer',
|
||||||
'TMDO_Zone_Classifier' => 'WPDO_Zone_Classifier',
|
'TMDO_Zone_Classifier' => 'WPDO_Zone_Classifier',
|
||||||
'TMDO_Core' => 'WPDO_Core',
|
'TMDO_Core' => 'WPDO_Core',
|
||||||
'TMDO_REST_API' => 'WPDO_REST_API',
|
'TMDO_REST_API' => 'WPDO_REST_API',
|
||||||
'TMDO_Snapshot_Manager' => 'WPDO_Snapshot_Manager',
|
'TMDO_Snapshot_Manager' => 'WPDO_Snapshot_Manager',
|
||||||
'TMDO_Snapshot_Writer' => 'WPDO_Snapshot_Writer',
|
'TMDO_Snapshot_Writer' => 'WPDO_Snapshot_Writer',
|
||||||
'TMDO_Snapshot_Reader' => 'WPDO_Snapshot_Reader',
|
'TMDO_Snapshot_Reader' => 'WPDO_Snapshot_Reader',
|
||||||
'TMDO_Snapshot_Pruner' => 'WPDO_Snapshot_Pruner',
|
'TMDO_Snapshot_Pruner' => 'WPDO_Snapshot_Pruner',
|
||||||
'TMDO_FSM_Guard' => 'WPDO_FSM_Guard',
|
'TMDO_FSM_Guard' => 'WPDO_FSM_Guard',
|
||||||
'TMDO_Site_Health' => 'WPDO_Site_Health',
|
'TMDO_Site_Health' => 'WPDO_Site_Health',
|
||||||
'TMDO_Health_Cron' => 'WPDO_Health_Cron',
|
'TMDO_Health_Cron' => 'WPDO_Health_Cron',
|
||||||
'TMDO_Email_Notifier' => 'WPDO_Email_Notifier',
|
'TMDO_Email_Notifier' => 'WPDO_Email_Notifier',
|
||||||
'TMDO_Slack_Notifier' => 'WPDO_Slack_Notifier',
|
'TMDO_Slack_Notifier' => 'WPDO_Slack_Notifier',
|
||||||
'TMDO_Discord_Notifier' => 'WPDO_Discord_Notifier',
|
'TMDO_Discord_Notifier' => 'WPDO_Discord_Notifier',
|
||||||
'TMDO_Telegram_Notifier' => 'WPDO_Telegram_Notifier',
|
'TMDO_Telegram_Notifier' => 'WPDO_Telegram_Notifier',
|
||||||
'TMDO_Monthly_Summary' => 'WPDO_Monthly_Summary',
|
'TMDO_Monthly_Summary' => 'WPDO_Monthly_Summary',
|
||||||
'TMDO_Site_Metrics_Collector' => 'WPDO_Site_Metrics_Collector',
|
'TMDO_Site_Metrics_Collector' => 'WPDO_Site_Metrics_Collector',
|
||||||
'TMDO_FSM_Advisor' => 'WPDO_FSM_Advisor',
|
'TMDO_FSM_Advisor' => 'WPDO_FSM_Advisor',
|
||||||
'TMDO_Module_Detector' => 'WPDO_Module_Detector',
|
'TMDO_Module_Detector' => 'WPDO_Module_Detector',
|
||||||
'TMDO_FSM_Automator' => 'WPDO_FSM_Automator',
|
'TMDO_FSM_Automator' => 'WPDO_FSM_Automator',
|
||||||
'TMDO_Module_Rules' => 'WPDO_Module_Rules',
|
'TMDO_Module_Rules' => 'WPDO_Module_Rules',
|
||||||
'TMDO_CSV_Writer' => 'WPDO_CSV_Writer',
|
'TMDO_CSV_Writer' => 'WPDO_CSV_Writer',
|
||||||
'TMDO_Export' => 'WPDO_Export',
|
'TMDO_Export' => 'WPDO_Export',
|
||||||
'TMDO_CLI' => 'WPDO_CLI',
|
'TMDO_CLI' => 'WPDO_CLI',
|
||||||
'TMDO_CLI_V2' => 'WPDO_CLI_V2',
|
'TMDO_CLI_V2' => 'WPDO_CLI_V2',
|
||||||
'TMDO_CLI_Member' => 'WPDO_CLI_Member',
|
'TMDO_CLI_Member' => 'WPDO_CLI_Member',
|
||||||
'TMDO_CLI_Post' => 'WPDO_CLI_Post',
|
'TMDO_CLI_Post' => 'WPDO_CLI_Post',
|
||||||
'TMDO_CLI_Term_Comment' => 'WPDO_CLI_Term_Comment',
|
'TMDO_CLI_Term_Comment' => 'WPDO_CLI_Term_Comment',
|
||||||
'TMDO_Member_Fields' => 'WPDO_Member_Fields',
|
'TMDO_Member_Fields' => 'WPDO_Member_Fields',
|
||||||
'TMDO_Post_Fields' => 'WPDO_Post_Fields',
|
'TMDO_Post_Fields' => 'WPDO_Post_Fields',
|
||||||
'TMDO_Points_Manager' => 'WPDO_Points_Manager',
|
'TMDO_Points_Manager' => 'WPDO_Points_Manager',
|
||||||
'TMDO_Demo_Entity_Counter' => 'WPDO_Demo_Entity_Counter',
|
'TMDO_Demo_Entity_Counter' => 'WPDO_Demo_Entity_Counter',
|
||||||
'TMDO_Postmeta_Cleaner' => 'WPDO_Postmeta_Cleaner',
|
'TMDO_Postmeta_Cleaner' => 'WPDO_Postmeta_Cleaner',
|
||||||
'TMDO_Termmeta_Cleaner' => 'WPDO_Termmeta_Cleaner',
|
'TMDO_Termmeta_Cleaner' => 'WPDO_Termmeta_Cleaner',
|
||||||
'TMDO_Commentmeta_Cleaner' => 'WPDO_Commentmeta_Cleaner',
|
'TMDO_Commentmeta_Cleaner' => 'WPDO_Commentmeta_Cleaner',
|
||||||
'TMDO_Post_Stress_Tester' => 'WPDO_Post_Stress_Tester',
|
'TMDO_Post_Stress_Tester' => 'WPDO_Post_Stress_Tester',
|
||||||
'TMDO_User_Stress_Tester' => 'WPDO_User_Stress_Tester',
|
'TMDO_User_Stress_Tester' => 'WPDO_User_Stress_Tester',
|
||||||
'TMDO_Term_Stress_Tester' => 'WPDO_Term_Stress_Tester',
|
'TMDO_Term_Stress_Tester' => 'WPDO_Term_Stress_Tester',
|
||||||
'TMDO_Comment_Stress_Tester' => 'WPDO_Comment_Stress_Tester',
|
'TMDO_Comment_Stress_Tester' => 'WPDO_Comment_Stress_Tester',
|
||||||
'TMDO_Post_Shadow_Verifier' => 'WPDO_Post_Shadow_Verifier',
|
'TMDO_Post_Shadow_Verifier' => 'WPDO_Post_Shadow_Verifier',
|
||||||
'TMDO_Term_Comment_Shadow_Verifier' => 'WPDO_Term_Comment_Shadow_Verifier',
|
'TMDO_Term_Comment_Shadow_Verifier' => 'WPDO_Term_Comment_Shadow_Verifier',
|
||||||
'TMDO_Term_Comment_Backfill' => 'WPDO_Term_Comment_Backfill',
|
'TMDO_Term_Comment_Backfill' => 'WPDO_Term_Comment_Backfill',
|
||||||
'TMDO_Term_Comment_Misc_Bucket' => 'WPDO_Term_Comment_Misc_Bucket',
|
'TMDO_Term_Comment_Misc_Bucket' => 'WPDO_Term_Comment_Misc_Bucket',
|
||||||
'TMDO_Term_Comment_Garbage_Filter' => 'WPDO_Term_Comment_Garbage_Filter',
|
'TMDO_Term_Comment_Garbage_Filter' => 'WPDO_Term_Comment_Garbage_Filter',
|
||||||
'TMDO_Setup_Wizard' => 'WPDO_Setup_Wizard',
|
'TMDO_Setup_Wizard' => 'WPDO_Setup_Wizard',
|
||||||
'TMDO_Dashboard_Widget' => 'WPDO_Dashboard_Widget',
|
'TMDO_Dashboard_Widget' => 'WPDO_Dashboard_Widget',
|
||||||
'TMDO_Help_Tabs' => 'WPDO_Help_Tabs',
|
'TMDO_Help_Tabs' => 'WPDO_Help_Tabs',
|
||||||
'TMDO_Admin' => 'WPDO_Admin',
|
'TMDO_Admin' => 'WPDO_Admin',
|
||||||
'TMDO_V2_Upgrader' => 'WPDO_V2_Upgrader',
|
'TMDO_V2_Upgrader' => 'WPDO_V2_Upgrader',
|
||||||
'TMDO_SQLite_Compat' => 'WPDO_SQLite_Compat',
|
'TMDO_SQLite_Compat' => 'WPDO_SQLite_Compat',
|
||||||
'TMDO_Type_Caster' => 'WPDO_Type_Caster',
|
'TMDO_Type_Caster' => 'WPDO_Type_Caster',
|
||||||
'TMDO_Audit_Logger' => 'WPDO_Audit_Logger',
|
'TMDO_Audit_Logger' => 'WPDO_Audit_Logger',
|
||||||
'TMDO_Shadow_Diff_Logger' => 'WPDO_Shadow_Diff_Logger',
|
'TMDO_Shadow_Diff_Logger' => 'WPDO_Shadow_Diff_Logger',
|
||||||
'TMDO_Conflict_Detector' => 'WPDO_Conflict_Detector',
|
'TMDO_Conflict_Detector' => 'WPDO_Conflict_Detector',
|
||||||
'TMDO_Cache_Orchestrator' => 'WPDO_Cache_Orchestrator',
|
'TMDO_Cache_Orchestrator' => 'WPDO_Cache_Orchestrator',
|
||||||
'TMDO_Query_Compiler' => 'WPDO_Query_Compiler',
|
'TMDO_Query_Compiler' => 'WPDO_Query_Compiler',
|
||||||
'TMDO_Auto_Promoter' => 'WPDO_Auto_Promoter',
|
'TMDO_Auto_Promoter' => 'WPDO_Auto_Promoter',
|
||||||
'TMDO_Adapter_Post' => 'WPDO_Adapter_Post',
|
'TMDO_Adapter_Post' => 'WPDO_Adapter_Post',
|
||||||
'TMDO_Adapter_User' => 'WPDO_Adapter_User',
|
'TMDO_Adapter_User' => 'WPDO_Adapter_User',
|
||||||
'TMDO_Adapter_Term' => 'WPDO_Adapter_Term',
|
'TMDO_Adapter_Term' => 'WPDO_Adapter_Term',
|
||||||
'TMDO_Adapter_Comment' => 'WPDO_Adapter_Comment',
|
'TMDO_Adapter_Comment' => 'WPDO_Adapter_Comment',
|
||||||
'TMDO_Options_Manager' => 'WPDO_Options_Manager',
|
'TMDO_Options_Manager' => 'WPDO_Options_Manager',
|
||||||
'TMDO_Hot_Migration' => 'WPDO_Hot_Migration',
|
'TMDO_Hot_Migration' => 'WPDO_Hot_Migration',
|
||||||
'TMDO_Warm_Migration' => 'WPDO_Warm_Migration',
|
'TMDO_Warm_Migration' => 'WPDO_Warm_Migration',
|
||||||
'TMDO_Cold_Migration' => 'WPDO_Cold_Migration',
|
'TMDO_Cold_Migration' => 'WPDO_Cold_Migration',
|
||||||
'TMDO_Archive_Migration' => 'WPDO_Archive_Migration',
|
'TMDO_Archive_Migration' => 'WPDO_Archive_Migration',
|
||||||
'TMDO_Migration_Base' => 'WPDO_Migration_Base',
|
'TMDO_Migration_Base' => 'WPDO_Migration_Base',
|
||||||
'TMDO_Post_Migration' => 'WPDO_Post_Migration',
|
'TMDO_Post_Migration' => 'WPDO_Post_Migration',
|
||||||
'TMDO_Zone_Hot' => 'WPDO_Zone_Hot',
|
'TMDO_Zone_Hot' => 'WPDO_Zone_Hot',
|
||||||
'TMDO_Zone_Warm' => 'WPDO_Zone_Warm',
|
'TMDO_Zone_Warm' => 'WPDO_Zone_Warm',
|
||||||
'TMDO_Zone_Cold' => 'WPDO_Zone_Cold',
|
'TMDO_Zone_Cold' => 'WPDO_Zone_Cold',
|
||||||
'TMDO_Zone_Router' => 'WPDO_Zone_Router',
|
'TMDO_Zone_Router' => 'WPDO_Zone_Router',
|
||||||
'TMDO_Routing_Predicate' => 'WPDO_Routing_Predicate',
|
'TMDO_Routing_Predicate' => 'WPDO_Routing_Predicate',
|
||||||
'TMDO_Migration_Phase_Base' => 'WPDO_Migration_Phase_Base',
|
'TMDO_Migration_Phase_Base' => 'WPDO_Migration_Phase_Base',
|
||||||
'TMDO_Phase_Diagnose' => 'WPDO_Phase_Diagnose',
|
'TMDO_Phase_Diagnose' => 'WPDO_Phase_Diagnose',
|
||||||
'TMDO_Phase_Backup' => 'WPDO_Phase_Backup',
|
'TMDO_Phase_Backup' => 'WPDO_Phase_Backup',
|
||||||
'TMDO_Phase_Demote' => 'WPDO_Phase_Demote',
|
'TMDO_Phase_Demote' => 'WPDO_Phase_Demote',
|
||||||
'TMDO_Phase_Install_Schema' => 'WPDO_Phase_Install_Schema',
|
'TMDO_Phase_Install_Schema' => 'WPDO_Phase_Install_Schema',
|
||||||
'TMDO_Phase_Backfill_Bulk' => 'WPDO_Phase_Backfill_Bulk',
|
'TMDO_Phase_Backfill_Bulk' => 'WPDO_Phase_Backfill_Bulk',
|
||||||
'TMDO_Phase_Backfill_Unserialize' => 'WPDO_Phase_Backfill_Unserialize',
|
'TMDO_Phase_Backfill_Unserialize' => 'WPDO_Phase_Backfill_Unserialize',
|
||||||
'TMDO_Phase_Promote_Shadow' => 'WPDO_Phase_Promote_Shadow',
|
'TMDO_Phase_Promote_Shadow' => 'WPDO_Phase_Promote_Shadow',
|
||||||
'TMDO_Phase_Verify_Sample' => 'WPDO_Phase_Verify_Sample',
|
'TMDO_Phase_Verify_Sample' => 'WPDO_Phase_Verify_Sample',
|
||||||
'TMDO_Phase_Promote_Aeav' => 'WPDO_Phase_Promote_Aeav',
|
'TMDO_Phase_Promote_Aeav' => 'WPDO_Phase_Promote_Aeav',
|
||||||
'TMDO_Phase_Cleanup' => 'WPDO_Phase_Cleanup',
|
'TMDO_Phase_Cleanup' => 'WPDO_Phase_Cleanup',
|
||||||
'TMDO_Phase_Completed' => 'WPDO_Phase_Completed',
|
'TMDO_Phase_Completed' => 'WPDO_Phase_Completed',
|
||||||
'TMDO_Zone_Archive' => 'WPDO_Zone_Archive',
|
'TMDO_Zone_Archive' => 'WPDO_Zone_Archive',
|
||||||
'TMDO_Interceptor_Base' => 'WPDO_Interceptor_Base',
|
'TMDO_Interceptor_Base' => 'WPDO_Interceptor_Base',
|
||||||
'TMDO_Standard_Post_Interceptor' => 'WPDO_Standard_Post_Interceptor',
|
'TMDO_Standard_Post_Interceptor' => 'WPDO_Standard_Post_Interceptor',
|
||||||
'TMDO_Query_Interceptor_Base' => 'WPDO_Query_Interceptor_Base',
|
'TMDO_Query_Interceptor_Base' => 'WPDO_Query_Interceptor_Base',
|
||||||
'TMDO_Abstract_Notifier' => 'WPDO_Abstract_Notifier',
|
'TMDO_Abstract_Notifier' => 'WPDO_Abstract_Notifier',
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ( $tmdo_class_aliases as $tmdo_class => $wpdo_alias ) {
|
foreach ( $tmdo_class_aliases as $tmdo_class => $wpdo_alias ) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,7 @@ class TMDO_Cache_Layer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
||||||
if ( empty( $cold_keys ) ) {
|
if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ class TMDO_Cache_Layer {
|
|||||||
// Check which IDs are already cached.
|
// Check which IDs are already cached.
|
||||||
$uncached = array();
|
$uncached = array();
|
||||||
foreach ( $post_ids as $pid ) {
|
foreach ( $post_ids as $pid ) {
|
||||||
$cached = wp_cache_get( "cold_{$pid}", $group );
|
$cached = wp_cache_get( TMDO_Zone_Cold::cache_key( (int) $pid, $post_type ), $group );
|
||||||
if ( false === $cached ) {
|
if ( false === $cached ) {
|
||||||
$uncached[] = (int) $pid;
|
$uncached[] = (int) $pid;
|
||||||
}
|
}
|
||||||
@@ -92,7 +94,7 @@ class TMDO_Cache_Layer {
|
|||||||
// Cache all results (including empty arrays for posts with no cold data).
|
// Cache all results (including empty arrays for posts with no cold data).
|
||||||
foreach ( $uncached as $pid ) {
|
foreach ( $uncached as $pid ) {
|
||||||
$data = $fetched[ $pid ] ?? array();
|
$data = $fetched[ $pid ] ?? array();
|
||||||
wp_cache_set( "cold_{$pid}", $data, $group, $ttl );
|
wp_cache_set( TMDO_Zone_Cold::cache_key( $pid, $post_type ), $data, $group, $ttl );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,33 +108,46 @@ class TMDO_Cache_Layer {
|
|||||||
*/
|
*/
|
||||||
public static function warm_post( int $post_id, string $post_type ): void {
|
public static function warm_post( int $post_id, string $post_type ): void {
|
||||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
||||||
if ( empty( $cold_keys ) ) {
|
if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force a fresh read from DB (bypasses cache).
|
// Force a fresh read from DB (bypasses cache).
|
||||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
||||||
wp_cache_delete( "cold_{$post_id}", $group );
|
wp_cache_delete( TMDO_Zone_Cold::cache_key( $post_id, $post_type ), $group );
|
||||||
|
|
||||||
// Re-read will populate cache.
|
// Re-read will populate cache.
|
||||||
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
|
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the cold zone holds data for this post type: its module writes
|
||||||
|
* to the cold table and the table exists. Registered cold fields alone do
|
||||||
|
* not mean either (zero side effect while the module is idle).
|
||||||
|
*
|
||||||
|
* @param string $post_type Post type.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function cold_active( string $post_type ): bool {
|
||||||
|
return TMDO_Feature_Flags::is_write_active( TMDO_Zone_Router::module_name( 'cold', $post_type ) )
|
||||||
|
&& TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush all cold cache for a post type.
|
* Flush all cold cache for a post type.
|
||||||
*
|
*
|
||||||
* Useful after bulk imports or schema changes.
|
* Useful after bulk imports or schema changes. Bumps the post type's cache
|
||||||
|
* version (new keys from now on) instead of wp_cache_flush_group(), which
|
||||||
|
* Redis Object Cache runs as one server-side scan that blocks Redis on a
|
||||||
|
* large group; older entries expire by their TTL.
|
||||||
*
|
*
|
||||||
* @param string $post_type Post type to flush.
|
* @param string $post_type Post type to flush.
|
||||||
*/
|
*/
|
||||||
public static function flush_group( string $post_type ): void {
|
public static function flush_group( string $post_type ): void {
|
||||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
$versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() );
|
||||||
|
$versions = is_array( $versions ) ? $versions : array();
|
||||||
if ( function_exists( 'wp_cache_flush_group' ) ) {
|
$versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1;
|
||||||
wp_cache_flush_group( $group );
|
update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true );
|
||||||
}
|
|
||||||
// If wp_cache_flush_group is not available (older WP or no object cache),
|
|
||||||
// individual entries will expire via TTL.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +183,7 @@ class TMDO_Cache_Layer {
|
|||||||
return array(
|
return array(
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
'prefetch_support' => true,
|
'prefetch_support' => true,
|
||||||
'flush_support' => function_exists( 'wp_cache_flush_group' ),
|
'flush_support' => true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
* @since 2.14.0
|
* @since 2.14.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
|
||||||
/**
|
/**
|
||||||
* TMDO_Comment_Stress_Tester — Async stress fixture generator for comment entity (v2.13.1).
|
* TMDO_Comment_Stress_Tester — Async stress fixture generator for comment entity (v2.13.1).
|
||||||
*
|
*
|
||||||
@@ -21,6 +20,10 @@
|
|||||||
* @since 2.13.1
|
* @since 2.13.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
* @since 2.12.0
|
* @since 2.12.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -35,57 +37,23 @@ class TMDO_Compatibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self::$detected = array(
|
self::$detected = array(
|
||||||
'hpct_active' => self::is_hpct_active(),
|
'hivepress' => self::is_hivepress_active(),
|
||||||
'hpct_imported' => self::is_hpct_imported(),
|
'woocommerce' => self::is_woocommerce_active(),
|
||||||
'hivepress' => self::is_hivepress_active(),
|
|
||||||
'woocommerce' => self::is_woocommerce_active(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return self::$detected;
|
return self::$detected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Can WPDO register its HPCT-inherited module interceptors?
|
|
||||||
*
|
|
||||||
* True when:
|
|
||||||
* - hp-custom-tables is NOT active, OR
|
|
||||||
* - hp-custom-tables IS active but already imported into WPDO.
|
|
||||||
*/
|
|
||||||
public static function can_register_hpct_hooks(): bool {
|
|
||||||
$compat = self::check();
|
|
||||||
|
|
||||||
// HPCT not active → WPDO can freely register.
|
|
||||||
if ( ! $compat['hpct_active'] ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HPCT is active but already imported → WPDO takes over.
|
|
||||||
if ( $compat['hpct_imported'] ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HPCT is active and NOT imported → defer to HPCT.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
|
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
|
||||||
*
|
*
|
||||||
* Zone interceptors are always safe to register — they operate on
|
* Zone interceptors are always safe to register — they operate on
|
||||||
* WPDO's own tables and do not conflict with HPCT.
|
* WPDO's own tables.
|
||||||
*/
|
*/
|
||||||
public static function can_register_zone_hooks(): bool {
|
public static function can_register_zone_hooks(): bool {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Should WPDO show an admin notice about HPCT import?
|
|
||||||
*/
|
|
||||||
public static function should_show_hpct_notice(): bool {
|
|
||||||
$compat = self::check();
|
|
||||||
return $compat['hpct_active'] && ! $compat['hpct_imported'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is HivePress core active?
|
* Is HivePress core active?
|
||||||
*/
|
*/
|
||||||
@@ -100,6 +68,26 @@ class TMDO_Compatibility {
|
|||||||
return class_exists( 'WooCommerce' );
|
return class_exists( 'WooCommerce' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integrations that are active while their AddOn is not.
|
||||||
|
*
|
||||||
|
* Core does not know these plugins; their fields are only optimised by the
|
||||||
|
* matching AddOn. The AddOn is detected by its bootstrap class, which its
|
||||||
|
* main file declares as soon as the plugin loads.
|
||||||
|
*
|
||||||
|
* @return array<string, string> Plugin name → AddOn plugin slug.
|
||||||
|
*/
|
||||||
|
public static function missing_integration_addons(): array {
|
||||||
|
$missing = array();
|
||||||
|
if ( self::is_hivepress_active() && ! class_exists( 'TMDO_HP_Bootstrap' ) ) {
|
||||||
|
$missing['HivePress'] = '2meet-data-optimizer-hivepress-addon';
|
||||||
|
}
|
||||||
|
if ( self::is_woocommerce_active() && ! class_exists( 'TMDO_Woocommerce_Bootstrap' ) ) {
|
||||||
|
$missing['WooCommerce'] = '2meet-data-optimizer-woocommerce-addon';
|
||||||
|
}
|
||||||
|
return $missing;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
|
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
|
||||||
*
|
*
|
||||||
@@ -111,23 +99,4 @@ class TMDO_Compatibility {
|
|||||||
|| defined( 'LATEPOINT_VERSION' )
|
|| defined( 'LATEPOINT_VERSION' )
|
||||||
|| function_exists( 'latepoint_get_settings' );
|
|| function_exists( 'latepoint_get_settings' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Detection Methods ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is HP Custom Tables plugin active (loaded)?
|
|
||||||
*
|
|
||||||
* Checks for the HPCT_Loader class, which is always loaded
|
|
||||||
* when hp-custom-tables is active.
|
|
||||||
*/
|
|
||||||
public static function is_hpct_active(): bool {
|
|
||||||
return class_exists( 'HPCT_Loader' ) || defined( 'HPCT_VERSION' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Has HPCT been imported into WPDO?
|
|
||||||
*/
|
|
||||||
private static function is_hpct_imported(): bool {
|
|
||||||
return (bool) get_option( 'wpdo_hpct_imported', false );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-287
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -29,31 +31,10 @@ class TMDO_Core {
|
|||||||
// Fire the schema registry hook so integrations can register their fields.
|
// Fire the schema registry hook so integrations can register their fields.
|
||||||
$registry = TMDO_Schema_Registry::instance();
|
$registry = TMDO_Schema_Registry::instance();
|
||||||
|
|
||||||
// v2.0.x: Pre-register the 7 partner plugin integrations BEFORE firing the
|
// Core-owned field providers register before `wpdo_register_fields` fires.
|
||||||
// `wpdo_register_fields` and `wpdo_register_custom_tables` hooks. Each
|
// AddOns wire their own integrations from their bootstraps
|
||||||
// `register()` is a thin `add_action()` wrapper guarded by a partner-plugin
|
// (plugins_loaded:3 or :6), so core does not probe for them here.
|
||||||
// `class_exists()` probe (TMDO_Infocards / Bookings / Quotation / Events /
|
foreach ( array( 'TMDO_Member_Fields', 'TMDO_Post_Fields' ) as $cls ) {
|
||||||
// Mobile_Bridge / Collab / Playlist). Without this, partner add_actions
|
|
||||||
// arrive too late and the do_action below misses them — observed via
|
|
||||||
// 2meet-infocards 9 hp_vendor meta keys never landing in Schema Registry.
|
|
||||||
// register() is side-effect-free aside from add_action(), so pre-registering
|
|
||||||
// is provably safe (no hidden dependency on later-state global init).
|
|
||||||
// v2.1.3 R1 (B step): TMDO_Events removed — 2meet-events now self-registers
|
|
||||||
// via its own bridge class. Other partners still pre-registered here pending
|
|
||||||
// their own decentralized migrations (R1 continuation in v2.2.x).
|
|
||||||
$partner_integrations = array(
|
|
||||||
'TMDO_Infocards',
|
|
||||||
'TMDO_Bookings',
|
|
||||||
'TMDO_Quotation',
|
|
||||||
'TMDO_Mobile_Bridge',
|
|
||||||
'TMDO_Collab',
|
|
||||||
'TMDO_Playlist',
|
|
||||||
'TMDO_WooCommerce',
|
|
||||||
'TMDO_Member_Fields',
|
|
||||||
'TMDO_Post_Fields',
|
|
||||||
'TMDO_Hivepress_Term_Comment_Fields',
|
|
||||||
);
|
|
||||||
foreach ( $partner_integrations as $cls ) {
|
|
||||||
if ( class_exists( $cls ) ) {
|
if ( class_exists( $cls ) ) {
|
||||||
call_user_func( array( $cls, 'register' ) );
|
call_user_func( array( $cls, 'register' ) );
|
||||||
}
|
}
|
||||||
@@ -72,7 +53,6 @@ class TMDO_Core {
|
|||||||
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
|
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
|
||||||
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
|
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
|
||||||
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
|
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
|
||||||
// This deprecates the need for the TMDO_Core hardcoded partner pre-registration list above.
|
|
||||||
//
|
//
|
||||||
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
|
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
|
||||||
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
|
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
|
||||||
@@ -91,6 +71,12 @@ class TMDO_Core {
|
|||||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||||
TMDO_Custom_Table_Registry::instance()->fire_registration();
|
TMDO_Custom_Table_Registry::instance()->fire_registration();
|
||||||
}
|
}
|
||||||
|
// Every provider has registered by now: re-register groups whose flat
|
||||||
|
// table still holds data but whose plugin is gone, so aeav_only
|
||||||
|
// values keep reading back (routing only — never DDL).
|
||||||
|
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
|
TMDO_Schema_Manager::restore_orphaned_groups();
|
||||||
|
}
|
||||||
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
|
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
$pending = TMDO_Entity_Registry::get_pending_schemas();
|
$pending = TMDO_Entity_Registry::get_pending_schemas();
|
||||||
if ( ! empty( $pending ) ) {
|
if ( ! empty( $pending ) ) {
|
||||||
@@ -109,17 +95,6 @@ class TMDO_Core {
|
|||||||
// Cache Layer hooks (Zone C prefetch + save_post warming).
|
// Cache Layer hooks (Zone C prefetch + save_post warming).
|
||||||
TMDO_Cache_Layer::register_hooks();
|
TMDO_Cache_Layer::register_hooks();
|
||||||
|
|
||||||
// Register HPCT-inherited module interceptors if allowed.
|
|
||||||
if ( TMDO_Compatibility::can_register_hpct_hooks() ) {
|
|
||||||
$this->register_hpct_interceptors();
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.1.2 fix: WC commission interceptor is independent of HPCT — writes
|
|
||||||
// to its own wp_wpdo_wc_commissions table. Register whenever WC is active.
|
|
||||||
if ( class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_active() && class_exists( 'TMDO_WC_Orders_Interceptor' ) ) {
|
|
||||||
( new TMDO_WC_Orders_Interceptor() )->register_hooks();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zone interceptors are always safe to register.
|
// Zone interceptors are always safe to register.
|
||||||
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
|
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
|
||||||
$this->register_zone_hooks();
|
$this->register_zone_hooks();
|
||||||
@@ -134,6 +109,9 @@ class TMDO_Core {
|
|||||||
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
||||||
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
||||||
|
|
||||||
|
// Keep post_status / post_date copies on post entity tables current.
|
||||||
|
TMDO_Post_Status_Sync::register();
|
||||||
|
|
||||||
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
||||||
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
||||||
TMDO_Audit_Logger::init();
|
TMDO_Audit_Logger::init();
|
||||||
@@ -147,18 +125,9 @@ class TMDO_Core {
|
|||||||
TMDO_Conflict_Monitor::register_hooks();
|
TMDO_Conflict_Monitor::register_hooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.0.x: partner plugin integrations have been pre-registered earlier
|
|
||||||
// (see top of run() before the wpdo_register_fields do_action). Removing
|
|
||||||
// the duplicate late-binding block — see commit log for time-ordering bug.
|
|
||||||
|
|
||||||
// Schedule cron events.
|
// Schedule cron events.
|
||||||
$this->schedule_cron();
|
$this->schedule_cron();
|
||||||
|
|
||||||
// Admin notice for HPCT import.
|
|
||||||
if ( is_admin() && TMDO_Compatibility::should_show_hpct_notice() ) {
|
|
||||||
add_action( 'admin_notices', array( $this, 'render_hpct_notice' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.6.6: async entity backfill cron handler.
|
// v2.6.6: async entity backfill cron handler.
|
||||||
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
|
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
|
||||||
|
|
||||||
@@ -180,14 +149,6 @@ class TMDO_Core {
|
|||||||
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
|
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.11.5: HivePress per-post transient cache → wp_options reroute.
|
|
||||||
// Filters registered on `init` so HivePress's own bootstrap (plugins_loaded:5)
|
|
||||||
// runs first and our filter chain catches the actual cache writes happening
|
|
||||||
// during save_post and term-cache rebuilds.
|
|
||||||
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
|
||||||
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.12.1: Term + Comment garbage write-time filter
|
// v2.12.1: Term + Comment garbage write-time filter
|
||||||
// (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys).
|
// (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys).
|
||||||
if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) {
|
if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) {
|
||||||
@@ -280,218 +241,6 @@ class TMDO_Core {
|
|||||||
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
|
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register default HivePress field mappings.
|
|
||||||
*
|
|
||||||
* These are the known HivePress meta_keys and their zone classifications.
|
|
||||||
*
|
|
||||||
* @param TMDO_Schema_Registry $registry The schema registry instance.
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function register_hivepress_defaults( TMDO_Schema_Registry $registry ): void {
|
|
||||||
// Zone A (Hot) — Listing search/filter fields.
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_price',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_price',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_featured',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_featured',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_verified',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_verified',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_expired_time',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_expired_time',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_featured_time',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_featured_time',
|
|
||||||
),
|
|
||||||
// Vendor hot fields.
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_verified',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_verified',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_hourly_rate',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_hourly_rate',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_rating_count',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'int(11) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_rating_count',
|
|
||||||
'indexed' => false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zone C (Cold) — Profile/display fields.
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_description',
|
|
||||||
'zone' => 'cold',
|
|
||||||
'cache_group' => 'wpdo_cold_hp_vendor',
|
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_description',
|
|
||||||
'zone' => 'cold',
|
|
||||||
'cache_group' => 'wpdo_cold_hp_listing',
|
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── HivePress extension fields (filled the 47% → 94% coverage gap) ──
|
|
||||||
// Reviews extension (hp_review post type).
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress-reviews',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_review',
|
|
||||||
'meta_key' => 'hp_rating',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_rating',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_rating',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(3,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_rating',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_review',
|
|
||||||
'meta_key' => 'hp_text',
|
|
||||||
'zone' => 'cold',
|
|
||||||
'cache_group' => 'wpdo_cold_hp_review',
|
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bookings extension (hp_booking post type).
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress-bookings',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_booking',
|
|
||||||
'meta_key' => 'hp_start_time',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_start_time',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_booking',
|
|
||||||
'meta_key' => 'hp_end_time',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_end_time',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_booking',
|
|
||||||
'meta_key' => 'hp_status',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => "varchar(20) NOT NULL DEFAULT ''",
|
|
||||||
'column' => 'hp_status',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_booking_enabled',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_booking_enabled',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Marketplace / Requests extension.
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress-marketplace',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_request',
|
|
||||||
'meta_key' => 'hp_request_category',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_request_category',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_offer',
|
|
||||||
'meta_key' => 'hp_amount',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_amount',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_offer',
|
|
||||||
'meta_key' => 'hp_request',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_request',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register HPCT-inherited module interceptors and query interceptors.
|
|
||||||
*/
|
|
||||||
private function register_hpct_interceptors(): void {
|
|
||||||
// 7 HPCT-era meta interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
|
||||||
// 1 LatePoint interceptor 已搬到 2meet-data-optimizer-latepoint-addon。
|
|
||||||
// 5 query interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
|
||||||
// 此方法保留為空殼以保持 ABI 相容;AddOn 各自於自身 bootstrap 內 register_hooks。
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register zone-specific hooks.
|
* Register zone-specific hooks.
|
||||||
*/
|
*/
|
||||||
@@ -512,6 +261,9 @@ class TMDO_Core {
|
|||||||
$post_router = new TMDO_Post_Query_Router();
|
$post_router = new TMDO_Post_Query_Router();
|
||||||
$post_router->register_hooks();
|
$post_router->register_hooks();
|
||||||
}
|
}
|
||||||
|
if ( class_exists( 'TMDO_Post_Query_Router' ) ) {
|
||||||
|
add_action( 'clean_post_cache', array( 'TMDO_Post_Query_Router', 'bump_count_version' ) );
|
||||||
|
}
|
||||||
|
|
||||||
// Zone B: Warm cleanup cron handler.
|
// Zone B: Warm cleanup cron handler.
|
||||||
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
|
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
|
||||||
@@ -547,6 +299,19 @@ class TMDO_Core {
|
|||||||
// v2.3.0 M6: daily health probe cron.
|
// v2.3.0 M6: daily health probe cron.
|
||||||
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
|
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
|
||||||
|
|
||||||
|
// 1.2.0: daily flat-vs-native drift sample (read by the post query router).
|
||||||
|
add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );
|
||||||
|
|
||||||
|
// 1.2.0: rebuild flat from native after writes bypassed it (entity was
|
||||||
|
// disabled, or the Hook Bus was off).
|
||||||
|
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );
|
||||||
|
add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );
|
||||||
|
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
|
||||||
|
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
|
||||||
|
|
||||||
|
// 1.3.0: declared indexes a visitor request left for WP-Cron.
|
||||||
|
add_action( TMDO_Schema_Manager::INDEXES_HOOK, array( 'TMDO_Schema_Manager', 'ensure_pending_indexes' ) );
|
||||||
|
|
||||||
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
|
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
|
||||||
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
||||||
|
|
||||||
@@ -789,27 +554,6 @@ class TMDO_Core {
|
|||||||
TMDO_Zone_Archive::sweep();
|
TMDO_Zone_Archive::sweep();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render admin notice when hp-custom-tables is detected but not imported.
|
|
||||||
*/
|
|
||||||
public function render_hpct_notice(): void {
|
|
||||||
$import_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=hpct-import' );
|
|
||||||
?>
|
|
||||||
<div class="notice notice-warning is-dismissible">
|
|
||||||
<p>
|
|
||||||
<strong>WP Data Optimizer:</strong>
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: %s: URL to import page */
|
|
||||||
esc_html__( 'HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。', '2meet-data-optimizer' ),
|
|
||||||
'<a href="' . esc_url( $import_url ) . '">' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '</a>'
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WP Cron handler: process one backfill batch for an entity/group pair.
|
* WP Cron handler: process one backfill batch for an entity/group pair.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
* @since 2.15.0 (v2 GCM, AEAD authentication)
|
* @since 2.15.0 (v2 GCM, AEAD authentication)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -25,17 +27,9 @@ class TMDO_Feature_Flags {
|
|||||||
private const OPTION_KEY = 'wpdo_features';
|
private const OPTION_KEY = 'wpdo_features';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All known HPCT-inherited modules.
|
* Modules driven by AddOn interceptors (WooCommerce commissions, LatePoint).
|
||||||
* These modules operate on existing hpct_* tables.
|
|
||||||
*/
|
*/
|
||||||
public const HPCT_MODULES = array(
|
public const ADDON_MODULES = array(
|
||||||
'reviews',
|
|
||||||
'messages',
|
|
||||||
'favorites',
|
|
||||||
'memberships',
|
|
||||||
'statistics',
|
|
||||||
'requests',
|
|
||||||
'listing_meta',
|
|
||||||
'wc_orders',
|
'wc_orders',
|
||||||
'latepoint',
|
'latepoint',
|
||||||
);
|
);
|
||||||
@@ -298,7 +292,7 @@ class TMDO_Feature_Flags {
|
|||||||
$saved = array();
|
$saved = array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$all_modules = array_merge( self::HPCT_MODULES, self::ZONE_MODULES );
|
$all_modules = array_merge( self::ADDON_MODULES, self::ZONE_MODULES );
|
||||||
$defaults = array_fill_keys( $all_modules, 'idle' );
|
$defaults = array_fill_keys( $all_modules, 'idle' );
|
||||||
|
|
||||||
self::$cache = array_merge( $defaults, $saved );
|
self::$cache = array_merge( $defaults, $saved );
|
||||||
@@ -306,13 +300,13 @@ class TMDO_Feature_Flags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return only HPCT-inherited module states.
|
* Return only AddOn module states.
|
||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public static function hpct_modules(): array {
|
public static function addon_modules(): array {
|
||||||
$all = self::all();
|
$all = self::all();
|
||||||
return array_intersect_key( $all, array_flip( self::HPCT_MODULES ) );
|
return array_intersect_key( $all, array_flip( self::ADDON_MODULES ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -324,23 +318,4 @@ class TMDO_Feature_Flags {
|
|||||||
$all = self::all();
|
$all = self::all();
|
||||||
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
|
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Map an HPCT feature flag status to a WPDO state.
|
|
||||||
*
|
|
||||||
* Used during HPCT import to translate HPCT's 4-state model
|
|
||||||
* to WPDO's 7-state model.
|
|
||||||
*
|
|
||||||
* @param string $hpct_status HPCT status (disabled/migrating/verified/enabled).
|
|
||||||
* @return string WPDO state.
|
|
||||||
*/
|
|
||||||
public static function map_hpct_status( string $hpct_status ): string {
|
|
||||||
return match ( $hpct_status ) {
|
|
||||||
'disabled' => 'idle',
|
|
||||||
'migrating' => 'backfill',
|
|
||||||
'verified' => 'cutover',
|
|
||||||
'enabled' => 'complete',
|
|
||||||
default => 'idle',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,7 @@ final class TMDO_Hook_Bus_Bridge {
|
|||||||
if ( null !== self::$cache ) {
|
if ( null !== self::$cache ) {
|
||||||
return self::$cache;
|
return self::$cache;
|
||||||
}
|
}
|
||||||
$value = get_option( self::OPTION, '1' ); // Default ON as of v2.5.4.
|
$value = get_option( self::OPTION, '0' );
|
||||||
self::$cache = ( '1' === (string) $value || true === $value );
|
self::$cache = ( '1' === (string) $value || true === $value );
|
||||||
return self::$cache;
|
return self::$cache;
|
||||||
}
|
}
|
||||||
@@ -96,13 +98,6 @@ final class TMDO_Hook_Bus_Bridge {
|
|||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
private const COEXIST_WHITELIST = array(
|
private const COEXIST_WHITELIST = array(
|
||||||
'TMDO_Reviews_Interceptor',
|
|
||||||
'TMDO_Messages_Interceptor',
|
|
||||||
'TMDO_Favorites_Interceptor',
|
|
||||||
'TMDO_Memberships_Interceptor',
|
|
||||||
'TMDO_Statistics_Interceptor',
|
|
||||||
'TMDO_Requests_Interceptor',
|
|
||||||
'TMDO_Listing_Meta_Interceptor',
|
|
||||||
'TMDO_WC_Orders_Interceptor',
|
'TMDO_WC_Orders_Interceptor',
|
||||||
'TMDO_LatePoint_Interceptor',
|
'TMDO_LatePoint_Interceptor',
|
||||||
'TMDO_Sync_Bridge',
|
'TMDO_Sync_Bridge',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,9 @@ class TMDO_Installer {
|
|||||||
*/
|
*/
|
||||||
private const SCHEMA_VERSION = '2.1.0';
|
private const SCHEMA_VERSION = '2.1.0';
|
||||||
|
|
||||||
|
/** Set once maybe_preserve_misc_bucket_default() has run for this site. */
|
||||||
|
public const MISC_BUCKET_DEFAULT_FLAG = 'wpdo_misc_bucket_default_v2';
|
||||||
|
|
||||||
/** Allowed SQL base types for hot-zone column definitions. */
|
/** Allowed SQL base types for hot-zone column definitions. */
|
||||||
private const ALLOWED_COL_BASE_TYPES = array(
|
private const ALLOWED_COL_BASE_TYPES = array(
|
||||||
'bigint',
|
'bigint',
|
||||||
@@ -219,47 +224,12 @@ class TMDO_Installer {
|
|||||||
add_option( 'wpdo_features', array(), '', 'no' );
|
add_option( 'wpdo_features', array(), '', 'no' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.5.4: one-time migration — enable Hook Bus + activate user/term/comment bridge.
|
|
||||||
self::maybe_autoactivate_entity_bridge();
|
|
||||||
|
|
||||||
// v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as
|
// v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as
|
||||||
// v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present
|
// v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present
|
||||||
// or if the migration flag is already set.
|
// or if the migration flag is already set.
|
||||||
self::maybe_migrate_crypto_v1_to_v2();
|
self::maybe_migrate_crypto_v1_to_v2();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One-time migration: enable Hook Bus and set user/term/comment to dual_write.
|
|
||||||
*
|
|
||||||
* Runs once per site (guarded by wpdo_entity_bridge_autoactivated_v2 flag).
|
|
||||||
* Safe: dual_write still writes native EAV — zero data-loss risk.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function maybe_autoactivate_entity_bridge(): void {
|
|
||||||
if ( get_option( 'wpdo_entity_bridge_autoactivated_v2' ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable Hook Bus.
|
|
||||||
update_option( 'wpdo_hook_bus_enabled', '1', false );
|
|
||||||
|
|
||||||
// Upgrade user/term/comment from disabled → dual_write.
|
|
||||||
$modes = get_option( 'wpdo_bridge_modes', array() );
|
|
||||||
if ( ! is_array( $modes ) ) {
|
|
||||||
$modes = array();
|
|
||||||
}
|
|
||||||
foreach ( array( 'user', 'term', 'comment' ) as $type ) {
|
|
||||||
if ( ! isset( $modes[ $type ] ) || 'disabled' === $modes[ $type ] ) {
|
|
||||||
$modes[ $type ] = 'dual_write';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// post entity is intentionally left alone — it uses the legacy Feature_Flags FSM.
|
|
||||||
update_option( 'wpdo_bridge_modes', $modes, false );
|
|
||||||
|
|
||||||
update_option( 'wpdo_entity_bridge_autoactivated_v2', '1', false );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0).
|
* One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0).
|
||||||
*
|
*
|
||||||
@@ -297,6 +267,128 @@ class TMDO_Installer {
|
|||||||
if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
|
if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
|
||||||
self::install();
|
self::install();
|
||||||
}
|
}
|
||||||
|
self::maybe_preserve_misc_bucket_default();
|
||||||
|
self::maybe_init_post_scoped_routing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time default for post_type-scoped entity routing.
|
||||||
|
*
|
||||||
|
* Autoloaded because TMDO_Entity_Registry::get_field() reads it per lookup.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function maybe_init_post_scoped_routing(): void {
|
||||||
|
global $wpdb;
|
||||||
|
if ( false !== get_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, false ) || ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$has_rows = false;
|
||||||
|
$tables = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%' ) );
|
||||||
|
foreach ( $tables as $table ) {
|
||||||
|
if ( $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from SHOW TABLES.
|
||||||
|
$has_rows = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, self::post_scoped_routing_default( TMDO_Mode_Manager::get( 'post' ), $has_rows ), '', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether post_type-scoped routing can start on right away.
|
||||||
|
*
|
||||||
|
* Existing post flat rows were written with legacy routing (a key shared by
|
||||||
|
* several groups went to the last one registered), so they may sit in
|
||||||
|
* another post type's table. Scoped lookups stay off there until the rows
|
||||||
|
* are reconciled; with the post bridge disabled nothing reads them.
|
||||||
|
*
|
||||||
|
* @param string $post_mode Current post entity bridge mode.
|
||||||
|
* @param bool $has_post_flat_rows Whether any wpdo_post_* table has rows.
|
||||||
|
* @return string '1' to enable, '0' to wait for reconcile.
|
||||||
|
*/
|
||||||
|
public static function post_scoped_routing_default( string $post_mode, bool $has_post_flat_rows ): string {
|
||||||
|
return ( 'disabled' === $post_mode || ! $has_post_flat_rows ) ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration for the misc bucket default flip (on → off).
|
||||||
|
*
|
||||||
|
* The bucket used to default to enabled; its rows are only readable while it
|
||||||
|
* is on. Sites that already hold misc rows and never set the toggle get '1'
|
||||||
|
* persisted so turning the default off cannot hide their data. Runs from
|
||||||
|
* maybe_upgrade() rather than install() because install() only runs when
|
||||||
|
* SCHEMA_VERSION changes; the flag is autoloaded since it is read per request.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function maybe_preserve_misc_bucket_default(): void {
|
||||||
|
if ( get_option( self::MISC_BUCKET_DEFAULT_FLAG ) || ! class_exists( 'TMDO_Term_Comment_Misc_Bucket' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unset = false === get_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
|
||||||
|
$has_rows = TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) > 0
|
||||||
|
|| TMDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) > 0;
|
||||||
|
|
||||||
|
if ( $unset && $has_rows ) {
|
||||||
|
update_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '1', false );
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( self::MISC_BUCKET_DEFAULT_FLAG, '1', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reasons this site's wpdo_* tables cannot be dropped yet; empty means safe.
|
||||||
|
*
|
||||||
|
* Reads the site's own options rather than Mode_Manager / Feature_Flags,
|
||||||
|
* whose per-request caches do not follow switch_to_blog() during uninstall.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function uninstall_blockers(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$blockers = array();
|
||||||
|
|
||||||
|
$modes = get_option( 'wpdo_bridge_modes', array() );
|
||||||
|
foreach ( is_array( $modes ) ? $modes : array() as $entity => $mode ) {
|
||||||
|
if ( 'aeav_only' === $mode ) {
|
||||||
|
$blockers[] = sprintf( '%1$s 的 bridge 模式是 aeav_only:資料只存在 wpdo_%1$s_* 扁平表。', $entity );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( array( 'wpdo_term_misc', 'wpdo_comment_misc' ) as $name ) {
|
||||||
|
if ( self::table_has_rows( $wpdb->prefix . $name ) ) {
|
||||||
|
$blockers[] = sprintf( '%s 有資料:這些 term / comment meta 只存在 misc 表。', $name );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$features = get_option( 'wpdo_features', array() );
|
||||||
|
foreach ( TMDO_Feature_Flags::ZONE_MODULES as $module ) {
|
||||||
|
$state = is_array( $features ) ? ( $features[ $module ] ?? 'idle' ) : 'idle';
|
||||||
|
if ( in_array( $state, array( 'cleanup', 'complete' ), true ) ) {
|
||||||
|
$blockers[] = sprintf( '區域模組 %1$s 處於 %2$s:讀取已改走 wpdo_* 表。', $module, $state );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $blockers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a table exists and holds at least one row.
|
||||||
|
*
|
||||||
|
* @param string $table Full table name.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function table_has_rows( string $table ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" );
|
||||||
|
// phpcs:enable
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
|
// ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
|
||||||
@@ -696,6 +788,8 @@ class TMDO_Installer {
|
|||||||
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
|
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
|
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
|
||||||
|
wp_clear_scheduled_hook( 'tmdo_drift_resync' );
|
||||||
|
wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
||||||
@@ -751,18 +845,11 @@ class TMDO_Installer {
|
|||||||
$wpdb->prefix . 'wpdo_user_core_profile',
|
$wpdb->prefix . 'wpdo_user_core_profile',
|
||||||
$wpdb->prefix . 'wpdo_user_social',
|
$wpdb->prefix . 'wpdo_user_social',
|
||||||
$wpdb->prefix . 'wpdo_user_commerce',
|
$wpdb->prefix . 'wpdo_user_commerce',
|
||||||
$wpdb->prefix . 'wpdo_user_hp_user',
|
|
||||||
$wpdb->prefix . 'wpdo_user_admin_prefs',
|
$wpdb->prefix . 'wpdo_user_admin_prefs',
|
||||||
$wpdb->prefix . 'wpdo_post_wp_core',
|
$wpdb->prefix . 'wpdo_post_wp_core',
|
||||||
$wpdb->prefix . 'wpdo_post_attachment',
|
$wpdb->prefix . 'wpdo_post_attachment',
|
||||||
$wpdb->prefix . 'wpdo_post_wc_product',
|
$wpdb->prefix . 'wpdo_post_wc_product',
|
||||||
$wpdb->prefix . 'wpdo_post_hp_listing_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_hp_request_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_hp_vendor_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_nav_menu_item',
|
$wpdb->prefix . 'wpdo_post_nav_menu_item',
|
||||||
$wpdb->prefix . 'wpdo_hot_hp_listing',
|
|
||||||
$wpdb->prefix . 'wpdo_term_hp_taxonomy',
|
|
||||||
$wpdb->prefix . 'wpdo_comment_hp_review',
|
|
||||||
$wpdb->prefix . 'wpdo_term_misc',
|
$wpdb->prefix . 'wpdo_term_misc',
|
||||||
$wpdb->prefix . 'wpdo_comment_misc',
|
$wpdb->prefix . 'wpdo_comment_misc',
|
||||||
);
|
);
|
||||||
@@ -906,6 +993,8 @@ class TMDO_Installer {
|
|||||||
'wpdo_health_snapshot_monthly',
|
'wpdo_health_snapshot_monthly',
|
||||||
'wpdo_post_shadow_verify',
|
'wpdo_post_shadow_verify',
|
||||||
'wpdo_term_comment_shadow_verify',
|
'wpdo_term_comment_shadow_verify',
|
||||||
|
'tmdo_drift_resync',
|
||||||
|
'tmdo_ensure_group_indexes',
|
||||||
'wpdo_post_stress_test_batch',
|
'wpdo_post_stress_test_batch',
|
||||||
'wpdo_user_stress_test_batch',
|
'wpdo_user_stress_test_batch',
|
||||||
'wpdo_term_stress_test_batch',
|
'wpdo_term_stress_test_batch',
|
||||||
@@ -1270,7 +1359,10 @@ class TMDO_Installer {
|
|||||||
*/
|
*/
|
||||||
private static function run_dbdelta(): void {
|
private static function run_dbdelta(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
// Same guard as install_v2_tables(): integration tests provide a stub dbDelta().
|
||||||
|
if ( ! function_exists( 'dbDelta' ) ) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
}
|
||||||
|
|
||||||
$charset = $wpdb->get_charset_collate();
|
$charset = $wpdb->get_charset_collate();
|
||||||
$p = $wpdb->prefix;
|
$p = $wpdb->prefix;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -14,10 +16,39 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
*/
|
*/
|
||||||
class TMDO_Logger {
|
class TMDO_Logger {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped correlation id, lazily generated.
|
||||||
|
*
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
private static ?string $trace_id = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped UUIDv4 correlation id.
|
||||||
|
*
|
||||||
|
* Every audit row written during one request shares this value so a single
|
||||||
|
* update_*_meta() call can be traced across entity groups.
|
||||||
|
*
|
||||||
|
* @return string UUIDv4.
|
||||||
|
*/
|
||||||
|
public static function trace_id(): string {
|
||||||
|
if ( null === self::$trace_id ) {
|
||||||
|
try {
|
||||||
|
$bytes = random_bytes( 16 );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
$bytes = pack( 'H*', md5( (string) microtime( true ) . wp_generate_password( 16, false ) ) );
|
||||||
|
}
|
||||||
|
$bytes[6] = chr( ( ord( $bytes[6] ) & 0x0f ) | 0x40 );
|
||||||
|
$bytes[8] = chr( ( ord( $bytes[8] ) & 0x3f ) | 0x80 );
|
||||||
|
self::$trace_id = vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $bytes ), 4 ) );
|
||||||
|
}
|
||||||
|
return self::$trace_id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
|
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
|
||||||
*
|
*
|
||||||
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved
|
* Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved
|
||||||
* for actual errors). Mode changes / cache flushes / audit prunes call here
|
* for actual errors). Mode changes / cache flushes / audit prunes call here
|
||||||
* many times per request — keeping them out of the DB error table.
|
* many times per request — keeping them out of the DB error table.
|
||||||
*
|
*
|
||||||
@@ -26,19 +57,36 @@ class TMDO_Logger {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function info( string $event, array $context = array() ): void {
|
public static function info( string $event, array $context = array() ): void {
|
||||||
|
// Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every
|
||||||
|
// CLI run and production request would flood the log.
|
||||||
|
if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::write_info( $event, $context );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an INFO line to the PHP error log.
|
||||||
|
*
|
||||||
|
* @param string $event Event name.
|
||||||
|
* @param array<string, mixed> $context Structured context.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function write_info( string $event, array $context ): void {
|
||||||
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a NOTICE-level event — alias of info() for callers that want a stronger
|
* Log a NOTICE-level event: always written to the PHP error log (unlike
|
||||||
* level intent. Same routing (error_log only).
|
* info()), for the audit trail of operator actions such as mode changes
|
||||||
|
* and snapshot deletions.
|
||||||
*
|
*
|
||||||
* @param string $event Event name.
|
* @param string $event Event name.
|
||||||
* @param array $context Structured context.
|
* @param array<string, mixed> $context Structured context.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function notice( string $event, array $context = array() ): void {
|
public static function notice( string $event, array $context = array() ): void {
|
||||||
self::info( $event, $context );
|
error_log( sprintf( '[WPDO][NOTICE][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +114,7 @@ class TMDO_Logger {
|
|||||||
*/
|
*/
|
||||||
public static function debug( string $event, array $context = array() ): void {
|
public static function debug( string $event, array $context = array() ): void {
|
||||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||||
self::info( $event, $context );
|
self::write_info( $event, $context );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform shadow verifier: must read raw meta to verify zone correctness
|
|
||||||
/**
|
/**
|
||||||
* TMDO_Post_Shadow_Verifier — Sample-and-compare flat vs wp_postmeta (v2.10.3).
|
* TMDO_Post_Shadow_Verifier — Sample-and-compare flat vs wp_postmeta (v2.10.3).
|
||||||
*
|
*
|
||||||
@@ -22,6 +21,10 @@
|
|||||||
* @since 2.10.3
|
* @since 2.10.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform shadow verifier: must read raw meta to verify zone correctness
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,7 @@ final class TMDO_Post_Shadow_Verifier {
|
|||||||
* is available.
|
* is available.
|
||||||
*
|
*
|
||||||
* @param string $post_type WP post_type to sample.
|
* @param string $post_type WP post_type to sample.
|
||||||
* @param string $group_name Entity group name (e.g. 'wc_product').
|
* @param string $group_name Entity group name (e.g. 'hp_listing_core').
|
||||||
* @param string $flat_table Fully qualified flat table name.
|
* @param string $flat_table Fully qualified flat table name.
|
||||||
* @param string[] $keys Meta keys to compare (each post checks all).
|
* @param string[] $keys Meta keys to compare (each post checks all).
|
||||||
* @param int $sample_size Number of posts to sample (capped to DB count).
|
* @param int $sample_size Number of posts to sample (capped to DB count).
|
||||||
@@ -300,23 +303,7 @@ final class TMDO_Post_Shadow_Verifier {
|
|||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
private static function group_post_type( string $group ): ?string {
|
private static function group_post_type( string $group ): ?string {
|
||||||
switch ( $group ) {
|
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||||
case 'attachment':
|
|
||||||
return 'attachment';
|
|
||||||
case 'wc_product':
|
|
||||||
return 'product';
|
|
||||||
case 'hp_listing_core':
|
|
||||||
return 'hp_listing';
|
|
||||||
case 'hp_request_core':
|
|
||||||
return 'hp_request';
|
|
||||||
case 'hp_vendor_core':
|
|
||||||
return 'hp_vendor';
|
|
||||||
case 'nav_menu_item':
|
|
||||||
return 'nav_menu_item';
|
|
||||||
case 'wp_core':
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
|
||||||
/**
|
/**
|
||||||
* TMDO_Post_Stress_Tester — Bulk fixture generator for post entity migration (v2.9.4).
|
* TMDO_Post_Stress_Tester — Bulk fixture generator for post entity migration (v2.9.4).
|
||||||
*
|
*
|
||||||
@@ -26,6 +25,10 @@
|
|||||||
* @since 2.9.4
|
* @since 2.9.4
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
* @since 2.9.0
|
* @since 2.9.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -21,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
*/
|
*/
|
||||||
class TMDO_REST_API {
|
class TMDO_REST_API {
|
||||||
|
|
||||||
|
/** Blocked views are counted 1 in this many (wpdo_rl_stats). */
|
||||||
|
private const RL_STATS_SAMPLE = 10;
|
||||||
|
|
||||||
const NAMESPACE = 'wpdo/v1';
|
const NAMESPACE = 'wpdo/v1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +40,8 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::READABLE,
|
'methods' => WP_REST_Server::READABLE,
|
||||||
'callback' => array( $this, 'get_listings' ),
|
'callback' => array( $this, 'get_listings' ),
|
||||||
|
// Public by design: viewable post types and published posts only, fields
|
||||||
|
// declared show_in_rest only (1.1.1), per_page capped (wpdo_rest_max_per_page).
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'args' => $this->listings_args(),
|
'args' => $this->listings_args(),
|
||||||
)
|
)
|
||||||
@@ -46,6 +53,8 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::READABLE,
|
'methods' => WP_REST_Server::READABLE,
|
||||||
'callback' => array( $this, 'get_listing' ),
|
'callback' => array( $this, 'get_listing' ),
|
||||||
|
// Public by design: the post must be viewable to the request (assert_post_viewable_or_error:
|
||||||
|
// publicly viewable or read_post); fields declared show_in_rest only (1.1.1).
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'args' => array(
|
'args' => array(
|
||||||
'id' => array(
|
'id' => array(
|
||||||
@@ -63,6 +72,7 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::READABLE,
|
'methods' => WP_REST_Server::READABLE,
|
||||||
'callback' => array( $this, 'get_stats' ),
|
'callback' => array( $this, 'get_stats' ),
|
||||||
|
// Public by design: view / engagement counters of a post viewable to the request.
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'args' => array(
|
'args' => array(
|
||||||
'id' => array(
|
'id' => array(
|
||||||
@@ -80,6 +90,8 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::CREATABLE,
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
'callback' => array( $this, 'post_view' ),
|
'callback' => array( $this, 'post_view' ),
|
||||||
|
// Public by design: anonymous visitors count views; viewable posts only, one view
|
||||||
|
// per IP per post per hour (rate limited).
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'args' => array(
|
'args' => array(
|
||||||
'id' => array(
|
'id' => array(
|
||||||
@@ -527,7 +539,16 @@ class TMDO_REST_API {
|
|||||||
* @return WP_REST_Response REST response.
|
* @return WP_REST_Response REST response.
|
||||||
*/
|
*/
|
||||||
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
|
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
|
||||||
$post_type = $request->get_param( 'post_type' ) ?? 'hp_listing';
|
$post_type = $request->get_param( 'post_type' ) ?? self::default_post_type();
|
||||||
|
if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) {
|
||||||
|
return new WP_REST_Response(
|
||||||
|
array(
|
||||||
|
'code' => 'rest_invalid_post_type',
|
||||||
|
'message' => 'Invalid post type.',
|
||||||
|
),
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
|
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
|
||||||
// Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
|
// Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
|
||||||
$max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
|
$max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
|
||||||
@@ -644,7 +665,7 @@ class TMDO_REST_API {
|
|||||||
return $err;
|
return $err;
|
||||||
}
|
}
|
||||||
|
|
||||||
$views = TMDO_Listing_Stats::get_view_count( $post_id );
|
$views = self::read_view_count( $post_id );
|
||||||
|
|
||||||
return new WP_REST_Response(
|
return new WP_REST_Response(
|
||||||
array(
|
array(
|
||||||
@@ -707,14 +728,18 @@ class TMDO_REST_API {
|
|||||||
|
|
||||||
if ( $cookie_blocked || $ip_blocked ) {
|
if ( $cookie_blocked || $ip_blocked ) {
|
||||||
// Track rate-limit hits per post for admin visibility, capped at 100 entries.
|
// Track rate-limit hits per post for admin visibility, capped at 100 entries.
|
||||||
$rl_stats = get_option( 'wpdo_rl_stats', array() );
|
// Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so
|
||||||
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1;
|
// anonymous requests cannot turn every blocked view into an option write.
|
||||||
if ( count( $rl_stats ) > 100 ) {
|
if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) {
|
||||||
// Keep only the top-100 posts by hit count to bound option size.
|
$rl_stats = get_option( 'wpdo_rl_stats', array() );
|
||||||
arsort( $rl_stats );
|
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE;
|
||||||
$rl_stats = array_slice( $rl_stats, 0, 100, true );
|
if ( count( $rl_stats ) > 100 ) {
|
||||||
|
// Keep only the top-100 posts by hit count to bound option size.
|
||||||
|
arsort( $rl_stats );
|
||||||
|
$rl_stats = array_slice( $rl_stats, 0, 100, true );
|
||||||
|
}
|
||||||
|
update_option( 'wpdo_rl_stats', $rl_stats, false );
|
||||||
}
|
}
|
||||||
update_option( 'wpdo_rl_stats', $rl_stats, false );
|
|
||||||
|
|
||||||
return new WP_REST_Response(
|
return new WP_REST_Response(
|
||||||
array(
|
array(
|
||||||
@@ -728,8 +753,8 @@ class TMDO_REST_API {
|
|||||||
// Mark as counted for this IP/session.
|
// Mark as counted for this IP/session.
|
||||||
set_transient( $rl_key, 1, HOUR_IN_SECONDS );
|
set_transient( $rl_key, 1, HOUR_IN_SECONDS );
|
||||||
|
|
||||||
TMDO_Listing_Stats::increment_view( $post_id );
|
self::bump_view_count( $post_id );
|
||||||
$views = TMDO_Listing_Stats::get_view_count( $post_id );
|
$views = self::read_view_count( $post_id );
|
||||||
|
|
||||||
$response = new WP_REST_Response(
|
$response = new WP_REST_Response(
|
||||||
array(
|
array(
|
||||||
@@ -1363,6 +1388,10 @@ class TMDO_REST_API {
|
|||||||
/**
|
/**
|
||||||
* Query Zone A flat table directly.
|
* Query Zone A flat table directly.
|
||||||
*
|
*
|
||||||
|
* Rows are joined to the posts table so only published posts of the
|
||||||
|
* requested type are listed (the hot table also holds drafts, private
|
||||||
|
* and trashed posts).
|
||||||
|
*
|
||||||
* @param string $post_type Post type slug.
|
* @param string $post_type Post type slug.
|
||||||
* @param int $per_page Number of items per page.
|
* @param int $per_page Number of items per page.
|
||||||
* @param int $offset Database offset.
|
* @param int $offset Database offset.
|
||||||
@@ -1381,8 +1410,13 @@ class TMDO_REST_API {
|
|||||||
): WP_REST_Response {
|
): WP_REST_Response {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
|
// Filters and ordering only on public columns: hidden values must not be
|
||||||
|
// inferable by filtering or sorting on them.
|
||||||
$table = TMDO_Zone_Hot::table( $post_type );
|
$table = TMDO_Zone_Hot::table( $post_type );
|
||||||
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
$columns = array_intersect_key(
|
||||||
|
TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ),
|
||||||
|
array_flip( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) )
|
||||||
|
);
|
||||||
|
|
||||||
// Build WHERE clauses from numeric filter params.
|
// Build WHERE clauses from numeric filter params.
|
||||||
$where = array();
|
$where = array();
|
||||||
@@ -1394,6 +1428,8 @@ class TMDO_REST_API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
|
$where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
|
||||||
|
$join_sql = "INNER JOIN `{$wpdb->posts}` p ON p.ID = t.post_id AND p.post_type = %s AND p.post_status = 'publish'";
|
||||||
|
array_unshift( $params, $post_type );
|
||||||
|
|
||||||
// Validate orderby against known columns + post_id.
|
// Validate orderby against known columns + post_id.
|
||||||
$allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
|
$allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
|
||||||
@@ -1402,16 +1438,12 @@ class TMDO_REST_API {
|
|||||||
|
|
||||||
// Count total.
|
// Count total.
|
||||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
|
||||||
if ( $params ) {
|
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` t {$join_sql} {$where_sql}", ...$params ) );
|
||||||
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) );
|
|
||||||
} else {
|
|
||||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch rows.
|
// Fetch rows.
|
||||||
$rows = $wpdb->get_results(
|
$rows = $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
|
"SELECT t.* FROM `{$table}` t {$join_sql} {$where_sql} ORDER BY t.`{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
|
||||||
...array_merge( $params, array( $per_page, $offset ) )
|
...array_merge( $params, array( $per_page, $offset ) )
|
||||||
),
|
),
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
@@ -1430,8 +1462,8 @@ class TMDO_REST_API {
|
|||||||
'post_type' => $post_type,
|
'post_type' => $post_type,
|
||||||
);
|
);
|
||||||
unset( $row['post_id'], $row['updated_at'] );
|
unset( $row['post_id'], $row['updated_at'] );
|
||||||
// Keep only columns with show_in_rest => true (or all if no registry entry).
|
// Keep only public columns (none when nothing is declared public).
|
||||||
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row;
|
$row = array_intersect_key( $row, $visible_cols );
|
||||||
return array_merge( $item, $row );
|
return array_merge( $item, $row );
|
||||||
},
|
},
|
||||||
$rows
|
$rows
|
||||||
@@ -1474,8 +1506,8 @@ class TMDO_REST_API {
|
|||||||
'id' => $post->ID,
|
'id' => $post->ID,
|
||||||
'post_type' => $post->post_type,
|
'post_type' => $post->post_type,
|
||||||
);
|
);
|
||||||
$cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
// Same show_in_rest restriction as the Zone A path.
|
||||||
foreach ( array_keys( $cols ) as $col ) {
|
foreach ( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) as $col ) {
|
||||||
$item[ $col ] = get_post_meta( $post->ID, $col, true );
|
$item[ $col ] = get_post_meta( $post->ID, $col, true );
|
||||||
}
|
}
|
||||||
$items[] = $item;
|
$items[] = $item;
|
||||||
@@ -1505,7 +1537,7 @@ class TMDO_REST_API {
|
|||||||
$min = $request->get_param( $col . '_min' );
|
$min = $request->get_param( $col . '_min' );
|
||||||
if ( null !== $min && is_numeric( $min ) ) {
|
if ( null !== $min && is_numeric( $min ) ) {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` >= %f",
|
'sql' => "t.`{$col}` >= %f",
|
||||||
'value' => (float) $min,
|
'value' => (float) $min,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1513,7 +1545,7 @@ class TMDO_REST_API {
|
|||||||
$max = $request->get_param( $col . '_max' );
|
$max = $request->get_param( $col . '_max' );
|
||||||
if ( null !== $max && is_numeric( $max ) ) {
|
if ( null !== $max && is_numeric( $max ) ) {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` <= %f",
|
'sql' => "t.`{$col}` <= %f",
|
||||||
'value' => (float) $max,
|
'value' => (float) $max,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1522,12 +1554,12 @@ class TMDO_REST_API {
|
|||||||
if ( null !== $exact && '' !== $exact ) {
|
if ( null !== $exact && '' !== $exact ) {
|
||||||
if ( is_numeric( $exact ) ) {
|
if ( is_numeric( $exact ) ) {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` = %f",
|
'sql' => "t.`{$col}` = %f",
|
||||||
'value' => (float) $exact,
|
'value' => (float) $exact,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` = %s",
|
'sql' => "t.`{$col}` = %s",
|
||||||
'value' => (string) $exact,
|
'value' => (string) $exact,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1537,13 +1569,25 @@ class TMDO_REST_API {
|
|||||||
return $filters;
|
return $filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post type GET /listings serves when the request names none.
|
||||||
|
*
|
||||||
|
* Filter `tmdo_rest_default_post_type` lets an integration (e.g. the
|
||||||
|
* HivePress AddOn) make its own post type the default.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function default_post_type(): string {
|
||||||
|
return (string) apply_filters( 'tmdo_rest_default_post_type', 'post' );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Argument definitions for GET /listings.
|
* Argument definitions for GET /listings.
|
||||||
*/
|
*/
|
||||||
private function listings_args(): array {
|
private function listings_args(): array {
|
||||||
return array(
|
return array(
|
||||||
'post_type' => array(
|
'post_type' => array(
|
||||||
'default' => 'hp_listing',
|
'default' => self::default_post_type(),
|
||||||
'sanitize_callback' => 'sanitize_key',
|
'sanitize_callback' => 'sanitize_key',
|
||||||
),
|
),
|
||||||
'per_page' => array(
|
'per_page' => array(
|
||||||
@@ -1640,4 +1684,37 @@ class TMDO_REST_API {
|
|||||||
$result = TMDO_Migration_Orchestrator::resume();
|
$result = TMDO_Migration_Orchestrator::resume();
|
||||||
return new WP_REST_Response( $result, ! empty( $result['ok'] ) ? 200 : 409 );
|
return new WP_REST_Response( $result, ! empty( $result['ok'] ) ? 200 : 409 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a post's view counter.
|
||||||
|
*
|
||||||
|
* Delegates to the HivePress AddOn when it is active, because that class
|
||||||
|
* adds an hp_view_count postmeta fallback for listings migrated before the
|
||||||
|
* warm zone existed. Without the AddOn — the normal case for a plain
|
||||||
|
* install — core reads its own warm row directly. Calling the AddOn class
|
||||||
|
* unconditionally used to fatal these endpoints on any site without it.
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
private static function read_view_count( int $post_id ): int {
|
||||||
|
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
|
||||||
|
return (int) TMDO_Listing_Stats::get_view_count( $post_id );
|
||||||
|
}
|
||||||
|
return (int) ( TMDO_Zone_Warm::get( $post_id, TMDO_Zone_Warm::VIEW_KEY ) ?? 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment a post's view counter.
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function bump_view_count( int $post_id ): void {
|
||||||
|
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
|
||||||
|
TMDO_Listing_Stats::increment_view( $post_id );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TMDO_Zone_Warm::increment( $post_id, TMDO_Zone_Warm::VIEW_KEY, 1, TMDO_Zone_Warm::VIEW_TTL );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
* @since 0.2.0
|
* @since 0.2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
* @since 2.13.3
|
* @since 2.13.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -65,6 +67,13 @@ class TMDO_Schema_Registry {
|
|||||||
*/
|
*/
|
||||||
private array $warm_fields = array();
|
private array $warm_fields = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Providers already told the Hot zone is frozen (once per request).
|
||||||
|
*
|
||||||
|
* @var array<string, true>
|
||||||
|
*/
|
||||||
|
private array $hot_deprecation_flagged = array();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private constructor — use instance() to get the singleton.
|
* Private constructor — use instance() to get the singleton.
|
||||||
*/
|
*/
|
||||||
@@ -94,7 +103,9 @@ class TMDO_Schema_Registry {
|
|||||||
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
||||||
* - cache_group (string) Object cache group (Zone C only).
|
* - cache_group (string) Object cache group (Zone C only).
|
||||||
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
||||||
* - show_in_rest (bool) Whether to expose via GET /wpdo/v1/listings. Default true.
|
* - show_in_rest (bool) Whether to expose via the public GET /wpdo/v1/listings
|
||||||
|
* endpoints. Default false; protected (underscore) keys and
|
||||||
|
* credential-like keys are never exposed (see rest_exposable()).
|
||||||
*/
|
*/
|
||||||
public function register( string $provider, array $config ): void {
|
public function register( string $provider, array $config ): void {
|
||||||
$config = wp_parse_args(
|
$config = wp_parse_args(
|
||||||
@@ -111,7 +122,7 @@ class TMDO_Schema_Registry {
|
|||||||
'cache_group' => '',
|
'cache_group' => '',
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
'cache_ttl' => HOUR_IN_SECONDS,
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'show_in_rest' => true,
|
'show_in_rest' => false,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,6 +147,7 @@ class TMDO_Schema_Registry {
|
|||||||
switch ( $config['zone'] ) {
|
switch ( $config['zone'] ) {
|
||||||
case 'hot':
|
case 'hot':
|
||||||
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
||||||
|
$this->flag_hot_deprecation( $provider, $config['meta_key'] );
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'warm':
|
case 'warm':
|
||||||
@@ -143,11 +155,43 @@ class TMDO_Schema_Registry {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cold':
|
case 'cold':
|
||||||
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
|
// 去重:hot 以 column 為 key、warm 以 meta_key 為 key,兩者天生冪等;
|
||||||
|
// 唯獨 cold 是 append,重複註冊會無限膨脹。而 `wpdo_register_fields`
|
||||||
|
// 本來就會被 fire 多次(core plugins_loaded:4 + late-bind safety net :30),
|
||||||
|
// 加上主外掛與 AddOn 可能同時註冊同一批欄位,實測曾出現同一 key 重複 4 次。
|
||||||
|
if ( ! isset( $this->cold_fields[ $config['post_type'] ] ) ) {
|
||||||
|
$this->cold_fields[ $config['post_type'] ] = array();
|
||||||
|
}
|
||||||
|
if ( ! in_array( $config['meta_key'], $this->cold_fields[ $config['post_type'] ], true ) ) {
|
||||||
|
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hot zone is frozen (ADR-003): fields still register so existing hot
|
||||||
|
* data stays readable, but each provider gets one notice per request.
|
||||||
|
*
|
||||||
|
* @param string $provider Provider name.
|
||||||
|
* @param string $meta_key First hot meta_key seen from it.
|
||||||
|
*/
|
||||||
|
private function flag_hot_deprecation( string $provider, string $meta_key ): void {
|
||||||
|
if ( isset( $this->hot_deprecation_flagged[ $provider ] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->hot_deprecation_flagged[ $provider ] = true;
|
||||||
|
_doing_it_wrong(
|
||||||
|
__CLASS__ . '::register',
|
||||||
|
sprintf(
|
||||||
|
'Provider "%1$s" registered Hot zone field "%2$s". The Hot zone is frozen; register an entity group (TMDO_Entity_Registry::register_group) instead.',
|
||||||
|
esc_html( $provider ),
|
||||||
|
esc_html( $meta_key )
|
||||||
|
),
|
||||||
|
esc_html( TMDO_VERSION )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk register multiple fields.
|
* Bulk register multiple fields.
|
||||||
*
|
*
|
||||||
@@ -234,6 +278,25 @@ class TMDO_Schema_Registry {
|
|||||||
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a field may appear in the public listings endpoints: declared
|
||||||
|
* show_in_rest, not a protected meta key (leading underscore — WordPress
|
||||||
|
* hides those from public output, e.g. _purchase_note), and not named like a
|
||||||
|
* credential (tokens, secrets, passwords, API keys) or a contact / location
|
||||||
|
* detail (email, phone, address, IP), whatever it declares.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $field Registered field.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function rest_exposable( array $field ): bool {
|
||||||
|
$key = (string) ( $field['meta_key'] ?? '' );
|
||||||
|
return ! empty( $field['show_in_rest'] )
|
||||||
|
&& 0 !== strpos( $key, '_' )
|
||||||
|
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) )
|
||||||
|
// Contact and location details (2.3 PR-096): private even when declared public.
|
||||||
|
&& 1 !== preg_match( '/e_?mail|phone|address|(^|_)ip(_|$|\s)/i', $key . ' ' . ( $field['column'] ?? '' ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns hot column names for a post type where show_in_rest is true.
|
* Returns hot column names for a post type where show_in_rest is true.
|
||||||
* Used by REST endpoints to restrict publicly exposed fields.
|
* Used by REST endpoints to restrict publicly exposed fields.
|
||||||
@@ -244,7 +307,7 @@ class TMDO_Schema_Registry {
|
|||||||
public function get_rest_visible_hot_columns( string $post_type ): array {
|
public function get_rest_visible_hot_columns( string $post_type ): array {
|
||||||
$visible = array();
|
$visible = array();
|
||||||
foreach ( $this->fields as $field ) {
|
foreach ( $this->fields as $field ) {
|
||||||
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||||
$visible[] = $field['column'];
|
$visible[] = $field['column'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,7 +324,7 @@ class TMDO_Schema_Registry {
|
|||||||
public function get_rest_visible_cold_keys( string $post_type ): array {
|
public function get_rest_visible_cold_keys( string $post_type ): array {
|
||||||
$visible = array();
|
$visible = array();
|
||||||
foreach ( $this->fields as $field ) {
|
foreach ( $this->fields as $field ) {
|
||||||
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||||
$visible[] = $field['meta_key'];
|
$visible[] = $field['meta_key'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
* @since 2.12.6
|
* @since 2.12.6
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -58,20 +60,22 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
final class TMDO_Term_Comment_Backfill {
|
final class TMDO_Term_Comment_Backfill {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
|
* Term and comment groups registered in the entity registry.
|
||||||
*
|
*
|
||||||
* @var array<string, array{entity_type:string, table_suffix:string}>
|
* @return array<string, string> Group name → entity type ('term' | 'comment').
|
||||||
*/
|
*/
|
||||||
private const GROUP_MAP = array(
|
public static function registered_groups(): array {
|
||||||
'hp_taxonomy' => array(
|
$groups = array();
|
||||||
'entity_type' => 'term',
|
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
return $groups;
|
||||||
),
|
}
|
||||||
'hp_review' => array(
|
foreach ( array( 'term', 'comment' ) as $entity_type ) {
|
||||||
'entity_type' => 'comment',
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
||||||
'table_suffix' => 'wpdo_comment_hp_review',
|
$groups[ $group ] = $groups[ $group ] ?? $entity_type;
|
||||||
),
|
}
|
||||||
);
|
}
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run backfill for every registered term + comment group.
|
* Run backfill for every registered term + comment group.
|
||||||
@@ -81,8 +85,8 @@ final class TMDO_Term_Comment_Backfill {
|
|||||||
*/
|
*/
|
||||||
public static function backfill_all( bool $dry_run = false ): array {
|
public static function backfill_all( bool $dry_run = false ): array {
|
||||||
$results = array();
|
$results = array();
|
||||||
foreach ( self::GROUP_MAP as $group_name => $cfg ) {
|
foreach ( self::registered_groups() as $group_name => $entity_type ) {
|
||||||
$results[ $group_name ] = self::backfill_group( $cfg['entity_type'], $group_name, $dry_run );
|
$results[ $group_name ] = self::backfill_group( $entity_type, $group_name, $dry_run );
|
||||||
}
|
}
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,7 @@ final class TMDO_Term_Comment_Backfill {
|
|||||||
$result['error'] = 'Entity Registry unavailable';
|
$result['error'] = 'Entity Registry unavailable';
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
if ( ! isset( self::GROUP_MAP[ $group_name ] ) ) {
|
if ( ( self::registered_groups()[ $group_name ] ?? null ) !== $entity_type ) {
|
||||||
$result['error'] = "Unknown group: {$group_name}";
|
$result['error'] = "Unknown group: {$group_name}";
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -138,7 +142,7 @@ final class TMDO_Term_Comment_Backfill {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
$flat_table = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix'];
|
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||||
|
|
||||||
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
|
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
|
||||||
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
* @since 2.12.5
|
* @since 2.12.5
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -39,23 +41,6 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
/** Default sample size per entity per cron tick. */
|
/** Default sample size per entity per cron tick. */
|
||||||
public const DEFAULT_SAMPLE_SIZE = 100;
|
public const DEFAULT_SAMPLE_SIZE = 100;
|
||||||
|
|
||||||
/**
|
|
||||||
* Group → flat table suffix mapping. Keep in sync with
|
|
||||||
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
|
|
||||||
*
|
|
||||||
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
|
|
||||||
*/
|
|
||||||
private const GROUP_MAP = array(
|
|
||||||
'hp_taxonomy' => array(
|
|
||||||
'entity_type' => 'term',
|
|
||||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
|
||||||
),
|
|
||||||
'hp_review' => array(
|
|
||||||
'entity_type' => 'comment',
|
|
||||||
'table_suffix' => 'wpdo_comment_hp_review',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a sample-compare pass for a single entity group.
|
* Run a sample-compare pass for a single entity group.
|
||||||
*
|
*
|
||||||
@@ -213,11 +198,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
|
||||||
|
|
||||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
|
||||||
$entity_type = $cfg['entity_type'];
|
|
||||||
|
|
||||||
// Only run when the entity is in shadow_read mode.
|
// Only run when the entity is in shadow_read mode.
|
||||||
if ( 'term' === $entity_type && ! $term_shadow ) {
|
if ( 'term' === $entity_type && ! $term_shadow ) {
|
||||||
continue;
|
continue;
|
||||||
@@ -230,7 +211,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
if ( empty( $keys ) ) {
|
if ( empty( $keys ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
|
||||||
try {
|
try {
|
||||||
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
||||||
@@ -335,16 +316,14 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
return array();
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
|
||||||
$out = array();
|
$out = array();
|
||||||
|
|
||||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
|
||||||
$entity_type = $cfg['entity_type'];
|
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
||||||
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
|
||||||
if ( empty( $keys ) ) {
|
if ( empty( $keys ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
|
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
|
||||||
/**
|
/**
|
||||||
* TMDO_Term_Stress_Tester — Async stress fixture generator for term entity (v2.13.0).
|
* TMDO_Term_Stress_Tester — Async stress fixture generator for term entity (v2.13.0).
|
||||||
*
|
*
|
||||||
@@ -22,6 +21,10 @@
|
|||||||
* @since 2.13.0
|
* @since 2.13.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
* @since 2.12.0
|
* @since 2.12.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
|
||||||
/**
|
/**
|
||||||
* TMDO_User_Stress_Tester — User entity 壓力測試 / Benchmark 工具
|
* TMDO_User_Stress_Tester — User entity 壓力測試 / Benchmark 工具
|
||||||
*
|
*
|
||||||
@@ -16,6 +15,10 @@
|
|||||||
* @since 2.6.7
|
* @since 2.6.7
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,Generic.CodeAnalysis.UnusedFunctionParameter,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.PHP.YodaConditions,WordPress.WP.AlternativeFunctions.rand_mt_rand,WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- 測試工具:mt_rand 用於杜撰測試資料;serialize() 為 WP capabilities 標準格式
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,Generic.CodeAnalysis.UnusedFunctionParameter,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.PHP.YodaConditions,WordPress.WP.AlternativeFunctions.rand_mt_rand,WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- 測試工具:mt_rand 用於杜撰測試資料;serialize() 為 WP capabilities 標準格式
|
||||||
|
|
||||||
defined( 'ABSPATH' ) || exit;
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TMDO_Drift_Check — read-only flat-vs-native comparison for every entity type.
|
||||||
|
*
|
||||||
|
* For each backfillable group and key, the flat column is compared with what
|
||||||
|
* native meta would put there (first native row → safe unserialize →
|
||||||
|
* TMDO_Type_Caster::to_db()):
|
||||||
|
*
|
||||||
|
* miss — native holds a non-empty value, the flat column is NULL or has no row
|
||||||
|
* diff — both hold a value and the values differ (numbers, JSON and
|
||||||
|
* serialized values are compared by value, not by formatting)
|
||||||
|
* extra — the flat column holds a value, native has no row for the key
|
||||||
|
* (a required column holding its default counts as absent); rows of
|
||||||
|
* another post type or of a deleted entity count too
|
||||||
|
*
|
||||||
|
* run() writes nothing except the per-group outcome in the wpdo_drift_status
|
||||||
|
* option. fix() rebuilds the drifted rows from native (native is never written).
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
* @since 1.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- drift checker: must read raw meta to compare it with the flat tables
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Read-only checker: table and column names come from the registry / adapters, meta keys and ids go through prepare() or intval.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-vs-native drift checker.
|
||||||
|
*/
|
||||||
|
final class TMDO_Drift_Check {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option holding the last outcome per entity / group.
|
||||||
|
*/
|
||||||
|
public const OPTION = 'wpdo_drift_status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities per group sampled by the daily check.
|
||||||
|
*/
|
||||||
|
public const CRON_SAMPLE = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single WP-Cron event rebuilding an entity's flat tables from native after
|
||||||
|
* writes bypassed them. Argument: the entity type.
|
||||||
|
*/
|
||||||
|
public const RESYNC_HOOK = 'tmdo_drift_resync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds before the scheduled rebuild, so the switch that triggered it
|
||||||
|
* finishes first.
|
||||||
|
*/
|
||||||
|
private const RESYNC_DELAY = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
|
||||||
|
* Exact matches compare BINARY: meta and flat tables may differ in collation,
|
||||||
|
* and a case-insensitive one would hide 'b' vs 'B'.
|
||||||
|
*/
|
||||||
|
private const SQL_NUMERIC = '^-?[0-9]+(\\\\.[0-9]+)?$';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare one entity type's flat tables with native meta.
|
||||||
|
*
|
||||||
|
* @param string $entity_type post | user | term | comment.
|
||||||
|
* @param string $group One group, or '' for every backfillable group.
|
||||||
|
* @param int $sample 0 checks every entity; N checks N random entities per group.
|
||||||
|
* @return array{entity:string, sample:int, groups:array<string, array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}>, total:array{miss:int, diff:int, extra:int}}
|
||||||
|
* @throws InvalidArgumentException Unknown entity type or group.
|
||||||
|
*/
|
||||||
|
public static function run( string $entity_type, string $group = '', int $sample = 0 ): array {
|
||||||
|
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
|
||||||
|
|
||||||
|
$report = array(
|
||||||
|
'entity' => $entity_type,
|
||||||
|
'sample' => $sample,
|
||||||
|
'groups' => array(),
|
||||||
|
'total' => array(
|
||||||
|
'miss' => 0,
|
||||||
|
'diff' => 0,
|
||||||
|
'extra' => 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
$status = get_option( self::OPTION, array() );
|
||||||
|
$status = is_array( $status ) ? $status : array();
|
||||||
|
|
||||||
|
foreach ( $groups as $name ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
|
||||||
|
unset( $result['ids'] );
|
||||||
|
|
||||||
|
$report['groups'][ $name ] = $result;
|
||||||
|
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
|
||||||
|
$report['total'][ $kind ] += $result[ $kind ];
|
||||||
|
}
|
||||||
|
$status[ $entity_type ][ $name ] = array(
|
||||||
|
'miss' => $result['miss'],
|
||||||
|
'diff' => $result['diff'],
|
||||||
|
'extra' => $result['extra'],
|
||||||
|
'sample' => $sample,
|
||||||
|
'checked_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( self::OPTION, $status );
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every backfillable group of the entity was checked within $max_age
|
||||||
|
* seconds (sampled or full) and none drifted. No record counts as unclean.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param int $max_age Seconds.
|
||||||
|
*/
|
||||||
|
public static function clean_since( string $entity_type, int $max_age ): bool {
|
||||||
|
$status = get_option( self::OPTION, array() );
|
||||||
|
$status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array();
|
||||||
|
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||||
|
if ( empty( $groups ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ( $groups as $group ) {
|
||||||
|
$last = $status[ $group ] ?? null;
|
||||||
|
if ( ! is_array( $last )
|
||||||
|
|| (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0
|
||||||
|
|| (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily check (wpdo_daily_health_check): sample every entity type written
|
||||||
|
* to both native and flat (dual_write, shadow_read). In aeav_only flat is
|
||||||
|
* the source of truth and native is stale by design, so drift means nothing
|
||||||
|
* there. The outcome replaces the recorded status, so a group found
|
||||||
|
* drifting leaves the router at once.
|
||||||
|
*/
|
||||||
|
public static function cron_tick(): void {
|
||||||
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||||
|
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) || ! TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
self::run( $entity_type, '', self::CRON_SAMPLE );
|
||||||
|
} catch ( InvalidArgumentException $e ) {
|
||||||
|
continue; // No adapter for this entity type on this site.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities to rebuild after a mode change: one whose flat tables were not
|
||||||
|
* written (disabled) is written next to native again, and values written
|
||||||
|
* meanwhile are missing from flat.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $new_mode New mode.
|
||||||
|
* @param string $old_mode Previous mode.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array {
|
||||||
|
$keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ );
|
||||||
|
return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities to rebuild after the Hook Bus flag changes: no write reached
|
||||||
|
* flat while it was off. Every entity written to both native and flat
|
||||||
|
* (aeav_only keeps flat as the source of truth).
|
||||||
|
*
|
||||||
|
* @param mixed $old_value Previous flag value.
|
||||||
|
* @param mixed $value New flag value.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function resync_after_hook_bus_change( $old_value, $value ): array {
|
||||||
|
if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$entities = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
||||||
|
$entities[] = $entity_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wpdo_bridge_mode_changed` handler.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $new_mode New mode.
|
||||||
|
* @param string $old_mode Previous mode.
|
||||||
|
*/
|
||||||
|
public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||||
|
self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `update_option_wpdo_hook_bus_enabled` handler.
|
||||||
|
*
|
||||||
|
* @param mixed $old_value Previous flag value.
|
||||||
|
* @param mixed $value New flag value.
|
||||||
|
*/
|
||||||
|
public static function on_hook_bus_toggled( $old_value, $value ): void {
|
||||||
|
self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time.
|
||||||
|
*
|
||||||
|
* @param string $option Option name.
|
||||||
|
* @param mixed $value Flag value.
|
||||||
|
*/
|
||||||
|
public static function on_hook_bus_added( $option, $value ): void {
|
||||||
|
unset( $option );
|
||||||
|
self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is
|
||||||
|
* left alone (fix() refuses).
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
*/
|
||||||
|
public static function run_scheduled_fix( string $entity_type ): void {
|
||||||
|
try {
|
||||||
|
$result = self::fix( $entity_type );
|
||||||
|
TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again.
|
||||||
|
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
|
||||||
|
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||||
|
TMDO_Logger::info(
|
||||||
|
'drift_resync_skipped',
|
||||||
|
array(
|
||||||
|
'entity' => $entity_type,
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule one rebuild per entity type (not twice while one is pending).
|
||||||
|
*
|
||||||
|
* @param string[] $entities Entity types.
|
||||||
|
*/
|
||||||
|
private static function schedule_resync( array $entities ): void {
|
||||||
|
foreach ( $entities as $entity_type ) {
|
||||||
|
if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) {
|
||||||
|
wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it).
|
||||||
|
*
|
||||||
|
* @param mixed $value Flag value.
|
||||||
|
*/
|
||||||
|
private static function flag_on( $value ): bool {
|
||||||
|
return '1' === (string) $value || true === $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild drifted flat rows from native meta, then check again.
|
||||||
|
*
|
||||||
|
* Rows of deleted entities (or, for post groups, of posts outside the
|
||||||
|
* group's post types) are deleted. Every other entity with drift in any key
|
||||||
|
* gets its whole row rebuilt from native; when native holds none of the
|
||||||
|
* group's keys the row is kept with its columns cleared. Row caches of the
|
||||||
|
* touched entities are invalidated. Native meta is never written.
|
||||||
|
*
|
||||||
|
* @param string $entity_type post | user | term | comment.
|
||||||
|
* @param string $group One group, or '' for every backfillable group.
|
||||||
|
* @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array<string, mixed>}
|
||||||
|
* @throws RuntimeException In aeav_only, where flat is the source of truth
|
||||||
|
* (and InvalidArgumentException via resolve() for an
|
||||||
|
* unknown entity type or group).
|
||||||
|
*/
|
||||||
|
public static function fix( string $entity_type, string $group = '' ): array {
|
||||||
|
global $wpdb;
|
||||||
|
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
|
||||||
|
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) {
|
||||||
|
throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $adapter->get_entity_id_column();
|
||||||
|
$fixed = array(
|
||||||
|
'rebuilt' => 0,
|
||||||
|
'cleared' => 0,
|
||||||
|
'deleted_rows' => 0,
|
||||||
|
);
|
||||||
|
foreach ( $groups as $name ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) {
|
||||||
|
$fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) );
|
||||||
|
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) {
|
||||||
|
if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) {
|
||||||
|
++$fixed['rebuilt'];
|
||||||
|
} else {
|
||||||
|
self::clear_row( $entity_type, $name, $table, $id, $eid );
|
||||||
|
++$fixed['cleared'];
|
||||||
|
}
|
||||||
|
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'fixed' => $fixed,
|
||||||
|
'after' => self::run( $entity_type, $group ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter and group list for a run.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group One group, or '' for every backfillable group.
|
||||||
|
* @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]}
|
||||||
|
* @throws InvalidArgumentException Unknown entity type or group.
|
||||||
|
*/
|
||||||
|
private static function resolve( string $entity_type, string $group ): array {
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
|
||||||
|
}
|
||||||
|
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||||
|
if ( '' !== $group ) {
|
||||||
|
if ( ! in_array( $group, $groups, true ) ) {
|
||||||
|
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
|
||||||
|
}
|
||||||
|
$groups = array( $group );
|
||||||
|
}
|
||||||
|
return array( $adapter, $groups );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare every key of one group.
|
||||||
|
*
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Group flat table.
|
||||||
|
* @param int $sample 0, or the number of random entities.
|
||||||
|
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>, ids:int[]}
|
||||||
|
*/
|
||||||
|
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$meta = $adapter->get_native_meta_table();
|
||||||
|
$id = $adapter->get_entity_id_column();
|
||||||
|
$mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
|
||||||
|
$scope = self::scope_sql( $adapter, $entity_type, $group, $sample );
|
||||||
|
$result = array(
|
||||||
|
'miss' => 0,
|
||||||
|
'diff' => 0,
|
||||||
|
'extra' => 0,
|
||||||
|
'keys' => array(),
|
||||||
|
'ids' => array(),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||||
|
$key = (string) $field['key'];
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
|
||||||
|
// First native row per entity — what a single get_*_meta() read returns.
|
||||||
|
$native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key )
|
||||||
|
. str_replace( '{id}', "m.`{$id}`", $scope );
|
||||||
|
|
||||||
|
// Text beyond the column width is stored as NULL by design (Type_Caster::fit_text).
|
||||||
|
$fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : '';
|
||||||
|
$miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) );
|
||||||
|
|
||||||
|
$candidates = $wpdb->get_results(
|
||||||
|
"SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
|
||||||
|
WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v
|
||||||
|
OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
$diff = array();
|
||||||
|
foreach ( (array) $candidates as $row ) {
|
||||||
|
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
|
||||||
|
$diff[] = (int) $row['eid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key );
|
||||||
|
if ( ! empty( $field['required'] ) ) {
|
||||||
|
$default = (string) TMDO_Type_Caster::to_db( null, $field );
|
||||||
|
$absent .= $wpdb->prepare(
|
||||||
|
" AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )",
|
||||||
|
$default,
|
||||||
|
$default,
|
||||||
|
$default
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every flat row counts, including rows of other post types or deleted
|
||||||
|
// entities; only sample mode narrows the rows.
|
||||||
|
$extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) );
|
||||||
|
|
||||||
|
$counts = array(
|
||||||
|
'miss' => count( $miss ),
|
||||||
|
'diff' => count( $diff ),
|
||||||
|
'extra' => count( $extra ),
|
||||||
|
);
|
||||||
|
$result['keys'][ $key ] = $counts;
|
||||||
|
$result['miss'] += $counts['miss'];
|
||||||
|
$result['diff'] += $counts['diff'];
|
||||||
|
$result['extra'] += $counts['extra'];
|
||||||
|
$result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra );
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['ids'] = array_values( array_unique( $result['ids'] ) );
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat rows whose entity no longer exists or, for post groups, whose post
|
||||||
|
* is outside the group's post types.
|
||||||
|
*
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Group flat table.
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$id = $adapter->get_entity_id_column();
|
||||||
|
$primary = $adapter->get_primary_table();
|
||||||
|
$pk = $adapter->get_primary_id_column();
|
||||||
|
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
|
||||||
|
$type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : '';
|
||||||
|
|
||||||
|
return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear a row's columns: native holds none of the group's keys. The row is
|
||||||
|
* kept (NULL-only rows are not deleted, as the router may join on them);
|
||||||
|
* required columns go back to their default.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Group flat table.
|
||||||
|
* @param string $id Entity id column.
|
||||||
|
* @param int $eid Entity id.
|
||||||
|
*/
|
||||||
|
private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$data = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||||
|
$data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field );
|
||||||
|
}
|
||||||
|
$wpdb->update( $table, $data, array( $id => $eid ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL restricting the checked entities: the group's post types, and in
|
||||||
|
* sample mode a random set of entity ids. `{id}` stands for the id column.
|
||||||
|
*
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param int $sample 0, or the number of random entities.
|
||||||
|
*/
|
||||||
|
private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
|
||||||
|
$type_sql = $post_types
|
||||||
|
? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')"
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if ( $sample <= 0 ) {
|
||||||
|
return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$primary = $adapter->get_primary_table();
|
||||||
|
$pk = $adapter->get_primary_id_column();
|
||||||
|
$ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample );
|
||||||
|
return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a flat value is what native meta would store.
|
||||||
|
*
|
||||||
|
* @param string $native Raw native meta_value.
|
||||||
|
* @param string $flat Flat column value.
|
||||||
|
* @param array<string, mixed> $field Registered field definition.
|
||||||
|
*/
|
||||||
|
private static function same( string $native, string $flat, array $field ): bool {
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
$value = TMDO_Safe_Unserialize::run( $native );
|
||||||
|
if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) {
|
||||||
|
return false; // Belongs in flat as NULL, yet this row holds a value.
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = TMDO_Type_Caster::to_db( $value, $field );
|
||||||
|
if ( null === $expected ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( 'json' === $type ) {
|
||||||
|
// Key order and int-vs-float formatting are not drift.
|
||||||
|
return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
|
||||||
|
}
|
||||||
|
if ( is_numeric( $expected ) && is_numeric( $flat ) ) {
|
||||||
|
return abs( (float) $expected - (float) $flat ) < 0.000001;
|
||||||
|
}
|
||||||
|
return (string) $expected === $flat;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ class TMDO_Health_Cron {
|
|||||||
}
|
}
|
||||||
} catch ( Throwable $e ) {
|
} catch ( Throwable $e ) {
|
||||||
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
|
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
|
||||||
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
TMDO_Logger::error( 'health_cron', 'module_detector', $e->getMessage() );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
|
|
||||||
/**
|
/**
|
||||||
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
||||||
*
|
*
|
||||||
* Registers 7 tests under Tools → Site Health → Status:
|
* Registers 10 tests under Tools → Site Health → Status:
|
||||||
* 1. wpdo_schema_drift (critical)
|
* 1. wpdo_schema_drift (critical)
|
||||||
* 2. wpdo_error_budget (recommended)
|
* 2. wpdo_error_budget (recommended)
|
||||||
* 3. wpdo_hook_conflicts (recommended)
|
* 3. wpdo_hook_conflicts (recommended)
|
||||||
@@ -11,12 +10,19 @@
|
|||||||
* 5. wpdo_postmeta_explosion (recommended)
|
* 5. wpdo_postmeta_explosion (recommended)
|
||||||
* 6. wpdo_orphan_zone_rows (recommended)
|
* 6. wpdo_orphan_zone_rows (recommended)
|
||||||
* 7. wpdo_missing_snapshot (critical)
|
* 7. wpdo_missing_snapshot (critical)
|
||||||
|
* 8. wpdo_entity_group_collisions (recommended)
|
||||||
|
* 9. wpdo_orphaned_entity_groups (recommended)
|
||||||
|
* 10. wpdo_missing_integration_addons (recommended)
|
||||||
*
|
*
|
||||||
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
||||||
*
|
*
|
||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -31,13 +37,16 @@ class TMDO_Site_Health {
|
|||||||
|
|
||||||
/** Test name → callable suffix mapping. */
|
/** Test name → callable suffix mapping. */
|
||||||
private const TESTS = array(
|
private const TESTS = array(
|
||||||
'wpdo_schema_drift' => 'check_schema_drift',
|
'wpdo_schema_drift' => 'check_schema_drift',
|
||||||
'wpdo_error_budget' => 'check_error_budget',
|
'wpdo_error_budget' => 'check_error_budget',
|
||||||
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
||||||
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
||||||
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
||||||
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
||||||
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
||||||
|
'wpdo_entity_group_collisions' => 'check_entity_group_collisions',
|
||||||
|
'wpdo_orphaned_entity_groups' => 'check_orphaned_entity_groups',
|
||||||
|
'wpdo_missing_integration_addons' => 'check_missing_integration_addons',
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +59,7 @@ class TMDO_Site_Health {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the 7 tests with WP Site Health.
|
* Register the 10 tests with WP Site Health.
|
||||||
*
|
*
|
||||||
* @param array $tests Existing tests.
|
* @param array $tests Existing tests.
|
||||||
* @return array
|
* @return array
|
||||||
@@ -73,13 +82,16 @@ class TMDO_Site_Health {
|
|||||||
*/
|
*/
|
||||||
private static function label_for( string $key ): string {
|
private static function label_for( string $key ): string {
|
||||||
$map = array(
|
$map = array(
|
||||||
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
|
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
|
||||||
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
|
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
|
||||||
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
|
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
|
||||||
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
|
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
|
||||||
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
|
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
|
||||||
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
|
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
|
||||||
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
|
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
|
||||||
|
'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ),
|
||||||
|
'wpdo_orphaned_entity_groups' => __( 'WPDO orphaned entity groups', '2meet-data-optimizer' ),
|
||||||
|
'wpdo_missing_integration_addons' => __( 'WPDO integrations without their AddOn', '2meet-data-optimizer' ),
|
||||||
);
|
);
|
||||||
return $map[ $key ] ?? $key;
|
return $map[ $key ] ?? $key;
|
||||||
}
|
}
|
||||||
@@ -422,6 +434,118 @@ class TMDO_Site_Health {
|
|||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Test 8: Entity group name collisions ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report entity groups registered twice with different definitions.
|
||||||
|
*
|
||||||
|
* Not cached: the collision list is in-memory state rebuilt on every
|
||||||
|
* request, so reading it is free and a transient would only go stale.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Site Health result.
|
||||||
|
*/
|
||||||
|
public static function check_entity_group_collisions(): array {
|
||||||
|
$collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array();
|
||||||
|
if ( empty( $collisions ) ) {
|
||||||
|
return self::wrap( 'wpdo_entity_group_collisions', self::pass( __( 'No entity group name collisions.', '2meet-data-optimizer' ) ) );
|
||||||
|
}
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $collisions as $c ) {
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'%s/%s (+%s −%s ~%s)',
|
||||||
|
$c['entity_type'],
|
||||||
|
$c['group'],
|
||||||
|
implode( ',', $c['added'] ) ?: '-',
|
||||||
|
implode( ',', $c['removed'] ) ?: '-',
|
||||||
|
implode( ',', $c['changed'] ) ?: '-'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return self::wrap(
|
||||||
|
'wpdo_entity_group_collisions',
|
||||||
|
self::fail(
|
||||||
|
__( 'WPDO entity group name collisions', '2meet-data-optimizer' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: list of entity/group names with field differences */
|
||||||
|
__( 'Another plugin registered these groups with a different field list; its definition was ignored and the first one kept: %s', '2meet-data-optimizer' ),
|
||||||
|
implode( '; ', $lines )
|
||||||
|
),
|
||||||
|
'recommended'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 9: Orphaned entity groups ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report flat-table groups kept alive from wpdo_registry_meta because no
|
||||||
|
* active plugin registers them any more.
|
||||||
|
*
|
||||||
|
* Not cached for the same reason as check_entity_group_collisions().
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Site Health result.
|
||||||
|
*/
|
||||||
|
public static function check_orphaned_entity_groups(): array {
|
||||||
|
$restored = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_restored_groups() : array();
|
||||||
|
if ( empty( $restored ) ) {
|
||||||
|
return self::wrap( 'wpdo_orphaned_entity_groups', self::pass( __( 'Every flat-table group is registered by an active plugin.', '2meet-data-optimizer' ) ) );
|
||||||
|
}
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $restored as $r ) {
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'%s/%s (%d fields%s%s)',
|
||||||
|
$r['entity_type'],
|
||||||
|
$r['group'],
|
||||||
|
$r['fields'],
|
||||||
|
$r['skipped'] ? ', skipped: ' . implode( ',', $r['skipped'] ) : '',
|
||||||
|
$r['native'] ? ', left on native meta (newer data there): ' . implode( ',', $r['native'] ) : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return self::wrap(
|
||||||
|
'wpdo_orphaned_entity_groups',
|
||||||
|
self::fail(
|
||||||
|
__( 'WPDO entity groups restored from stored definitions', '2meet-data-optimizer' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: list of entity/group names */
|
||||||
|
__( 'No active plugin registers these groups, so their stored definitions were re-applied to keep the flat-table data readable. Reactivate the owning plugin, or write the data back to native meta before removing it: %s', '2meet-data-optimizer' ),
|
||||||
|
implode( '; ', $lines )
|
||||||
|
),
|
||||||
|
'recommended'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 10: Integrations without their AddOn ──────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report HivePress / WooCommerce running while their AddOn is inactive.
|
||||||
|
*
|
||||||
|
* Not cached: two class_exists() probes.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Site Health result.
|
||||||
|
*/
|
||||||
|
public static function check_missing_integration_addons(): array {
|
||||||
|
$missing = class_exists( 'TMDO_Compatibility' ) ? TMDO_Compatibility::missing_integration_addons() : array();
|
||||||
|
if ( empty( $missing ) ) {
|
||||||
|
return self::wrap( 'wpdo_missing_integration_addons', self::pass( __( 'Every detected integration has its AddOn active.', '2meet-data-optimizer' ) ) );
|
||||||
|
}
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $missing as $plugin => $addon ) {
|
||||||
|
$lines[] = "{$plugin} → {$addon}";
|
||||||
|
}
|
||||||
|
return self::wrap(
|
||||||
|
'wpdo_missing_integration_addons',
|
||||||
|
self::fail(
|
||||||
|
__( 'WPDO integrations running without their AddOn', '2meet-data-optimizer' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: "Plugin → AddOn" pairs */
|
||||||
|
__( 'These plugins are active but their AddOn is not, so their fields are not optimised: %s. Activate the AddOn.', '2meet-data-optimizer' ),
|
||||||
|
implode( '; ', $lines )
|
||||||
|
),
|
||||||
|
'recommended'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a raw check result with required Site Health envelope fields.
|
* Wrap a raw check result with required Site Health envelope fields.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
* @since 2.6.2
|
* @since 2.6.2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* @since 1.3.0
|
* @since 1.3.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* 判斷條件(皆滿足才觸發):
|
* 判斷條件(皆滿足才觸發):
|
||||||
* 1. 當前 mode = shadow_read
|
* 1. 當前 mode = shadow_read
|
||||||
* 2. 進入 shadow_read 距今 ≥ `min_days`
|
* 2. 進入 shadow_read 距今 ≥ `min_days`
|
||||||
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
|
* 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移
|
||||||
|
* (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級)
|
||||||
*
|
*
|
||||||
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
|
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
|
||||||
* 若 diff 出現,規則永不觸發;人工介入即可。
|
* 若 diff 出現,規則永不觸發;人工介入即可。
|
||||||
@@ -20,6 +21,8 @@
|
|||||||
* @since 1.5.0
|
* @since 1.5.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +36,9 @@ final class TMDO_Auto_Promoter {
|
|||||||
|
|
||||||
public const CRON_HOOK = 'wpdo_auto_promote_check';
|
public const CRON_HOOK = 'wpdo_auto_promote_check';
|
||||||
|
|
||||||
|
/** A drift check older than this (seconds) does not count for promotion. */
|
||||||
|
public const DRIFT_MAX_AGE = 2 * DAY_IN_SECONDS;
|
||||||
|
|
||||||
public static function init(): void {
|
public static function init(): void {
|
||||||
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
|
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
|
||||||
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
|
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
|
||||||
@@ -80,7 +86,7 @@ final class TMDO_Auto_Promoter {
|
|||||||
/**
|
/**
|
||||||
* 評估單一 entity 是否可升級。
|
* 評估單一 entity 是否可升級。
|
||||||
*
|
*
|
||||||
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed'
|
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'drift_not_clean' | 'vetoed' | 'error'
|
||||||
*/
|
*/
|
||||||
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
|
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
|
||||||
$current = TMDO_Mode_Manager::get( $type );
|
$current = TMDO_Mode_Manager::get( $type );
|
||||||
@@ -93,14 +99,13 @@ final class TMDO_Auto_Promoter {
|
|||||||
return 'not_ready';
|
return 'not_ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
|
if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) {
|
||||||
$count = (int) ( $diff_counts[ $type ] ?? 0 );
|
return 'drift_not_clean';
|
||||||
if ( $count > 0 ) {
|
|
||||||
return 'has_diff';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
|
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為
|
||||||
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
|
// shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。
|
||||||
|
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 );
|
||||||
if ( ! $should ) {
|
if ( ! $should ) {
|
||||||
return 'vetoed';
|
return 'vetoed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
* @since 1.1.2
|
* @since 1.1.2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* @since 2.6.6
|
* @since 2.6.6
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions -- inherits engine coding standard.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions -- inherits engine coding standard.
|
||||||
|
|
||||||
defined( 'ABSPATH' ) || exit;
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
@@ -142,13 +144,14 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
}
|
}
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
++$stats['errors'];
|
++$stats['errors'];
|
||||||
error_log(
|
TMDO_Logger::error(
|
||||||
sprintf(
|
'migration_engine',
|
||||||
'[UAE Migration] Error %s/%s ID=%d: %s',
|
'migrate_group',
|
||||||
$entity_type,
|
$e->getMessage(),
|
||||||
$group_name,
|
array(
|
||||||
$entity_id,
|
'entity_type' => $entity_type,
|
||||||
$e->getMessage()
|
'group' => $group_name,
|
||||||
|
'entity_id' => $entity_id,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,6 +176,9 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
// 標記完成
|
// 標記完成
|
||||||
if ( ! $options['dry_run'] ) {
|
if ( ! $options['dry_run'] ) {
|
||||||
self::mark_migration_completed( $entity_type, $group_name );
|
self::mark_migration_completed( $entity_type, $group_name );
|
||||||
|
if ( $stats['migrated'] > 0 ) {
|
||||||
|
self::counts_changed( $entity_type );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
|
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
|
||||||
@@ -180,6 +186,45 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows rebuilt outside the Hook Bus (backfill, drift-check --fix): routed
|
||||||
|
* post counts cached for the old rows are stale (2.4 PR-129).
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function counts_changed( string $entity_type ): void {
|
||||||
|
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Query_Router' ) ) {
|
||||||
|
TMDO_Post_Query_Router::bump_count_version();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild one entity's row of a group from native meta.
|
||||||
|
*
|
||||||
|
* @return bool False when native holds none of the group's keys (nothing written).
|
||||||
|
*/
|
||||||
|
public static function migrate_entity( string $entity_type, string $group_name, int $entity_id ): bool {
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||||
|
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||||
|
if ( ! $adapter || empty( $group_fields ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$migrated = self::migrate_single_entity(
|
||||||
|
$entity_type,
|
||||||
|
$group_name,
|
||||||
|
$entity_id,
|
||||||
|
array_column( $group_fields, 'key' ),
|
||||||
|
array_column( $group_fields, null, 'key' ),
|
||||||
|
$adapter,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if ( $migrated ) {
|
||||||
|
self::counts_changed( $entity_type );
|
||||||
|
}
|
||||||
|
return $migrated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 遷移單一 entity 的所有 meta
|
* 遷移單一 entity 的所有 meta
|
||||||
*/
|
*/
|
||||||
@@ -241,6 +286,27 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// REPLACE rewrites the whole row: a column left out falls back to its DDL
|
||||||
|
// default (e.g. session_count DEFAULT 0), inventing a value the object never
|
||||||
|
// had. Keys without a native row are written as NULL; required columns are
|
||||||
|
// NOT NULL, so they keep their default (treated as "absent" by the diff logger).
|
||||||
|
foreach ( $field_map as $key => $field_def ) {
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||||
|
if ( ! array_key_exists( $col, $data ) && empty( $field_def['required'] ) ) {
|
||||||
|
$data[ $col ] = null;
|
||||||
|
$formats[] = '%s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REPLACE drops columns it is not given: keep a post row's status/date
|
||||||
|
// copies, or one NULL row turns the copy route off for the group (2.4 PR-127).
|
||||||
|
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Status_Sync' ) ) {
|
||||||
|
foreach ( TMDO_Post_Status_Sync::insert_columns( $entity_id, $group_name ) as $col => $value ) {
|
||||||
|
$data[ $col ] = $value;
|
||||||
|
$formats[] = '%s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ( $dry_run ) {
|
if ( $dry_run ) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -347,13 +413,25 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
++$batch_migrated;
|
++$batch_migrated;
|
||||||
}
|
}
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) );
|
TMDO_Logger::error(
|
||||||
|
'migration_engine',
|
||||||
|
'migrate_group_batch',
|
||||||
|
$e->getMessage(),
|
||||||
|
array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'entity_id' => $entity_id,
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
$last_id = $entity_id;
|
$last_id = $entity_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
$total = $prev_total + $batch_migrated;
|
$total = $prev_total + $batch_migrated;
|
||||||
$done = count( $entity_ids ) < $batch_size;
|
$done = count( $entity_ids ) < $batch_size;
|
||||||
|
if ( $batch_migrated > 0 ) {
|
||||||
|
self::counts_changed( $entity_type );
|
||||||
|
}
|
||||||
|
|
||||||
if ( $done ) {
|
if ( $done ) {
|
||||||
self::mark_migration_completed( $entity_type, $group_name );
|
self::mark_migration_completed( $entity_type, $group_name );
|
||||||
@@ -372,6 +450,23 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered groups of an entity type that can be backfilled from native
|
||||||
|
* meta. Orphan groups restored from wpdo_registry_meta are left out: their
|
||||||
|
* values live only in the flat table, a backfill would wipe them.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function backfillable_groups( string $entity_type ): array {
|
||||||
|
$orphans = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_restored_groups() as $restored ) {
|
||||||
|
if ( $entity_type === $restored['entity_type'] ) {
|
||||||
|
$orphans[] = $restored['group'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values( array_diff( TMDO_Entity_Registry::get_groups_for_type( $entity_type ), $orphans ) );
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
// 斷點管理
|
// 斷點管理
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +31,24 @@ final class TMDO_Entity_Registry {
|
|||||||
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
|
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
|
||||||
private static array $pending_schemas = array();
|
private static array $pending_schemas = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array<string, mixed>>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/
|
||||||
|
private static array $unscoped_index = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array<string, array<string, mixed>>>> 實體類型 → post_type → meta_key → 欄位定義 */
|
||||||
|
private static array $scoped_index = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
|
||||||
|
private static array $scopes = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
|
||||||
|
private static array $indexes = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
|
||||||
|
private static array $collisions = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */
|
||||||
|
private static array $restored = array();
|
||||||
|
|
||||||
/** 合法的欄位型別 */
|
/** 合法的欄位型別 */
|
||||||
public const VALID_TYPES = array(
|
public const VALID_TYPES = array(
|
||||||
'text',
|
'text',
|
||||||
@@ -47,11 +67,20 @@ final class TMDO_Entity_Registry {
|
|||||||
/** 合法的實體類型 */
|
/** 合法的實體類型 */
|
||||||
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
|
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
|
||||||
|
|
||||||
|
/** Option:'1' 時 get_field() 依傳入的 post_type 解析(見 get_field())。 */
|
||||||
|
public const SCOPED_ROUTING_OPTION = 'tmdo_post_scoped_routing';
|
||||||
|
|
||||||
public static function init(): void {
|
public static function init(): void {
|
||||||
self::$adapters = array();
|
self::$adapters = array();
|
||||||
self::$groups = array();
|
self::$groups = array();
|
||||||
self::$field_index = array();
|
self::$field_index = array();
|
||||||
|
self::$unscoped_index = array();
|
||||||
|
self::$scoped_index = array();
|
||||||
|
self::$scopes = array();
|
||||||
|
self::$indexes = array();
|
||||||
self::$pending_schemas = array();
|
self::$pending_schemas = array();
|
||||||
|
self::$collisions = array();
|
||||||
|
self::$restored = array();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -90,10 +119,17 @@ final class TMDO_Entity_Registry {
|
|||||||
* - unique (bool) 唯一索引
|
* - unique (bool) 唯一索引
|
||||||
* - options (array) enum 選項
|
* - options (array) enum 選項
|
||||||
* - label (string) 顯示名稱
|
* - label (string) 顯示名稱
|
||||||
|
* @param array<string, mixed> $args 選填:post_types (string[]) — 群組只套用於這些 post_type。
|
||||||
|
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
|
||||||
|
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
|
||||||
|
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
|
||||||
|
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
|
||||||
|
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
|
||||||
|
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
|
||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function register_group( string $entity_type, string $group_name, array $fields ): bool {
|
public static function register_group( string $entity_type, string $group_name, array $fields, array $args = array() ): bool {
|
||||||
|
|
||||||
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
|
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
|
||||||
return false;
|
return false;
|
||||||
@@ -103,12 +139,57 @@ final class TMDO_Entity_Registry {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防重複登錄
|
$normalized = self::normalize_fields( $entity_type, $group_name, $fields );
|
||||||
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
|
||||||
|
if ( empty( $normalized ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 驗證並正規化欄位定義
|
// 防重複登錄:相同定義(init:1 重新觸發)靜默略過;定義不同則記錄衝突,保留先註冊者。
|
||||||
|
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
||||||
|
if ( self::$groups[ $entity_type ][ $group_name ] !== $normalized ) {
|
||||||
|
self::record_collision( $entity_type, $group_name, self::$groups[ $entity_type ][ $group_name ], $normalized );
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建立索引以供快速查詢
|
||||||
|
$post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) );
|
||||||
|
if ( $post_types ) {
|
||||||
|
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
|
||||||
|
}
|
||||||
|
$keys = array_column( $normalized, 'key' );
|
||||||
|
// A sort index may end with the entity ID (flat-first tie-break, 2.4 PR-118).
|
||||||
|
$id_col = (string) self::$adapters[ $entity_type ]->get_entity_id_column();
|
||||||
|
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
|
||||||
|
$columns = array_values( array_map( 'strval', (array) $columns ) );
|
||||||
|
$fields = '' !== $id_col && end( $columns ) === $id_col ? array_slice( $columns, 0, -1 ) : $columns;
|
||||||
|
if ( $fields && ! array_diff( $fields, $keys ) ) {
|
||||||
|
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ( $normalized as $normalized_field ) {
|
||||||
|
self::index_field( $entity_type, $normalized_field, $post_types );
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$groups[ $entity_type ][ $group_name ] = $normalized;
|
||||||
|
|
||||||
|
// 加入待建表佇列
|
||||||
|
self::$pending_schemas[] = array(
|
||||||
|
'type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'fields' => $normalized,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。
|
||||||
|
* @param array<int, array<string, mixed>> $fields
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array {
|
||||||
$normalized = array();
|
$normalized = array();
|
||||||
|
|
||||||
foreach ( $fields as $field ) {
|
foreach ( $fields as $field ) {
|
||||||
@@ -139,27 +220,148 @@ final class TMDO_Entity_Registry {
|
|||||||
$normalized_field['entity_type'] = $entity_type;
|
$normalized_field['entity_type'] = $entity_type;
|
||||||
|
|
||||||
$normalized[] = $normalized_field;
|
$normalized[] = $normalized_field;
|
||||||
|
|
||||||
// 建立索引以供快速查詢
|
|
||||||
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( empty( $normalized ) ) {
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 還原孤兒群組:依 wpdo_registry_meta 保存的定義重新登錄,只供讀寫路由用。
|
||||||
|
*
|
||||||
|
* 與 register_group() 的差異:不進 pending_schemas(絕不觸發 DDL),且已被
|
||||||
|
* 其他存活群組管理的 key 一律略過,不覆蓋。$native_keys 列出原生 meta 仍有
|
||||||
|
* 資料的 key:那些值比扁平表新(孤兒期間寫入),必須留在原生 meta。
|
||||||
|
*
|
||||||
|
* @param string[] $native_keys 原生 meta 仍有資料、不可還原的 key。
|
||||||
|
* @return bool 有登錄任何欄位時為 true
|
||||||
|
* @param array<int, array<string, mixed>> $fields
|
||||||
|
*/
|
||||||
|
public static function restore_group( string $entity_type, string $group_name, array $fields, array $native_keys = array() ): bool {
|
||||||
|
if ( ! isset( self::$adapters[ $entity_type ] ) || isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$groups[ $entity_type ][ $group_name ] = $normalized;
|
$kept = array();
|
||||||
|
$skipped = array();
|
||||||
|
$native = array();
|
||||||
|
foreach ( self::normalize_fields( $entity_type, $group_name, $fields ) as $field ) {
|
||||||
|
if ( isset( self::$field_index[ $entity_type ][ $field['key'] ] ) ) {
|
||||||
|
$skipped[] = (string) $field['key'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( in_array( $field['key'], $native_keys, true ) ) {
|
||||||
|
$native[] = (string) $field['key'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kept[] = $field;
|
||||||
|
}
|
||||||
|
|
||||||
// 加入待建表佇列
|
$id = $entity_type . '/' . $group_name;
|
||||||
self::$pending_schemas[] = array(
|
if ( empty( $kept ) ) {
|
||||||
'type' => $entity_type,
|
// 整組都留在原生 meta 時仍要回報:扁平表的舊資料需要人工處理。
|
||||||
'group' => $group_name,
|
if ( $native ) {
|
||||||
'fields' => $normalized,
|
self::$restored[ $id ] = array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'fields' => 0,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'native' => $native,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $kept as $field ) {
|
||||||
|
self::index_field( $entity_type, $field, array() );
|
||||||
|
}
|
||||||
|
self::$groups[ $entity_type ][ $group_name ] = $kept;
|
||||||
|
|
||||||
|
self::$restored[ $id ] = array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'fields' => count( $kept ),
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'native' => $native,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把欄位加入索引。field_index 保留舊語意(整個實體共用、後註冊者覆蓋),
|
||||||
|
* 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。
|
||||||
|
*
|
||||||
|
* @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。
|
||||||
|
* @param array<string, mixed> $field
|
||||||
|
*/
|
||||||
|
private static function index_field( string $entity_type, array $field, array $post_types ): void {
|
||||||
|
self::$field_index[ $entity_type ][ $field['key'] ] = $field;
|
||||||
|
|
||||||
|
if ( empty( $post_types ) ) {
|
||||||
|
self::$unscoped_index[ $entity_type ][ $field['key'] ] = $field;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ( $post_types as $post_type ) {
|
||||||
|
self::$scoped_index[ $entity_type ][ $post_type ][ $field['key'] ] = $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本請求從 wpdo_registry_meta 還原的孤兒群組。
|
||||||
|
*
|
||||||
|
* @return array<int, array{entity_type:string, group:string, fields:int, skipped:string[], native:string[]}>
|
||||||
|
*/
|
||||||
|
public static function get_restored_groups(): array {
|
||||||
|
return array_values( self::$restored );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。
|
||||||
|
*
|
||||||
|
* 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。
|
||||||
|
* @param array<int, array<string, mixed>> $existing
|
||||||
|
* @param array<int, array<string, mixed>> $incoming
|
||||||
|
*/
|
||||||
|
private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void {
|
||||||
|
$id = $entity_type . '/' . $group_name;
|
||||||
|
if ( isset( self::$collisions[ $id ] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$old = array_column( $existing, null, 'key' );
|
||||||
|
$new = array_column( $incoming, null, 'key' );
|
||||||
|
|
||||||
|
$changed = array();
|
||||||
|
foreach ( array_intersect_key( $new, $old ) as $key => $field ) {
|
||||||
|
if ( $field !== $old[ $key ] ) {
|
||||||
|
$changed[] = (string) $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$collisions[ $id ] = array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'added' => array_map( 'strval', array_keys( array_diff_key( $new, $old ) ) ),
|
||||||
|
'removed' => array_map( 'strval', array_keys( array_diff_key( $old, $new ) ) ),
|
||||||
|
'changed' => $changed,
|
||||||
|
);
|
||||||
|
|
||||||
|
$throttle = 'tmdo_group_collision_' . md5( (string) wp_json_encode( self::$collisions[ $id ] ) );
|
||||||
|
if ( class_exists( 'TMDO_Logger' ) && false === get_transient( $throttle ) ) {
|
||||||
|
set_transient( $throttle, 1, DAY_IN_SECONDS );
|
||||||
|
TMDO_Logger::warning( 'entity_group_collision', self::$collisions[ $id ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本請求偵測到的同名群組衝突(第二份定義已被捨棄)。
|
||||||
|
*
|
||||||
|
* @return array<int, array{entity_type:string, group:string, added:string[], removed:string[], changed:string[]}>
|
||||||
|
*/
|
||||||
|
public static function get_collisions(): array {
|
||||||
|
return array_values( self::$collisions );
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
// 查詢 API
|
// 查詢 API
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -167,10 +369,21 @@ final class TMDO_Entity_Registry {
|
|||||||
/**
|
/**
|
||||||
* 依 meta_key 查詢欄位定義
|
* 依 meta_key 查詢欄位定義
|
||||||
*
|
*
|
||||||
|
* 傳入 $post_type 且 SCOPED_ROUTING_OPTION 開啟時:先找限定該 post_type 的群組,
|
||||||
|
* 再找不限定的群組,都沒有則為 null。否則沿用舊語意(整個實體共用索引,
|
||||||
|
* 共用 key 由最後註冊的群組擁有)。舊資料可能已依舊語意散落在別的群組表,
|
||||||
|
* 所以既有站要等資料對齊後才開啟 option。
|
||||||
|
*
|
||||||
|
* @param string $post_type 選填:post 的 post_type。
|
||||||
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
|
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
|
||||||
*/
|
*/
|
||||||
public static function get_field( string $entity_type, string $meta_key ): ?array {
|
public static function get_field( string $entity_type, string $meta_key, string $post_type = '' ): ?array {
|
||||||
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
|
if ( '' === $post_type || '1' !== (string) get_option( self::SCOPED_ROUTING_OPTION, '0' ) ) {
|
||||||
|
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
|
||||||
|
}
|
||||||
|
return self::$scoped_index[ $entity_type ][ $post_type ][ $meta_key ]
|
||||||
|
?? self::$unscoped_index[ $entity_type ][ $meta_key ]
|
||||||
|
?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +395,24 @@ final class TMDO_Entity_Registry {
|
|||||||
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群組宣告的複合索引。
|
||||||
|
*
|
||||||
|
* @return array<string, string[]> 索引名 → 欄位 key
|
||||||
|
*/
|
||||||
|
public static function get_group_indexes( string $entity_type, string $group_name ): array {
|
||||||
|
return self::$indexes[ $entity_type ][ $group_name ] ?? array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群組限定的 post_type(未限定時為空陣列)。
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function get_group_post_types( string $entity_type, string $group_name ): array {
|
||||||
|
return self::$scopes[ $entity_type ][ $group_name ] ?? array();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得特定群組的完整欄位定義
|
* 取得特定群組的完整欄位定義
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +44,15 @@ final class TMDO_Hook_Bus {
|
|||||||
// ── 刪除攔截 ──────────────────────────────────────────
|
// ── 刪除攔截 ──────────────────────────────────────────
|
||||||
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
|
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
|
||||||
|
|
||||||
|
// ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ──
|
||||||
|
// Native is the source of truth in these modes; flat follows what
|
||||||
|
// native actually became (WP may skip or refuse a write).
|
||||||
|
add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 );
|
||||||
|
add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 );
|
||||||
|
add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 );
|
||||||
|
add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 );
|
||||||
|
add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 );
|
||||||
|
|
||||||
// ── 實體刪除時自動清理 ───────────────────────────────
|
// ── 實體刪除時自動清理 ───────────────────────────────
|
||||||
add_action(
|
add_action(
|
||||||
$adapter->get_delete_hook(),
|
$adapter->get_delete_hook(),
|
||||||
@@ -68,26 +79,26 @@ final class TMDO_Hook_Bus {
|
|||||||
*/
|
*/
|
||||||
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||||||
|
|
||||||
// 已在短路中 → 避免遞迴
|
|
||||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
|
||||||
return $check;
|
|
||||||
}
|
|
||||||
|
|
||||||
$type = self::resolve_type_from_current_filter();
|
$type = self::resolve_type_from_current_filter();
|
||||||
if ( ! $type ) {
|
if ( ! $type ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
// 已在短路中 → 避免遞迴
|
||||||
|
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
|
||||||
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check; // 非管理欄位,放行
|
return $check; // 非管理欄位,放行
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode-aware dispatch ──────────────────────────────
|
// ── Mode-aware dispatch ──────────────────────────────
|
||||||
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
||||||
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
// dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat
|
||||||
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
// shadow_read: 同 dual_write
|
||||||
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
||||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
||||||
return $check; // disabled
|
return $check; // disabled
|
||||||
}
|
}
|
||||||
@@ -103,6 +114,13 @@ final class TMDO_Hook_Bus {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dual_write / shadow_read: native first. Writing flat here, before WP
|
||||||
|
// decides whether native changes at all (same value, non-matching
|
||||||
|
// prev_value), let flat drift; sync_from_native() runs after the write.
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
|
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
|
||||||
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
|
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
|
||||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
|
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
|
||||||
@@ -111,27 +129,22 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
|
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
|
||||||
|
|
||||||
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
|
|
||||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
|
||||||
return null; // dual_write / shadow_read
|
|
||||||
}
|
|
||||||
|
|
||||||
return $flat_result; // aeav_only
|
return $flat_result; // aeav_only
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||||||
|
|
||||||
// 已在短路中 → 避免遞迴
|
|
||||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
|
||||||
return $check;
|
|
||||||
}
|
|
||||||
|
|
||||||
$type = self::resolve_type_from_current_filter();
|
$type = self::resolve_type_from_current_filter();
|
||||||
if ( ! $type ) {
|
if ( ! $type ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
// 已在短路中 → 避免遞迴
|
||||||
|
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
|
||||||
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
@@ -148,6 +161,12 @@ final class TMDO_Hook_Bus {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dual_write / shadow_read: native first (add($unique) may be refused);
|
||||||
|
// sync_from_native() runs on added_{type}_meta.
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
|
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
|
||||||
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
|
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
|
||||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
|
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
|
||||||
@@ -156,11 +175,228 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
|
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
|
||||||
|
|
||||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
return $flat_result;
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
// dual_write / shadow_read:原生寫入後同步 flat
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity IDs whose native rows a pending delete removes, keyed "type:key".
|
||||||
|
*
|
||||||
|
* @var array<string, int[]>
|
||||||
|
*/
|
||||||
|
private static array $pending_deletes = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID.
|
||||||
|
*
|
||||||
|
* @var array<string, array{type:string, id:int, key:string}>
|
||||||
|
*/
|
||||||
|
private static array $pending_renames = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the entity's current native value for a key into its flat column
|
||||||
|
* (NULL when native has no row). Runs in dual_write / shadow_read only,
|
||||||
|
* after WP wrote native, so flat always ends up equal to native — also
|
||||||
|
* when WP skipped or refused the write, and when an earlier direct SQL
|
||||||
|
* write had left flat stale. A NULL never creates a row: deleting an
|
||||||
|
* entity drops its flat row before WP deletes the meta rows one by one.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Value the caller wrote (for listeners).
|
||||||
|
* @param string $op 'add' | 'update' | 'delete'.
|
||||||
|
* @param bool $delete_all Whether the delete was a delete_all.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void {
|
||||||
|
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$field_def = self::field_for( $type, $object_id, $meta_key );
|
||||||
|
if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $flat_result;
|
$before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op );
|
||||||
|
$native = self::native_first_value( $type, $object_id, $meta_key );
|
||||||
|
$result = null === $native
|
||||||
|
? self::clear_column( $type, $object_id, $meta_key, $field_def )
|
||||||
|
: self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def );
|
||||||
|
|
||||||
|
if ( 'delete' === $op ) {
|
||||||
|
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before );
|
||||||
|
} else {
|
||||||
|
do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `added_{type}_meta` action.
|
||||||
|
*
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( $type ) {
|
||||||
|
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `updated_{type}_meta` action; also re-syncs the old key of a row renamed
|
||||||
|
* through update_metadata_by_mid.
|
||||||
|
*
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' );
|
||||||
|
|
||||||
|
$rename_key = $type . ':' . (int) $meta_id;
|
||||||
|
if ( isset( self::$pending_renames[ $rename_key ] ) ) {
|
||||||
|
$old = self::$pending_renames[ $rename_key ];
|
||||||
|
unset( self::$pending_renames[ $rename_key ] );
|
||||||
|
self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `update_{type}_metadata_by_mid` filter: remember the old key when a row
|
||||||
|
* is renamed, so its flat column can follow. Never changes $check.
|
||||||
|
*
|
||||||
|
* @param mixed $check Short-circuit value.
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @param mixed $meta_value New value.
|
||||||
|
* @param string|false $meta_key New key, or false to keep it.
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type || false === $meta_key ) {
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
$row = self::native_row_by_mid( $type, (int) $meta_id );
|
||||||
|
if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) {
|
||||||
|
self::$pending_renames[ $type . ':' . (int) $meta_id ] = array(
|
||||||
|
'type' => $type,
|
||||||
|
'id' => $row['id'],
|
||||||
|
'key' => $row['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `delete_{type}_meta` action (rows still exist): record which entities a
|
||||||
|
* delete touches — a delete_all passes object ID 0.
|
||||||
|
*
|
||||||
|
* @param int[] $meta_ids Meta IDs to delete.
|
||||||
|
* @param int $object_id Entity ID (0 for delete_all).
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ids = array();
|
||||||
|
foreach ( (array) $meta_ids as $mid ) {
|
||||||
|
$row = self::native_row_by_mid( $type, (int) $mid );
|
||||||
|
if ( $row ) {
|
||||||
|
$ids[ $row['id'] ] = $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `deleted_{type}_meta` action: re-sync every entity the delete touched.
|
||||||
|
*
|
||||||
|
* @param int[] $meta_ids Deleted meta IDs.
|
||||||
|
* @param int $object_id Entity ID (0 for delete_all).
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pending_key = $type . ':' . $meta_key;
|
||||||
|
$ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id );
|
||||||
|
unset( self::$pending_deletes[ $pending_key ] );
|
||||||
|
|
||||||
|
foreach ( $ids as $id ) {
|
||||||
|
if ( $id > 0 ) {
|
||||||
|
self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First native value of a key (the one get_*_meta( $id, $key, true ) returns),
|
||||||
|
* read with SQL so no meta filter runs. Null when the entity has no row.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string {
|
||||||
|
global $wpdb;
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||||
|
// get_results(): get_var() turns an empty string into null.
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
return $rows ? (string) $rows[0]['meta_value'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity ID and key of a native meta row.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @return array{id:int, key:string}|null
|
||||||
|
*/
|
||||||
|
private static function native_row_by_mid( string $type, int $meta_id ): ?array {
|
||||||
|
global $wpdb;
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||||
|
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A );
|
||||||
|
if ( ! $row ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'key' => (string) $row['mkey'],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -289,6 +525,34 @@ final class TMDO_Hook_Bus {
|
|||||||
return 'flat';
|
return 'flat';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a flat column to NULL on the entity's existing row; never insert one.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $entity_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param array<string, mixed> $field_def Registered field.
|
||||||
|
* @return bool|null Null when there is no adapter or table.
|
||||||
|
*/
|
||||||
|
private static function clear_column( string $type, int $entity_id, string $meta_key, array $field_def ): ?bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $type, $field_def['group'] );
|
||||||
|
if ( ! $adapter || ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$result = $wpdb->update( $table, array( $col => null ), array( $adapter->get_entity_id_column() => $entity_id ) );
|
||||||
|
|
||||||
|
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $field_def['group'] );
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
|
return false !== $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 執行 Upsert 操作
|
* 執行 Upsert 操作
|
||||||
*/
|
*/
|
||||||
@@ -320,25 +584,31 @@ final class TMDO_Hook_Bus {
|
|||||||
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
|
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
|
||||||
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
||||||
|
|
||||||
// 鎖防遞迴
|
// 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
|
||||||
$lock_key = $entity_id . ':' . $meta_key;
|
$lock_key = $type . ':' . $entity_id . ':' . $meta_key;
|
||||||
self::$internal_ops[ $lock_key ] = true;
|
self::$internal_ops[ $lock_key ] = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
||||||
|
// post:新列一併帶入 post_status / post_date 副本(只在 insert 時寫入)。
|
||||||
|
$extra = 'post' === $type ? TMDO_Post_Status_Sync::insert_columns( $entity_id, $group ) : array();
|
||||||
$result = TMDO_DB::upsert(
|
$result = TMDO_DB::upsert(
|
||||||
$table,
|
$table,
|
||||||
array(
|
array_merge(
|
||||||
$id_col => $entity_id,
|
array(
|
||||||
$col => $db_value,
|
$id_col => $entity_id,
|
||||||
|
$col => $db_value,
|
||||||
|
),
|
||||||
|
$extra
|
||||||
),
|
),
|
||||||
array( $col ),
|
array( $col ),
|
||||||
$id_col,
|
$id_col,
|
||||||
array( '%d', $format )
|
array_merge( array( '%d', $format ), array_fill( 0, count( $extra ), '%s' ) )
|
||||||
);
|
);
|
||||||
|
|
||||||
// 清除快取
|
// 清除快取
|
||||||
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
// 短路回傳 true(WP 認為寫入成功)
|
// 短路回傳 true(WP 認為寫入成功)
|
||||||
return $result !== false;
|
return $result !== false;
|
||||||
@@ -372,7 +642,7 @@ final class TMDO_Hook_Bus {
|
|||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
@@ -380,12 +650,20 @@ final class TMDO_Hook_Bus {
|
|||||||
// ── Mode-aware dispatch ──────────────────────────────
|
// ── Mode-aware dispatch ──────────────────────────────
|
||||||
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
|
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
|
||||||
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
|
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
|
||||||
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
|
// shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
|
||||||
// aeav_only : 讀取走 UAE flat,不讀 EAV
|
// aeav_only : 讀取走 UAE flat,不讀 EAV
|
||||||
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
|
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
|
||||||
return $check; // disabled / dual_write
|
return $check; // disabled / dual_write
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shadow_read serves native: update_metadata() reads the old value
|
||||||
|
// through this filter, and serving the just-upserted flat value made
|
||||||
|
// WP see old === new and skip the native write (dev36, 2026-09-26).
|
||||||
|
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
||||||
|
self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def );
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
|
||||||
$group = $field_def['group'];
|
$group = $field_def['group'];
|
||||||
$row = self::get_or_load_row( $type, (int) $object_id, $group );
|
$row = self::get_or_load_row( $type, (int) $object_id, $group );
|
||||||
|
|
||||||
@@ -393,22 +671,6 @@ final class TMDO_Hook_Bus {
|
|||||||
$has_value = is_array( $row ) && array_key_exists( $col, $row );
|
$has_value = is_array( $row ) && array_key_exists( $col, $row );
|
||||||
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||||
|
|
||||||
// Shadow-read:與 EAV 比對,記錄差異
|
|
||||||
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
|
||||||
try {
|
|
||||||
TMDO_Shadow_Diff_Logger::compare_and_log(
|
|
||||||
$type,
|
|
||||||
(int) $object_id,
|
|
||||||
$meta_key,
|
|
||||||
$value,
|
|
||||||
$field_def
|
|
||||||
);
|
|
||||||
} catch ( \Throwable $e ) {
|
|
||||||
// 比對失敗不該影響讀取
|
|
||||||
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 值為空 → 回 WP 原生慣例
|
// 值為空 → 回 WP 原生慣例
|
||||||
if ( empty( $row ) || $value === null || $value === '' ) {
|
if ( empty( $row ) || $value === null || $value === '' ) {
|
||||||
return $single ? '' : array();
|
return $single ? '' : array();
|
||||||
@@ -418,6 +680,36 @@ final class TMDO_Hook_Bus {
|
|||||||
return $single ? array( $value ) : array( $value );
|
return $single ? array( $value ) : array( $value );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shadow_read: compare the flat value with native meta for a sample of reads.
|
||||||
|
*
|
||||||
|
* Each compare costs a flat row read and a native SQL read, so only a share
|
||||||
|
* of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1,
|
||||||
|
* default 0.1). The logger rate-limits repeated diffs itself.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param array<string, mixed> $field_def Field definition.
|
||||||
|
*/
|
||||||
|
private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void {
|
||||||
|
$rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key );
|
||||||
|
if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = self::get_or_load_row( $type, $object_id, $field_def['group'] );
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||||
|
$value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
// 比對失敗不該影響讀取
|
||||||
|
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得(或載入)完整列資料,並快取
|
* 取得(或載入)完整列資料,並快取
|
||||||
*/
|
*/
|
||||||
@@ -469,7 +761,7 @@ final class TMDO_Hook_Bus {
|
|||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
@@ -487,6 +779,12 @@ final class TMDO_Hook_Bus {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dual_write / shadow_read: WP deletes native (also delete_all);
|
||||||
|
// deleted_{type}_meta re-syncs every touched entity from native.
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 截取 before value(v1.3.1)
|
// 截取 before value(v1.3.1)
|
||||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
|
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
|
||||||
|
|
||||||
@@ -567,14 +865,11 @@ final class TMDO_Hook_Bus {
|
|||||||
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
||||||
|
|
||||||
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
return $result !== false; // aeav_only
|
||||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result !== false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -606,6 +901,21 @@ final class TMDO_Hook_Bus {
|
|||||||
// 工具方法
|
// 工具方法
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat post writes skip update_metadata()'s own hooks in aeav_only, so
|
||||||
|
* move posts' last_changed here: caches keyed on it (WP query caches,
|
||||||
|
* routed found-rows counts) must not outlive the write.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function touch_posts_last_changed( string $type ): void {
|
||||||
|
if ( 'post' === $type ) {
|
||||||
|
wp_cache_set_posts_last_changed();
|
||||||
|
TMDO_Post_Query_Router::bump_count_version();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 從當前 filter 名稱推斷實體類型
|
* 從當前 filter 名稱推斷實體類型
|
||||||
* 例:update_user_metadata → user
|
* 例:update_user_metadata → user
|
||||||
@@ -620,6 +930,44 @@ final class TMDO_Hook_Bus {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity type from the meta action / by-mid filter being run
|
||||||
|
* (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …).
|
||||||
|
*/
|
||||||
|
private static function resolve_type_from_meta_hook(): ?string {
|
||||||
|
if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) {
|
||||||
|
return '' !== $matches[1] ? $matches[1] : $matches[2];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
|
||||||
|
*
|
||||||
|
* 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
|
||||||
|
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||||
|
if ( null !== $field_def && 'post' === $type ) {
|
||||||
|
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
|
||||||
|
}
|
||||||
|
// A group whose table upgrade waits for WP-CLI / WP-Cron stays native
|
||||||
|
// (not in aeav_only, where native is stale; the router agrees).
|
||||||
|
return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a group is deferred and so read and written natively.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function deferred( string $type, string $group ): bool {
|
||||||
|
return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接讀取(繞過 WP filter 系統)
|
* 直接讀取(繞過 WP filter 系統)
|
||||||
* 供 wpdo_get_meta() 便利函式使用,性能更好
|
* 供 wpdo_get_meta() 便利函式使用,性能更好
|
||||||
@@ -635,18 +983,22 @@ final class TMDO_Hook_Bus {
|
|||||||
// 回傳所有群組的所有欄位
|
// 回傳所有群組的所有欄位
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
||||||
$row = self::get_or_load_row( $type, $entity_id, $group );
|
$deferred = self::deferred( $type, $group );
|
||||||
|
$row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group );
|
||||||
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
|
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
|
||||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||||
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
|
$result[ $field['key'] ] = $deferred
|
||||||
|
? get_metadata( $type, $entity_id, $field['key'], true )
|
||||||
|
: TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $key );
|
$field_def = self::field_for( $type, $entity_id, $key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return null;
|
// A deferred group's value is the native one.
|
||||||
|
return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
|
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||||
|
|
||||||
|
|
||||||
@@ -63,16 +65,15 @@ final class TMDO_Mode_Manager {
|
|||||||
private static ?array $cache = null;
|
private static ?array $cache = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 預設模式(v2.5.4 起):
|
* 預設模式:全部 disabled,由管理者明確選擇後才啟用 entity bridge。
|
||||||
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
|
* 已存在 wpdo_bridge_modes 的站台(包含 aeav_only)以儲存值為準,不受影響。
|
||||||
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV)
|
|
||||||
*/
|
*/
|
||||||
private static function defaults(): array {
|
private static function defaults(): array {
|
||||||
return array(
|
return array(
|
||||||
'post' => self::MODE_DISABLED,
|
'post' => self::MODE_DISABLED,
|
||||||
'user' => self::MODE_DUAL_WRITE,
|
'user' => self::MODE_DISABLED,
|
||||||
'term' => self::MODE_DUAL_WRITE,
|
'term' => self::MODE_DISABLED,
|
||||||
'comment' => self::MODE_DUAL_WRITE,
|
'comment' => self::MODE_DISABLED,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +140,11 @@ final class TMDO_Mode_Manager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode );
|
||||||
|
if ( $guarded ) {
|
||||||
|
return $guarded;
|
||||||
|
}
|
||||||
|
|
||||||
$all = self::all();
|
$all = self::all();
|
||||||
$all[ $entity_type ] = $new_mode;
|
$all[ $entity_type ] = $new_mode;
|
||||||
|
|
||||||
@@ -153,7 +159,7 @@ final class TMDO_Mode_Manager {
|
|||||||
$entered[ $entity_type ] = time();
|
$entered[ $entity_type ] = time();
|
||||||
update_option( self::OPT_ENTERED_AT, $entered, false );
|
update_option( self::OPT_ENTERED_AT, $entered, false );
|
||||||
|
|
||||||
TMDO_Logger::info(
|
TMDO_Logger::notice(
|
||||||
'bridge_mode_changed',
|
'bridge_mode_changed',
|
||||||
array(
|
array(
|
||||||
'entity_type' => $entity_type,
|
'entity_type' => $entity_type,
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Copies of posts.post_status / post_date on post entity tables (O-1).
|
||||||
|
*
|
||||||
|
* With the copies (and their `(post_status, post_date)` index) a routed search
|
||||||
|
* can filter and sort inside the flat table instead of joining back to posts.
|
||||||
|
*
|
||||||
|
* Columns only reach a table two ways, never as DDL on a populated table in a
|
||||||
|
* web request:
|
||||||
|
* - a brand-new (empty) group table gets them when it is created;
|
||||||
|
* - `wp tmdo post-status-sync` adds them to existing tables and backfills.
|
||||||
|
*
|
||||||
|
* Per-group state lives in the TMDO_Post_Status_Sync::OPTION option:
|
||||||
|
* 'columns' (added, backfill pending) or 'ready' (every row copied).
|
||||||
|
* Afterwards transition_post_status keeps the copies current, and flat writes
|
||||||
|
* that create a row fill them in.
|
||||||
|
*
|
||||||
|
* @package TMDO
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps post_status / post_date copies on post entity tables.
|
||||||
|
*/
|
||||||
|
final class TMDO_Post_Status_Sync {
|
||||||
|
|
||||||
|
public const OPTION = 'tmdo_post_status_columns';
|
||||||
|
|
||||||
|
private const INDEX = 'idx_post_status_date_id';
|
||||||
|
|
||||||
|
/** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */
|
||||||
|
private const LEGACY_INDEX = 'idx_post_status_date';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the sync into WordPress.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
|
||||||
|
add_action( 'wpdo_schema_updated', array( __CLASS__, 'on_schema_updated' ), 10, 3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* '' (no columns), 'columns' or 'ready'.
|
||||||
|
*
|
||||||
|
* @param string $group Post group name.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function state( string $group ): string {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
return is_array( $states ) ? (string) ( $states[ $group ] ?? '' ) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every post status change or save: refresh the copies in each table that has them.
|
||||||
|
*
|
||||||
|
* @param string $new_status New status.
|
||||||
|
* @param string $old_status Old status.
|
||||||
|
* @param object $post Saved post.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_transition( $new_status, $old_status, $post ): void {
|
||||||
|
global $wpdb;
|
||||||
|
unset( $new_status, $old_status );
|
||||||
|
foreach ( self::groups_with_columns() as $group ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
self::forget( $group ); // The table was dropped (retired or uninstalled group).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$post->post_status,
|
||||||
|
$post->post_date,
|
||||||
|
$post->ID
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post group table was just created or altered: an empty one gets the
|
||||||
|
* columns now; one holding rows waits for the CLI.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Table name.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_schema_updated( $entity_type, $group, $table ): void {
|
||||||
|
global $wpdb;
|
||||||
|
if ( 'post' !== $entity_type || '' !== self::state( $group ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( self::add_columns( $group ) ) {
|
||||||
|
self::set_state( $group, 'ready' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies for a row a flat write is about to create (empty when the
|
||||||
|
* group's table has no such columns).
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return array{post_status?:string, post_date?:string}
|
||||||
|
*/
|
||||||
|
public static function insert_columns( int $post_id, string $group ): array {
|
||||||
|
if ( '' === self::state( $group ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
if ( ! $post ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'post_status' => $post->post_status,
|
||||||
|
'post_date' => $post->post_date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the columns to every post group table and copy posts' values in.
|
||||||
|
*
|
||||||
|
* @param bool $dry_run Count mismatching rows only.
|
||||||
|
* @param int $batch Rows per UPDATE.
|
||||||
|
* @return array<string, array{rows:int, mismatched:int}>
|
||||||
|
*/
|
||||||
|
public static function run( bool $dry_run = false, int $batch = 1000 ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$batch = max( 1, $batch );
|
||||||
|
$report = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$report[ $group ] = array(
|
||||||
|
'rows' => $rows,
|
||||||
|
'mismatched' => '' === self::state( $group ) ? $rows : self::mismatches( $group ),
|
||||||
|
);
|
||||||
|
if ( $dry_run || ! self::add_columns( $group ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$max = (int) $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
for ( $from = 0; $from < $max; $from += $batch ) {
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id SET t.post_status = p.post_status, t.post_date = p.post_date WHERE t.id > %d AND t.id <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$from,
|
||||||
|
$from + $batch
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::set_state( $group, 'ready' );
|
||||||
|
}
|
||||||
|
// Routed counts cached while the copies were stale must go.
|
||||||
|
if ( ! $dry_run && class_exists( 'TMDO_Post_Query_Router' ) ) {
|
||||||
|
TMDO_Post_Query_Router::bump_count_version();
|
||||||
|
}
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows whose copies differ from posts (rows of deleted posts excluded).
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public static function mismatches( string $group ): int {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
return (int) $wpdb->get_var(
|
||||||
|
"SELECT COUNT(*) FROM `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id WHERE NOT ( t.post_status <=> p.post_status ) OR NOT ( t.post_date <=> p.post_date )" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add whichever of the two columns and the index the table lacks.
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return bool False when the table is missing or the ALTER failed.
|
||||||
|
*/
|
||||||
|
private static function add_columns( string $group ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$indexes = array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$changes = array();
|
||||||
|
if ( ! in_array( 'post_status', $columns, true ) ) {
|
||||||
|
// Same collation as posts.post_status: the table default may differ,
|
||||||
|
// and comparing the two columns would then fail (illegal mix of collations).
|
||||||
|
$source = $wpdb->get_row( "SHOW FULL COLUMNS FROM `{$wpdb->posts}` LIKE 'post_status'", ARRAY_A );
|
||||||
|
$collation = preg_replace( '/[^A-Za-z0-9_]/', '', (string) ( $source['Collation'] ?? '' ) );
|
||||||
|
$changes[] = 'ADD COLUMN `post_status` VARCHAR(20)' . ( '' !== $collation ? " COLLATE {$collation}" : '' ) . ' DEFAULT NULL';
|
||||||
|
}
|
||||||
|
if ( ! in_array( 'post_date', $columns, true ) ) {
|
||||||
|
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
|
||||||
|
}
|
||||||
|
if ( ! in_array( self::INDEX, $indexes, true ) ) {
|
||||||
|
// Ends with post_id: the router reads the date route's ID tie-break
|
||||||
|
// from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128).
|
||||||
|
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)';
|
||||||
|
}
|
||||||
|
if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) {
|
||||||
|
$changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`';
|
||||||
|
}
|
||||||
|
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( '' === self::state( $group ) ) {
|
||||||
|
self::set_state( $group, 'columns' );
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post groups whose table has the columns.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function groups_with_columns(): array {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
return is_array( $states ) ? array_keys( $states ) : array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a group from the recorded states.
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function forget( string $group ): void {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
if ( is_array( $states ) && isset( $states[ $group ] ) ) {
|
||||||
|
unset( $states[ $group ] );
|
||||||
|
update_option( self::OPTION, $states, true );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a group's state.
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $state 'columns' | 'ready'.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function set_state( string $group, string $state ): void {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
$states = is_array( $states ) ? $states : array();
|
||||||
|
$states[ $group ] = $state;
|
||||||
|
update_option( self::OPTION, $states, true );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TMDO_Promotion_Guard — keeps native meta authoritative on HivePress /
|
||||||
|
* WooCommerce sites (ADR-004 "search-index mode").
|
||||||
|
*
|
||||||
|
* Both plugins read native meta without going through a keyed
|
||||||
|
* get_*_meta() call: HivePress models load every key with get_*_meta( $id ),
|
||||||
|
* WooCommerce does the same for products and writes stock / sales with direct
|
||||||
|
* SQL. The Hook Bus cannot serve those reads from flat tables, so on these
|
||||||
|
* sites an entity must never reach aeav_only and native rows must never be
|
||||||
|
* cleaned up. Demotion always stays open.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses aeav_only promotion and native-meta cleanup where an integration
|
||||||
|
* reads native meta directly.
|
||||||
|
*/
|
||||||
|
final class TMDO_Promotion_Guard {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity types each integration reads natively.
|
||||||
|
*
|
||||||
|
* HivePress models (Post, User, Term, Comment) all load meta with an empty
|
||||||
|
* key; WooCommerce does so for products and customers.
|
||||||
|
*/
|
||||||
|
private const GUARDED = array(
|
||||||
|
'HivePress' => array( 'post', 'user', 'term', 'comment' ),
|
||||||
|
'WooCommerce' => array( 'post', 'user' ),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a mode change. Only promotion to aeav_only is guarded.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $new_mode Target mode.
|
||||||
|
* @return WP_Error|null Error when refused.
|
||||||
|
*/
|
||||||
|
public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error {
|
||||||
|
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a cleanup of the entity's native managed meta rows.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @return WP_Error|null Error when refused.
|
||||||
|
*/
|
||||||
|
public static function check_cleanup( string $entity_type ): ?WP_Error {
|
||||||
|
return self::refuse( $entity_type, 'cleanup' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarded entity types that are already in aeav_only (promoted before the
|
||||||
|
* guard existed). Native meta may be stale there.
|
||||||
|
*
|
||||||
|
* @return array<string, string[]> Entity type → integration names.
|
||||||
|
*/
|
||||||
|
public static function violations(): array {
|
||||||
|
$out = array();
|
||||||
|
foreach ( TMDO_Mode_Manager::all() as $type => $mode ) {
|
||||||
|
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$integrations = self::integrations_for( $type );
|
||||||
|
if ( ! empty( $integrations ) ) {
|
||||||
|
$out[ $type ] = $integrations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active integrations that read this entity type natively.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function integrations_for( string $entity_type ): array {
|
||||||
|
$active = array(
|
||||||
|
'HivePress' => TMDO_Compatibility::is_hivepress_active(),
|
||||||
|
'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$names = array();
|
||||||
|
foreach ( self::GUARDED as $name => $types ) {
|
||||||
|
if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) {
|
||||||
|
$names[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the refusal, unless no integration applies or a filter allows it.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $action 'aeav_only' or 'cleanup'.
|
||||||
|
* @return WP_Error|null
|
||||||
|
*/
|
||||||
|
private static function refuse( string $entity_type, string $action ): ?WP_Error {
|
||||||
|
$integrations = self::integrations_for( $entity_type );
|
||||||
|
if ( empty( $integrations ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow a guarded aeav_only promotion or cleanup anyway.
|
||||||
|
*
|
||||||
|
* @param bool $allow Default false.
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $action 'aeav_only' or 'cleanup'.
|
||||||
|
* @param string[] $integrations Integrations that read this entity natively.
|
||||||
|
*/
|
||||||
|
if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_Error(
|
||||||
|
'promotion_guarded',
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */
|
||||||
|
__( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ),
|
||||||
|
implode( ' / ', $integrations ),
|
||||||
|
$entity_type,
|
||||||
|
$action
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user