diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml new file mode 100644 index 00000000..7103c136 --- /dev/null +++ b/.github/workflows/playwright-test.yml @@ -0,0 +1,104 @@ +name: Playwright Test Runner + +on: + workflow_call: + inputs: + test-mode: + required: true + type: string + description: 'Test mode: default or file-based-execution' + project-name: + required: true + type: string + description: 'Playwright project name to run' + +jobs: + playwright-test: + name: Playwright tests (${{ inputs.test-mode == 'default' && 'Default Mode' || 'File-based Execution' }}) + runs-on: ubuntu-22.04 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up PHP + uses: codesnippetspro/setup-php@v2 + with: + php-version: "8.1" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Compute dependency hash + id: deps-hash + run: | + set -euo pipefail + tmpfile=$(mktemp) + for f in src/composer.lock package-lock.json; do + if [ -f "$f" ]; then + cat "$f" >> "$tmpfile" + fi + done + if [ -s "$tmpfile" ]; then + deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) + else + deps_hash=$(echo "${GITHUB_SHA:-unknown}" | cut -c1-8) + fi + echo "deps_hash=$deps_hash" >> "$GITHUB_OUTPUT" + + - name: Get build cache + id: deps-cache + uses: actions/cache/restore@v4 + with: + path: | + node_modules + src/vendor + key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} + restore-keys: | + ${{ runner.os }}-deps- + + - name: Install workflow dependencies (wp-env, playwright) + if: steps.deps-cache.outputs.cache-hit != 'true' + run: npm run prepare-environment:ci && npm run bundle + + - name: Save vendor and node_modules cache + if: steps.deps-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + src/vendor + node_modules + key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} + + - name: Start WordPress environment + run: | + npx wp-env start + + - name: Activate code-snippets plugin + run: npx wp-env run cli wp plugin activate code-snippets + + - name: WordPress debug information + run: | + npx wp-env run cli wp core version + npx wp-env run cli wp --info + + - name: Install playwright/test + run: | + npx playwright install chromium + + - name: Run Playwright tests + run: npm run test:playwright -- --project=${{ inputs.project-name }} + + - name: Stop WordPress environment + if: always() + run: npx wp-env stop + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-test-results-${{ inputs.test-mode }} + path: test-results/ + if-no-files-found: ignore + retention-days: 2 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 37cc3aa6..4d3274f8 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -24,107 +24,31 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - Playwright: - name: Playwright test on PHP 8.1 - runs-on: ubuntu-22.04 + playwright-default: if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - - name: Set up PHP - uses: codesnippetspro/setup-php@v2 - with: - php-version: "8.1" - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version-file: .node-version - cache: 'npm' - - - name: Compute dependency hash - id: deps-hash - run: | - set -euo pipefail - # concatenate existing lock files (src/composer.lock and package-lock.json) - tmpfile=$(mktemp) - for f in src/composer.lock package-lock.json; do - if [ -f "$f" ]; then - cat "$f" >> "$tmpfile" - fi - done - if [ -s "$tmpfile" ]; then - deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) - else - # no lock files found, fall back to short commit sha - deps_hash=$(echo "${GITHUB_SHA:-unknown}" | cut -c1-8) - fi - echo "deps_hash=$deps_hash" >> "$GITHUB_OUTPUT" - - - name: Get build cache - id: deps-cache - uses: actions/cache/restore@v4 - with: - path: | - node_modules - src/vendor - key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} - restore-keys: | - ${{ runner.os }}-deps- - - - name: Install workflow dependencies (wp-env, playwright) - if: steps.deps-cache.outputs.cache-hit != 'true' - run: npm run prepare-environment:ci && npm run bundle - - - name: Save vendor and node_modules cache - if: steps.deps-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: | - src/vendor - node_modules - key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} - - - name: Start WordPress environment - run: | - npx wp-env start + uses: ./.github/workflows/playwright-test.yml + with: + test-mode: 'default' + project-name: 'chromium-db-snippets' - - name: Activate code-snippets plugin - run: npx wp-env run cli wp plugin activate code-snippets - - - name: WordPress debug information - run: | - npx wp-env run cli wp core version - npx wp-env run cli wp --info - - - name: Install playwright/test - run: | - npx playwright install chromium - - - name: Run Playwright tests - run: npm run test:playwright - - - name: Stop WordPress environment - if: always() - run: npx wp-env stop - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-test-results - path: test-results/ - if-no-files-found: ignore - retention-days: 2 + playwright-file-based-execution: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') + uses: ./.github/workflows/playwright-test.yml + with: + test-mode: 'file-based-execution' + project-name: 'chromium-file-based-snippets' test-result: - needs: [Playwright] - if: always() && (needs.Playwright.result != 'skipped') + needs: [playwright-default, playwright-file-based-execution] + if: always() && (needs.playwright-default.result != 'skipped' || needs.playwright-file-based-execution.result != 'skipped') runs-on: ubuntu-22.04 - name: Playwright - Test Results + name: Playwright - Test Results Summary steps: - - name: Test status - run: echo "Test status is - ${{ needs.Playwright.result }}" - - name: Check Playwright status - if: ${{ needs.Playwright.result != 'success' && needs.Playwright.result != 'skipped' }} + - name: Test status summary + run: | + echo "Default Mode: ${{ needs.playwright-default.result }}" + echo "File-based Execution: ${{ needs.playwright-file-based-execution.result }}" + + - name: Check overall status + if: ${{ (needs.playwright-default.result != 'success' && needs.playwright-default.result != 'skipped') || (needs.playwright-file-based-execution.result != 'success' && needs.playwright-file-based-execution.result != 'skipped') }} run: exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b058eab1..5a333535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,31 @@ # Changelog -## 3.7.1 ([unreleased]) + + + +## [3.7.1-beta.3] (2025-10-22) + +### Added +* Snippets REST API now supports pagination via page and per_page query parameters. + + +## [3.7.1-beta.2] (2025-10-22) + +### Added +* Implemented version switching with a new 'Version Switch' section in Settings + +## [3.7.1-beta.1] (2025-10-16) + +### Added +* Added @CarolinaOP and @louiswol94 as plugin contributors +* File-based execution mode for snippets (Optional in Plugin Settings) + +### Changed +* Minor UI/UX tweaks to the editor form and sidebar +* Improved editor preview behavior. ### Fixed +* Improved reliability of snippet evaluation and front-end integration. * Prefixed Composer packages to reduce collisions with other plugins, especially those using Guzzle. * Functions conditions were loading before loop setup, resulting in some conditions not working. (PRO) * JavaScript and CSS snippets loading twice due to a conditions bug. (PRO) diff --git a/package-lock.json b/package-lock.json index 000857bc..fe96a6f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-snippets", - "version": "3.7.0", + "version": "3.7.1-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-snippets", - "version": "3.7.0", + "version": "3.7.1-beta.3", "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", diff --git a/package.json b/package.json index 5269b101..8d664089 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "code-snippets", "description": "Manage code snippets running on a WordPress-powered site through a graphical interface.", "homepage": "https://codesnippets.pro", - "version": "3.7.0", + "version": "3.7.1-beta.3", "main": "src/dist/edit.js", "directories": { "test": "tests" diff --git a/scripts/version.ts b/scripts/version.ts index a1716184..c5b70e01 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -15,8 +15,10 @@ replaceInFile( .replace(/(?'CODE_SNIPPETS_VERSION',\s+)'[\w-.]+'/, `$1'${plugin.version}'`) ) -replaceInFile( - 'src/readme.txt', - contents => contents - .replace(/(?Stable tag:\s+|@version\s+)\d+\.\d+[\w-.]+$/mg, `$1${plugin.version}`) -) +if (!/beta/i.test(plugin.version)) { + replaceInFile( + 'src/readme.txt', + contents => contents + .replace(/(?Stable tag:\s+|@version\s+)\d+\.\d+[\w-.]+$/mg, `$1${plugin.version}`) + ) +} diff --git a/src/code-snippets.php b/src/code-snippets.php index d5a7ce77..1db3c0bc 100644 --- a/src/code-snippets.php +++ b/src/code-snippets.php @@ -8,11 +8,11 @@ * License: GPL-2.0-or-later * License URI: license.txt * Text Domain: code-snippets - * Version: 3.7.0 + * Version: 3.7.1-beta.3 * Requires PHP: 7.4 * Requires at least: 5.0 * - * @version 3.7.0 + * @version 3.7.1-beta.3 * @package Code_Snippets * @author Shea Bunge * @copyright 2012-2024 Code Snippets Pro @@ -37,7 +37,7 @@ * * @const string */ - define( 'CODE_SNIPPETS_VERSION', '3.7.0' ); + define( 'CODE_SNIPPETS_VERSION', '3.7.1-beta.3' ); /** * The full path to the main file of this plugin. diff --git a/src/css/settings.scss b/src/css/settings.scss index ffde5fe8..e2a040ae 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -1,6 +1,6 @@ @use 'common/codemirror'; -$sections: general, editor, debug; +$sections: general, editor, debug, version-switch; p.submit { display: flex; @@ -127,3 +127,87 @@ body.js { .cloud-settings tbody tr:nth-child(n+5) { display: none; } + +// Version Switch Styles +.code-snippets-version-switch { + .current-version { + font-family: monospace; + font-size: 1.1em; + font-weight: bold; + color: #0073aa; + background: #f0f6fc; + padding: 2px 8px; + border-radius: 3px; + border: 1px solid #c3c4c7; + } + + #target_version { + min-inline-size: 200px; + margin-inline-start: 8px; + } + + #switch-version-btn { + &[disabled] { + opacity: 0.6; + cursor: not-allowed; + background-color: #f0f0f1 !important; + color: #a7aaad !important; + border-color: #dcdcde !important; + } + } + + // Warning box styling + #version-switch-warning { + margin-block-start: 20px !important; + padding: 12px 16px; + border-inline-start: 4px solid #dba617; + background: #fff8e5; + border-radius: 4px; + + p { + margin: 0; + color: #8f6914; + + strong { + color: #8f6914; + } + } + } + + #version-switch-result { + margin-block-start: 12px; + + &.notice { + padding: 8px 12px; + border-radius: 4px; + } + } + + .notice { + &.notice { + &-success { + border-inline-start-color: #00a32a; + } + + &-error { + border-inline-start-color: #d63638; + } + + &-warning { + border-inline-start-color: #dba617; + } + + &-info { + border-inline-start-color: #72aee6; + } + } + } +} + +.version-switch-settings { + .form-table { + th { + inline-size: 180px; + } + } +} diff --git a/src/js/services/settings/index.ts b/src/js/services/settings/index.ts index 1cbaa4c6..b02809af 100644 --- a/src/js/services/settings/index.ts +++ b/src/js/services/settings/index.ts @@ -1,2 +1,3 @@ export { handleSettingsTabs } from './tabs' export { handleEditorPreviewUpdates } from './editor-preview' +export { initVersionSwitch } from './version' diff --git a/src/js/services/settings/version.ts b/src/js/services/settings/version.ts new file mode 100644 index 00000000..8e30cf4a --- /dev/null +++ b/src/js/services/settings/version.ts @@ -0,0 +1,182 @@ +// Handles version switching UI on the settings screen. +// Exported init function so callers can opt-in like other settings modules. +// Uses vanilla DOM APIs and the global `code_snippets_version_switch` config +// injected by PHP via wp_add_inline_script. + +interface VersionConfig { + ajaxurl?: string + nonce_switch?: string + nonce_refresh?: string + +} + +interface AjaxResponse { + success?: boolean + data?: { + message?: string + } +} + +declare global { + interface Window { + code_snippets_version_switch?: VersionConfig + __code_snippets_i18n?: Record + } +} + +const el = (id: string): HTMLElement | null => document.getElementById(id) + +const getConfig = (): VersionConfig => { + const w = <{ code_snippets_version_switch?: VersionConfig }>window + return w.code_snippets_version_switch ?? {} +} + +const getCurrentVersion = (): string => (document.querySelector('.current-version')?.textContent ?? '').trim() + +const getI18n = (key: string, fallback: string): string => window.__code_snippets_i18n?.[key] ?? fallback + +const bindDropdown = ( + dropdown: HTMLSelectElement, + button: HTMLButtonElement | null, + currentVersion: string, +): void => { + dropdown.addEventListener('change', (): void => { + const selectedVersion = dropdown.value + if (!button) { + return + } + if (!selectedVersion || selectedVersion === currentVersion) { + button.disabled = true + const warn = el('version-switch-warning') + if (warn) { warn.setAttribute('style', 'display: none;') } + } else { + button.disabled = false + const warn = el('version-switch-warning') + if (warn) { warn.setAttribute('style', '') } + } + }) +} + +const SUCCESS_RELOAD_MS = 3000 + +const postForm = async (data: Record, cfg: VersionConfig): Promise => { + const body = new URLSearchParams() + Object.keys(data).forEach(k => body.append(k, data[k])) + const resp = await fetch(cfg.ajaxurl ?? '/wp-admin/admin-ajax.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: body.toString(), + credentials: 'same-origin', + }) + const json = await resp.json() + return json +} + +const bindSwitch = ( + button: HTMLButtonElement, + dropdown: HTMLSelectElement, + result: HTMLDivElement, + cfg: VersionConfig, + currentVersion: string, +): void => { + button.addEventListener('click', (): void => { + void (async (): Promise => { + const targetVersion = dropdown.value + if (!targetVersion || targetVersion === currentVersion) { + result.className = 'notice notice-warning' + result.innerHTML = `

${getI18n('selectDifferent', 'Please select a different version to switch to.')}

` + result.style.display = '' + return + } + + button.disabled = true + const originalText = button.textContent ?? '' + button.textContent = getI18n('switching', 'Switching...') + + result.className = 'notice notice-info' + result.innerHTML = `

${getI18n('processing', 'Processing version switch. Please wait...')}

` + result.style.display = '' + + try { + const response = await postForm({ + action: 'code_snippets_switch_version', + target_version: targetVersion, + nonce: cfg.nonce_switch ?? '', + }, cfg) + + if (response.success) { + result.className = 'notice notice-success' + result.innerHTML = `

${response.data?.message ?? ''}

` + setTimeout(() => window.location.reload(), SUCCESS_RELOAD_MS) + return + } + + result.className = 'notice notice-error' + result.innerHTML = `

${response.data?.message ?? getI18n('error', 'An error occurred.')}

` + button.disabled = false + button.textContent = originalText + } catch (_err) { + result.className = 'notice notice-error' + result.innerHTML = `

${getI18n('errorSwitch', 'An error occurred while switching versions. Please try again.')}

` + button.disabled = false + button.textContent = originalText + } + })() + }) +} + +const REFRESH_RELOAD_MS = 1000 + +const bindRefresh = ( + btn: HTMLButtonElement, + cfg: VersionConfig, +): void => { + btn.addEventListener('click', (): void => { + void (async (): Promise => { + const original = btn.textContent ?? '' + btn.disabled = true + btn.textContent = getI18n('refreshing', 'Refreshing...') + + try { + await postForm({ + action: 'code_snippets_refresh_versions', + nonce: cfg.nonce_refresh ?? '', + }, cfg) + + btn.textContent = getI18n('refreshed', 'Refreshed!') + setTimeout(() => { + btn.disabled = false + btn.textContent = original + window.location.reload() + }, REFRESH_RELOAD_MS) + } catch { + btn.disabled = false + btn.textContent = original + } + })() + }) +} + +export const initVersionSwitch = (): void => { + const cfg = getConfig() + const currentVersion = getCurrentVersion() + + const button = el('switch-version-btn') + const dropdown = el('target_version') + const result = el('version-switch-result') + const refreshBtn = el('refresh-versions-btn') + + if (dropdown) { + bindDropdown(dropdown, button, currentVersion) + } + + if (button && dropdown && result) { + bindSwitch(button, dropdown, result, cfg, currentVersion) + } + + if (refreshBtn) { + bindRefresh(refreshBtn, cfg) + } +} + + diff --git a/src/js/settings.ts b/src/js/settings.ts index c943ae31..c89d8f57 100644 --- a/src/js/settings.ts +++ b/src/js/settings.ts @@ -1,4 +1,5 @@ -import { handleEditorPreviewUpdates, handleSettingsTabs } from './services/settings' +import { handleEditorPreviewUpdates, handleSettingsTabs, initVersionSwitch } from './services/settings' handleSettingsTabs() handleEditorPreviewUpdates() +initVersionSwitch() diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index 0fa97e78..a38e216e 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -77,6 +77,13 @@ class Plugin { */ public Licensing $licensing; + /** + * Handles snippet handler registration. + * + * @var Snippet_Handler_Registry + */ + public Snippet_Handler_Registry $snippet_handler_registry; + /** * Class constructor * @@ -125,11 +132,24 @@ public function load_plugin() { // Settings component. require_once $includes_path . '/settings/settings-fields.php'; require_once $includes_path . '/settings/editor-preview.php'; + require_once $includes_path . '/settings/class-version-switch.php'; require_once $includes_path . '/settings/settings.php'; // Cloud List Table shared functions. require_once $includes_path . '/cloud/list-table-shared-ops.php'; + // Snippet files. + $this->snippet_handler_registry = new Snippet_Handler_Registry( [ + 'php' => new Php_Snippet_Handler(), + 'html' => new Html_Snippet_Handler(), + ] ); + + $fs = new WordPress_File_System_Adapter(); + + $config_repo = new Snippet_Config_Repository( $fs ); + + ( new Snippet_Files( $this->snippet_handler_registry, $fs, $config_repo ) )->register_hooks(); + $this->front_end = new Front_End(); $this->cloud_api = new Cloud_API(); diff --git a/src/php/class-snippet.php b/src/php/class-snippet.php index abe11b44..bd3269ff 100644 --- a/src/php/class-snippet.php +++ b/src/php/class-snippet.php @@ -189,7 +189,7 @@ public static function get_type_from_scope( string $scope ): string { * * @return string The snippet type – will be a filename extension. */ - protected function get_type(): string { + public function get_type(): string { return self::get_type_from_scope( $this->scope ); } diff --git a/src/php/evaluation/class-evaluate-content.php b/src/php/evaluation/class-evaluate-content.php index f76df4c8..da7a0779 100644 --- a/src/php/evaluation/class-evaluate-content.php +++ b/src/php/evaluation/class-evaluate-content.php @@ -4,6 +4,9 @@ use Code_Snippets\DB; use Code_Snippets\Snippet; +use Code_Snippets\Settings; +use Code_Snippets\Snippet_Files; +use function Code_Snippets\code_snippets; /** * Class for evaluating content snippets. @@ -40,8 +43,13 @@ public function __construct( DB $db ) { * Initialise class functions. */ public function init() { - add_action( 'wp_head', [ $this, 'load_head_content' ] ); - add_action( 'wp_footer', [ $this, 'load_footer_content' ] ); + if ( Snippet_Files::is_active() ) { + add_action( 'wp_head', [ $this, 'load_head_content_from_flat_files' ] ); + add_action( 'wp_footer', [ $this, 'load_footer_content_from_flat_files' ] ); + } else { + add_action( 'wp_head', [ $this, 'load_head_content' ] ); + add_action( 'wp_footer', [ $this, 'load_footer_content' ] ); + } } /** @@ -77,4 +85,46 @@ public function load_head_content() { public function load_footer_content() { $this->print_content_snippets( 'footer-content' ); } + + public function load_head_content_from_flat_files() { + $this->load_content_snippets_from_flat_files( 'head-content' ); + } + + public function load_footer_content_from_flat_files() { + $this->load_content_snippets_from_flat_files( 'footer-content' ); + } + + private function populate_active_snippets_from_flat_files() { + $handler = code_snippets()->snippet_handler_registry->get_handler( 'html' ); + $dir_name = $handler->get_dir_name(); + $ext = $handler->get_file_extension(); + + $scopes = [ 'head-content', 'footer-content' ]; + $all_snippets = Snippet_Files::get_active_snippets_from_flat_files( $scopes, $dir_name ); + + foreach ( $all_snippets as $snippet ) { + $scope = $snippet['scope']; + + // Add file path information to the snippet for later use + $table_name = Snippet_Files::get_hashed_table_name( $snippet['table'] ); + $base_path = Snippet_Files::get_base_dir( $table_name, $dir_name ); + $snippet['file_path'] = $base_path . '/' . $snippet['id'] . '.' . $ext; + + $this->active_snippets[ $scope ][] = $snippet; + } + } + + private function load_content_snippets_from_flat_files( string $scope ) { + if ( is_null( $this->active_snippets ) ) { + $this->populate_active_snippets_from_flat_files(); + } + + if ( ! isset( $this->active_snippets[ $scope ] ) ) { + return; + } + + foreach ( $this->active_snippets[ $scope ] as $snippet ) { + require_once $snippet['file_path']; + } + } } diff --git a/src/php/evaluation/class-evaluate-functions.php b/src/php/evaluation/class-evaluate-functions.php index 0009961e..eca66a08 100644 --- a/src/php/evaluation/class-evaluate-functions.php +++ b/src/php/evaluation/class-evaluate-functions.php @@ -4,9 +4,13 @@ use Code_Snippets\DB; use Code_Snippets\REST_API\Snippets_REST_Controller; +use Code_Snippets\Settings; +use Code_Snippets\Snippet_Files; use function Code_Snippets\clean_active_snippets_cache; use function Code_Snippets\clean_snippets_cache; use function Code_Snippets\execute_snippet; +use function Code_Snippets\code_snippets; +use function Code_Snippets\execute_snippet_from_flat_file; /** * Class for evaluating functions snippets. @@ -104,6 +108,28 @@ private function quick_deactivate_snippet( int $snippet_id, string $table_name ) [ '%d' ] ); clean_snippets_cache( $table_name ); + + $network = $table_name === $this->db->ms_table; + do_action( 'code_snippets/deactivate_snippet', $snippet_id, $network ); + } + } + + private function evaluate_snippet_flat_file( array $snippet, string $file_path, ?array $edit_snippet = null ) { + $snippet_id = $snippet['id']; + $code = $snippet['code']; + $table_name = $snippet['table']; + + // If the snippet is a single-use snippet, deactivate it before execution to ensure that the process always happens. + if ( 'single-use' === $snippet['scope'] ) { + $this->quick_deactivate_snippet( $snippet_id, $table_name ); + } + + if ( ! is_null( $edit_snippet ) && $edit_snippet['id'] === $snippet_id && $edit_snippet['table'] === $table_name ) { + return; + } + + if ( apply_filters( 'code_snippets/allow_execute_snippet', true, $snippet_id, $table_name ) ) { + execute_snippet_from_flat_file( $code, $file_path, $snippet_id ); } } @@ -117,6 +143,14 @@ public function evaluate_early(): bool { return false; } + if ( Snippet_Files::is_active() ) { + return $this->evaluate_file_snippets(); + } + + return $this->evaluate_db_snippets(); + } + + public function evaluate_db_snippets(): bool { $scopes = [ 'global', 'single-use', is_admin() ? 'admin' : 'front-end' ]; $active_snippets = $this->db->fetch_active_snippets( $scopes ); $edit_snippet = $this->get_currently_editing_snippet(); @@ -139,4 +173,21 @@ public function evaluate_early(): bool { return true; } + + private function evaluate_file_snippets(): bool { + $type = 'php'; + $scopes = [ 'global', 'single-use', is_admin() ? 'admin' : 'front-end' ]; + $snippets = Snippet_Files::get_active_snippets_from_flat_files( $scopes, $type ); + $edit_snippet = $this->get_currently_editing_snippet(); + + foreach ( $snippets as $snippet ) { + $table_name = Snippet_Files::get_hashed_table_name( $snippet['table'] ); + $base_path = Snippet_Files::get_base_dir( $table_name, $type ); + $file = $base_path . '/' . $snippet['id'] . '.' . $type; + + $this->evaluate_snippet_flat_file( $snippet, $file, $edit_snippet ); + } + + return true; + } } diff --git a/src/php/flat-files/classes/class-config-repository.php b/src/php/flat-files/classes/class-config-repository.php new file mode 100644 index 00000000..07ab5452 --- /dev/null +++ b/src/php/flat-files/classes/class-config-repository.php @@ -0,0 +1,55 @@ +fs = $fs; + } + + public function load( string $base_dir ): array { + $config_file_path = trailingslashit( $base_dir ) . static::CONFIG_FILE_NAME; + + if ( is_file( $config_file_path ) ) { + if ( function_exists( 'opcache_invalidate' ) ) { + opcache_invalidate( $config_file_path, true ); + } + return require $config_file_path; + } + return []; + } + + public function save( string $base_dir, array $active_snippets ): void { + $config_file_path = trailingslashit( $base_dir ) . static::CONFIG_FILE_NAME; + + ksort( $active_snippets ); + + $file_content = "fs->put_contents( $config_file_path, $file_content, FS_CHMOD_FILE ); + + if ( is_file( $config_file_path ) ) { + if ( function_exists( 'opcache_invalidate' ) ) { + opcache_invalidate( $config_file_path, true ); + } + } + } + + public function update( string $base_dir, Snippet $snippet, ?bool $remove = false ): void { + $active_snippets = $this->load( $base_dir ); + + if ( $remove ) { + unset( $active_snippets[ $snippet->id ] ); + } else { + $active_snippets[ $snippet->id ] = $snippet->get_fields(); + } + + $this->save( $base_dir, $active_snippets ); + } +} diff --git a/src/php/flat-files/classes/class-file-system-adapter.php b/src/php/flat-files/classes/class-file-system-adapter.php new file mode 100644 index 00000000..62055253 --- /dev/null +++ b/src/php/flat-files/classes/class-file-system-adapter.php @@ -0,0 +1,47 @@ +fs = $wp_filesystem; + } + + public function put_contents( string $path, string $contents, $chmod ) { + return $this->fs->put_contents( $path, $contents, $chmod ); + } + + public function exists( string $path ): bool { + return $this->fs->exists( $path ); + } + + public function delete( $file, $recursive = false, $type = false ): bool { + return $this->fs->delete( $file, $recursive, $type ); + } + + public function is_dir( string $path ): bool { + return $this->fs->is_dir( $path ); + } + + public function mkdir( string $path, $chmod ) { + return $this->fs->mkdir( $path, $chmod ); + } + + public function rmdir( string $path, bool $recursive = false ): bool { + return $this->fs->rmdir( $path, $recursive ); + } + + public function chmod( string $path, $chmod ): bool { + return $this->fs->chmod( $path, $chmod ); + } + + public function is_writable( string $path ): bool { + return $this->fs->is_writable( $path ); + } +} diff --git a/src/php/flat-files/classes/class-snippet-files.php b/src/php/flat-files/classes/class-snippet-files.php new file mode 100644 index 00000000..a1743666 --- /dev/null +++ b/src/php/flat-files/classes/class-snippet-files.php @@ -0,0 +1,486 @@ +handler_registry = $handler_registry; + $this->fs = $fs; + $this->config_repo = $config_repo; + } + + /** + * Check if flat files are enabled by checking for the flag file. + * This avoids database calls for better performance. + * + * @return bool True if flat files are enabled, false otherwise. + */ + public static function is_active(): bool { + $flag_file_path = self::get_flag_file_path(); + return file_exists( $flag_file_path ); + } + + private static function get_flag_file_path(): string { + return self::get_base_dir() . '/' . self::ENABLED_FLAG_FILE; + } + + private function handle_enabled_file_flag( bool $enabled ): void { + $flag_file_path = self::get_flag_file_path(); + + if ( $enabled ) { + $base_dir = self::get_base_dir(); + $this->maybe_create_directory( $base_dir ); + + $this->fs->put_contents( $flag_file_path, '', FS_CHMOD_FILE ); + } else { + $this->delete_file( $flag_file_path ); + } + } + + public function register_hooks(): void { + if ( ! $this->fs->is_writable( WP_CONTENT_DIR ) ) { + return; + } + + if ( self::is_active() ) { + add_action( 'code_snippets/create_snippet', [ $this, 'handle_snippet' ], 10, 2 ); + add_action( 'code_snippets/update_snippet', [ $this, 'handle_snippet' ], 10, 2 ); + add_action( 'code_snippets/delete_snippet', [ $this, 'delete_snippet' ], 10, 2 ); + add_action( 'code_snippets/activate_snippet', [ $this, 'activate_snippet' ], 10, 1 ); + add_action( 'code_snippets/deactivate_snippet', [ $this, 'deactivate_snippet' ], 10, 2 ); + add_action( 'code_snippets/activate_snippets', [ $this, 'activate_snippets' ], 10, 2 ); + + add_action( 'updated_option', [ $this, 'sync_active_shared_network_snippets' ], 10, 3 ); + add_action( 'add_option', [ $this, 'sync_active_shared_network_snippets_add' ], 10, 2 ); + } + + add_filter( 'code_snippets_settings_fields', [ $this, 'add_settings_fields' ], 10, 1 ); + add_action( 'code_snippets/settings_updated', [ $this, 'create_all_flat_files' ], 10, 2 ); + } + + public function activate_snippets( $valid_snippets, $table ): void { + foreach ( $valid_snippets as $snippet ) { + $snippet->active = true; + $this->handle_snippet( $snippet, $table ); + } + } + + public function handle_snippet( Snippet $snippet, string $table ): void { + if ( 0 === $snippet->id ) { + return; + } + + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( $table ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + $this->maybe_create_directory( $base_dir ); + + $file_path = $this->get_snippet_file_path( $base_dir, $snippet->id, $handler->get_file_extension() ); + + $contents = $handler->wrap_code( $snippet->code ); + + $this->fs->put_contents( $file_path, $contents, FS_CHMOD_FILE ); + + $this->config_repo->update( $base_dir, $snippet ); + } + + public function delete_snippet( Snippet $snippet, bool $network ): void { + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( $network ) ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + + $file_path = $this->get_snippet_file_path( $base_dir, $snippet->id, $handler->get_file_extension() ); + $this->delete_file( $file_path ); + + $this->config_repo->update( $base_dir, $snippet, true ); + } + + public function activate_snippet( Snippet $snippet ): void { + $snippet = get_snippet( $snippet->id, $snippet->network ); + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( $snippet->network ) ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + + $this->maybe_create_directory( $base_dir ); + + $file_path = $this->get_snippet_file_path( $base_dir, $snippet->id, $handler->get_file_extension() ); + + $contents = $handler->wrap_code( $snippet->code ); + + $this->fs->put_contents( $file_path, $contents, FS_CHMOD_FILE ); + + $this->config_repo->update( $base_dir, $snippet ); + } + + public function deactivate_snippet( int $snippet_id, bool $network ): void { + $snippet = get_snippet( $snippet_id, $network ); + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( $network ) ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + + $this->config_repo->update( $base_dir, $snippet ); + } + + public static function get_base_dir( string $table = '', string $snippet_type = '' ): string { + $base_dir = WP_CONTENT_DIR . '/code-snippets'; + + if ( ! empty( $table ) ) { + $base_dir .= '/' . $table; + } + + if ( ! empty( $snippet_type ) ) { + $base_dir .= '/' . $snippet_type; + } + + return $base_dir; + } + + public static function get_base_url( string $table = '', string $snippet_type = '' ): string { + $base_url = WP_CONTENT_URL . '/code-snippets'; + + if ( ! empty( $table ) ) { + $base_url .= '/' . $table; + } + + if ( ! empty( $snippet_type ) ) { + $base_url .= '/' . $snippet_type; + } + + return $base_url; + } + + private function maybe_create_directory( string $dir ): void { + if ( ! $this->fs->is_dir( $dir ) ) { + $result = wp_mkdir_p( $dir ); + + if ( $result ) { + $this->fs->chmod( $dir, FS_CHMOD_DIR ); + } + } + } + + private function get_snippet_file_path( string $base_dir, int $snippet_id, string $ext ): string { + return trailingslashit( $base_dir ) . $snippet_id . '.' . $ext; + } + + private function delete_file( string $file_path ): void { + if ( $this->fs->exists( $file_path ) ) { + $this->fs->delete( $file_path ); + } + } + + public function sync_active_shared_network_snippets( $option, $old_value, $value ): void { + if ( 'active_shared_network_snippets' !== $option ) { + return; + } + + $this->create_active_shared_network_snippets_file( $value ); + } + + public function sync_active_shared_network_snippets_add( $option, $value ): void { + if ( 'active_shared_network_snippets' !== $option ) { + return; + } + + $this->create_active_shared_network_snippets_file( $value ); + } + + private function create_active_shared_network_snippets_file( $value ): void { + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( false ) ); + $base_dir = self::get_base_dir( $table ); + + $this->maybe_create_directory( $base_dir ); + + $file_path = trailingslashit( $base_dir ) . 'active-shared-network-snippets.php'; + $file_content = "fs->put_contents( $file_path, $file_content, FS_CHMOD_FILE ); + } + + public static function get_hashed_table_name( string $table ): string { + return wp_hash( $table ); + } + + public static function get_active_snippets_from_flat_files( + array $scopes = [], + $snippet_type = 'php' + ): array { + $active_snippets = []; + $db = code_snippets()->db; + + $table = self::get_hashed_table_name( $db->get_table_name() ); + $snippets = self::load_active_snippets_from_file( + $table, + $snippet_type, + $scopes + ); + + if ( $snippets ) { + foreach ( $snippets as $snippet ) { + $active_snippets[] = [ + 'id' => intval( $snippet['id'] ), + 'code' => $snippet['code'], + 'scope' => $snippet['scope'], + 'table' => $db->table, + 'network' => false, + 'priority' => intval( $snippet['priority'] ), + 'condition_id' => intval( $snippet['condition_id'] ), + ]; + } + } + + if ( is_multisite() ) { + $ms_table = self::get_hashed_table_name( $db->get_table_name( true ) ); + + $root_base_dir = self::get_base_dir( $table ); + $active_shared_ids_file_path = $root_base_dir . '/active-shared-network-snippets.php'; + $active_shared_ids = is_file( $active_shared_ids_file_path ) + ? require $active_shared_ids_file_path + : []; + + $ms_snippets = self::load_active_snippets_from_file( + $ms_table, + $snippet_type, + $scopes, + $active_shared_ids + ); + + if ( $ms_snippets ) { + $active_shared_ids = is_array( $active_shared_ids ) + ? array_map( 'intval', $active_shared_ids ) + : []; + + foreach ( $ms_snippets as $snippet ) { + $id = intval( $snippet['id'] ); + + if ( ! $snippet['active'] && ! in_array( $id, $active_shared_ids, true ) ) { + continue; + } + + $active_snippets[] = [ + 'id' => $id, + 'code' => $snippet['code'], + 'scope' => $snippet['scope'], + 'table' => $db->ms_table, + 'network' => true, + 'priority' => intval( $snippet['priority'] ), + 'condition_id' => intval( $snippet['condition_id'] ), + ]; + } + + self::sort_active_snippets( $active_snippets, $db ); + } + } + + return $active_snippets; + } + + private static function sort_active_snippets( array &$active_snippets, $db ): void { + $comparisons = [ + function ( array $a, array $b ) { + return $a['priority'] <=> $b['priority']; + }, + function ( array $a, array $b ) use ( $db ) { + $a_table = $a['table'] === $db->ms_table ? 0 : 1; + $b_table = $b['table'] === $db->ms_table ? 0 : 1; + return $a_table <=> $b_table; + }, + function ( array $a, array $b ) { + return $a['id'] <=> $b['id']; + }, + ]; + + usort( + $active_snippets, + static function ( $a, $b ) use ( $comparisons ) { + foreach ( $comparisons as $comparison ) { + $result = $comparison( $a, $b ); + if ( 0 !== $result ) { + return $result; + } + } + + return 0; + } + ); + } + + private static function load_active_snippets_from_file( + string $table, + string $snippet_type, + array $scopes, + ?array $active_shared_ids = null + ): array { + $snippets = []; + $db = code_snippets()->db; + + $base_dir = self::get_base_dir( $table, $snippet_type ); + $snippets_file_path = $base_dir . '/index.php'; + + if ( ! is_file( $snippets_file_path ) ) { + return $snippets; + } + + $cache_key = sprintf( + 'active_snippets_%s_%s', + sanitize_key( join( '_', $scopes ) ), + self::get_hashed_table_name( $db->table ) === $table ? $db->table : $db->ms_table + ); + + $cached_snippets = wp_cache_get( $cache_key, CACHE_GROUP ); + + if ( is_array( $cached_snippets ) ) { + return $cached_snippets; + } + + $file_snippets = require $snippets_file_path; + + $filtered_snippets = array_filter( + $file_snippets, + function ( $snippet ) use ( $scopes, $active_shared_ids ) { + $is_active = $snippet['active']; + + if ( null !== $active_shared_ids ) { + $is_active = $is_active || in_array( + intval( $snippet['id'] ), + $active_shared_ids, + true + ); + } + + return ( $is_active || 'condition' === $snippet['scope'] ) && in_array( $snippet['scope'], $scopes, true ); + } + ); + + wp_cache_set( $cache_key, $filtered_snippets, CACHE_GROUP ); + + return $filtered_snippets; + } + + public function add_settings_fields( array $fields ): array { + $fields['general']['enable_flat_files'] = [ + 'name' => __( 'Enable file-based execution', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Snippets will be executed directly from files instead of the database.', 'code-snippets' ) . ' ' . sprintf( + '%s', + esc_url( 'https://codesnippets.pro/doc/file-based-execution/' ), + __( 'Learn more.', 'code-snippets' ) + ), + ]; + + return $fields; + } + + public function create_all_flat_files( array $settings, array $input ): void { + if ( ! isset( $settings['general']['enable_flat_files'] ) ) { + return; + } + + $this->handle_enabled_file_flag( $settings['general']['enable_flat_files'] ); + + if ( ! $settings['general']['enable_flat_files'] ) { + return; + } + + $this->create_snippet_flat_files(); + $this->create_active_shared_network_snippets_config_file(); + } + + private function create_snippet_flat_files(): void { + $db = code_snippets()->db; + + $scopes = Snippet::get_all_scopes(); + + $data = $db->fetch_active_snippets( $scopes ); + + foreach ( $data as $snippet ) { + $snippet_obj = get_snippet( $snippet['id'], $db->ms_table === $snippet['table'] ); + $this->handle_snippet( $snippet_obj, $snippet['table'] ); + } + + if ( is_multisite() ) { + $current_blog_id = get_current_blog_id(); + + $sites = get_sites( [ 'fields' => 'ids' ] ); + foreach ( $sites as $site_id ) { + switch_to_blog( $site_id ); + $db->set_table_vars(); + + $site_data = $db->fetch_active_snippets( $scopes ); + foreach ( $site_data as $table_name => $active_snippets ) { + foreach ( $active_snippets as $snippet ) { + $snippet_obj = get_snippet( $snippet['id'], false ); + $this->handle_snippet( $snippet_obj, $table_name ); + } + } + + restore_current_blog(); + } + + $db->set_table_vars(); + } + } + + private function create_active_shared_network_snippets_config_file(): void { + if ( is_multisite() ) { + $current_blog_id = get_current_blog_id(); + $sites = get_sites( [ 'fields' => 'ids' ] ); + $db = code_snippets()->db; + + foreach ( $sites as $site_id ) { + switch_to_blog( $site_id ); + $db->set_table_vars(); + + $active_shared_network_snippets = get_option( 'active_shared_network_snippets' ); + if ( false !== $active_shared_network_snippets ) { + $this->create_active_shared_network_snippets_file( $active_shared_network_snippets ); + } + + restore_current_blog(); + } + + $db->set_table_vars(); + } else { + $active_shared_network_snippets = get_option( 'active_shared_network_snippets' ); + if ( false !== $active_shared_network_snippets ) { + $this->create_active_shared_network_snippets_file( $active_shared_network_snippets ); + } + } + } +} diff --git a/src/php/flat-files/handlers/html-snippet-handler.php b/src/php/flat-files/handlers/html-snippet-handler.php new file mode 100644 index 00000000..d7a4446a --- /dev/null +++ b/src/php/flat-files/handlers/html-snippet-handler.php @@ -0,0 +1,17 @@ +\n\n" . $code; + } +} diff --git a/src/php/flat-files/handlers/php-snippet-handler.php b/src/php/flat-files/handlers/php-snippet-handler.php new file mode 100644 index 00000000..5d306392 --- /dev/null +++ b/src/php/flat-files/handlers/php-snippet-handler.php @@ -0,0 +1,17 @@ + $handler ) { + $this->register_handler( $type, $handler ); + } + } + + /** + * Registers a handler for a snippet type. + * + * @param string $type + * @param Snippet_Type_Handler_Interface $handler + * @return void + */ + public function register_handler( string $type, Snippet_Type_Handler_Interface $handler ): void { + $this->handlers[ $type ] = $handler; + } + + /** + * Gets the handler for a snippet type. + * + * @param string $type + * + * @return Snippet_Type_Handler_Interface|null + */ + public function get_handler( string $type ): ?Snippet_Type_Handler_Interface { + if ( ! isset( $this->handlers[ $type ] ) ) { + return null; + } + + return $this->handlers[ $type ]; + } +} diff --git a/src/php/front-end/class-front-end.php b/src/php/front-end/class-front-end.php index 74ca529a..72003164 100644 --- a/src/php/front-end/class-front-end.php +++ b/src/php/front-end/class-front-end.php @@ -227,6 +227,20 @@ protected function convert_boolean_attribute_flags( array $atts, array $boolean_ return $atts; } + /** + * Build the file path for a snippet's flat file. + * + * @param string $table_name Table name for the snippet. + * @param Snippet $snippet Snippet object. + * + * @return string Full file path for the snippet. + */ + private function build_snippet_flat_file_path( string $table_name, Snippet $snippet ): string { + $handler = code_snippets()->snippet_handler_registry->get_handler( $snippet->get_type() ); + + return Snippet_Files::get_base_dir( $table_name, $handler->get_dir_name() ) . '/' . $snippet->id . '.' . $handler->get_file_extension(); + } + /** * Evaluate the code from a content shortcode. * @@ -240,6 +254,20 @@ protected function evaluate_shortcode_content( Snippet $snippet, array $atts ): return $snippet->code; } + if ( ! Snippet_Files::is_active() ) { + return $this->evaluate_shortcode_from_db( $snippet, $atts ); + } + + $network = DB::validate_network_param( $snippet->network ); + $table_name = Snippet_Files::get_hashed_table_name( code_snippets()->db->get_table_name( $network ) ); + $filepath = $this->build_snippet_flat_file_path( $table_name, $snippet ); + + return file_exists( $filepath ) + ? $this->evaluate_shortcode_from_flat_file( $filepath, $atts ) + : $this->evaluate_shortcode_from_db( $snippet, $atts ); + } + + private function evaluate_shortcode_from_db( Snippet $snippet, array $atts ): string { /** * Avoiding extract is typically recommended, however in this situation we want to make it easy for snippet * authors to use custom attributes. @@ -254,6 +282,46 @@ protected function evaluate_shortcode_content( Snippet $snippet, array $atts ): return ob_get_clean(); } + private function evaluate_shortcode_from_flat_file( $filepath, array $atts ): string { + ob_start(); + + ( function( $atts ) use ( $filepath ) { + /** + * Avoiding extract is typically recommended, however in this situation we want to make it easy for snippet + * authors to use custom attributes. + * + * @phpcs:disable WordPress.PHP.DontExtract.extract_extract + */ + extract( $atts ); + require_once $filepath; + } )( $atts ); + + return ob_get_clean(); + } + + private function get_snippet( int $id, bool $network, string $snippet_type ): Snippet { + if ( ! Snippet_Files::is_active() ) { + return get_snippet( $id, $network ); + } + + $validated_network = DB::validate_network_param( $network ); + $table_name = Snippet_Files::get_hashed_table_name( code_snippets()->db->get_table_name( $validated_network ) ); + $handler = code_snippets()->snippet_handler_registry->get_handler( $snippet_type ); + $config_filepath = Snippet_Files::get_base_dir( $table_name, $handler->get_dir_name() ) . '/index.php'; + + if ( file_exists( $config_filepath ) ) { + $config = require_once $config_filepath; + $snippet_data = $config[ $id ] ?? null; + + if ( $snippet_data ) { + $snippet = new Snippet( $snippet_data ); + return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network ); + } + } + + return get_snippet( $id, $network ); + } + /** * Render the value of a content shortcode * @@ -284,7 +352,7 @@ public function render_content_shortcode( array $atts ): string { return $this->invalid_id_warning( $id ); } - $snippet = get_snippet( $id, (bool) $atts['network'] ); + $snippet = $this->get_snippet( $id, (bool) $atts['network'], 'html' ); // Render the source code if this is not a shortcode snippet. if ( 'content' !== $snippet->scope ) { @@ -426,7 +494,7 @@ public function render_source_shortcode( array $atts ): string { return $this->invalid_id_warning( $id ); } - $snippet = get_snippet( $id, (bool) $atts['network'] ); + $snippet = $this->get_snippet( $id, (bool) $atts['network'], 'html' ); return $this->render_snippet_source( $snippet, $atts ); } diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/rest-api/class-snippets-rest-controller.php index 18a1f827..6fe58ba9 100644 --- a/src/php/rest-api/class-snippets-rest-controller.php +++ b/src/php/rest-api/class-snippets-rest-controller.php @@ -80,6 +80,9 @@ public function register_routes() { [ 'network' ] ); + // Allow standard collection parameters (page, per_page, etc.) on the collection route. + $collection_args = array_merge( $network_args, $this->get_collection_params() ); + register_rest_route( $this->namespace, $route, @@ -88,7 +91,7 @@ public function register_routes() { 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'get_items_permissions_check' ], - 'args' => $network_args, + 'args' => $collection_args, ], [ 'methods' => WP_REST_Server::CREATABLE, @@ -186,14 +189,32 @@ public function register_routes() { } /** - * Retrieves a collection of snippets. + * Retrieves a collection of snippets, with pagination. * * @param WP_REST_Request $request Full details about the request. * * @return WP_REST_Response Response object on success. */ public function get_items( $request ): WP_REST_Response { - $snippets = get_snippets(); + $network = $request->get_param( 'network' ); + $all_snippets = get_snippets( [], $network ); + + // Get collection params (page, per_page). + $collection_params = $this->get_collection_params(); + $per_page_request = (int) $request->get_param( 'per_page' ); + $per_page = max( 1, $per_page_request ? $per_page_request : (int) $collection_params['per_page']['default'] ); + + $page_request = (int) $request->get_param( 'page' ); + $page = max( 1, $page_request ? $page_request : (int) $collection_params['page']['default'] ); + + // Count total items + $total_items = count( $all_snippets ); + $total_pages = (int) ceil( $total_items / $per_page ); + + // Slice the full list to the requested page. + $offset = ( $page - 1 ) * $per_page; + $snippets = array_slice( $all_snippets, $offset, $per_page ); + $snippets_data = []; foreach ( $snippets as $snippet ) { @@ -201,7 +222,11 @@ public function get_items( $request ): WP_REST_Response { $snippets_data[] = $this->prepare_response_for_collection( $snippet_data ); } - return rest_ensure_response( $snippets_data ); + $response = rest_ensure_response( $snippets_data ); + $response->header( 'X-WP-Total', (string) $total_items ); + $response->header( 'X-WP-TotalPages', (string) $total_pages ); + + return $response; } /** diff --git a/src/php/settings/class-setting-field.php b/src/php/settings/class-setting-field.php index e8d64dcd..e9887456 100644 --- a/src/php/settings/class-setting-field.php +++ b/src/php/settings/class-setting-field.php @@ -117,7 +117,11 @@ public function render() { * Render a callback field. */ public function render_callback_field() { - call_user_func( $this->render_callback ); + if ( ! is_callable( $this->render_callback ) ) { + return; + } + + call_user_func( $this->render_callback, $this->args ); } /** diff --git a/src/php/settings/class-version-switch.php b/src/php/settings/class-version-switch.php new file mode 100644 index 00000000..7738147d --- /dev/null +++ b/src/php/settings/class-version-switch.php @@ -0,0 +1,362 @@ + $download_url ) { + if ( 'trunk' !== $version ) { + $versions[] = [ + 'version' => $version, + 'url' => $download_url, + ]; + } + } + + // Sort versions in descending order + usort( $versions, function( $a, $b ) { + return version_compare( $b['version'], $a['version'] ); + }); + + // Cache for configured duration + set_transient( VERSION_CACHE_KEY, $versions, VERSION_CACHE_DURATION ); + } + + return $versions; + } + + public static function get_current_version(): string { + return defined( 'CODE_SNIPPETS_VERSION' ) ? CODE_SNIPPETS_VERSION : '0.0.0'; + } + + public static function is_version_switch_in_progress(): bool { + return get_transient( PROGRESS_KEY ) !== false; + } + + public static function clear_version_caches(): void { + delete_transient( VERSION_CACHE_KEY ); + delete_transient( PROGRESS_KEY ); + } + + public static function validate_target_version( string $target_version, array $available_versions ): array { + if ( empty( $target_version ) ) { + return [ + 'success' => false, + 'message' => __( 'No target version specified.', 'code-snippets' ), + 'download_url' => '', + ]; + } + + foreach ( $available_versions as $version_info ) { + if ( $version_info['version'] === $target_version ) { + return [ + 'success' => true, + 'message' => '', + 'download_url' => $version_info['url'], + ]; + } + } + + return [ + 'success' => false, + 'message' => __( 'Invalid version specified.', 'code-snippets' ), + 'download_url' => '', + ]; + } + + public static function create_error_response( string $message, string $technical_details = '' ): array { + if ( ! empty( $technical_details ) ) { + if ( function_exists( 'error_log' ) ) { + error_log( sprintf( 'Code Snippets version switch error: %s. Details: %s', $message, $technical_details ) ); + } + } + + return [ + 'success' => false, + 'message' => $message, + ]; + } + + public static function perform_version_install( string $download_url ) { + if ( ! function_exists( 'wp_update_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/update.php'; + } + if ( ! function_exists( 'show_message' ) ) { + require_once ABSPATH . 'wp-admin/includes/misc.php'; + } + if ( ! class_exists( 'Plugin_Upgrader' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + } + + $update_handler = new \WP_Ajax_Upgrader_Skin(); + $upgrader = new \Plugin_Upgrader( $update_handler ); + + global $code_snippets_last_update_handler, $code_snippets_last_upgrader; + $code_snippets_last_update_handler = $update_handler; + $code_snippets_last_upgrader = $upgrader; + + return $upgrader->install( $download_url, [ + 'overwrite_package' => true, + 'clear_update_cache' => true, + ] ); + } + + public static function extract_handler_messages( $update_handler, $upgrader ): string { + $handler_messages = ''; + + if ( isset( $update_handler ) ) { + if ( method_exists( $update_handler, 'get_errors' ) ) { + $errs = $update_handler->get_errors(); + if ( $errs instanceof \WP_Error && $errs->has_errors() ) { + $handler_messages .= implode( "\n", $errs->get_error_messages() ); + } + } + if ( method_exists( $update_handler, 'get_error_messages' ) ) { + $em = $update_handler->get_error_messages(); + if ( $em ) { + $handler_messages .= "\n" . $em; + } + } + if ( method_exists( $update_handler, 'get_upgrade_messages' ) ) { + $upgrade_msgs = $update_handler->get_upgrade_messages(); + if ( is_array( $upgrade_msgs ) ) { + $handler_messages .= "\n" . implode( "\n", $upgrade_msgs ); + } elseif ( $upgrade_msgs ) { + $handler_messages .= "\n" . (string) $upgrade_msgs; + } + } + } + + if ( empty( $handler_messages ) && isset( $upgrader->result ) ) { + if ( is_wp_error( $upgrader->result ) ) { + $handler_messages = implode( "\n", $upgrader->result->get_error_messages() ); + } else { + $handler_messages = is_scalar( $upgrader->result ) ? (string) $upgrader->result : print_r( $upgrader->result, true ); + } + } + + return trim( $handler_messages ); + } + + public static function log_version_switch_attempt( string $target_version, $result, string $details = '' ): void { + if ( function_exists( 'error_log' ) ) { + error_log( sprintf( 'Code Snippets version switch failed. target=%s, result=%s, details=%s', $target_version, var_export( $result, true ), $details ) ); + } + } + + public static function handle_installation_failure( string $target_version, string $download_url, $install_result ): array { + global $code_snippets_last_update_handler, $code_snippets_last_upgrader; + + $handler_messages = self::extract_handler_messages( $code_snippets_last_update_handler, $code_snippets_last_upgrader ); + self::log_version_switch_attempt( $target_version, $install_result, "URL: $download_url, Messages: $handler_messages" ); + + $fallback_message = __( 'Failed to switch versions. Please try again.', 'code-snippets' ); + if ( ! empty( $handler_messages ) ) { + $short = wp_trim_words( wp_strip_all_tags( $handler_messages ), 40, '...' ); + $fallback_message = sprintf( '%s %s', $fallback_message, $short ); + } + + return [ + 'success' => false, + 'message' => $fallback_message, + ]; + } + + public static function handle_version_switch( string $target_version ): array { + if ( ! current_user_can( 'update_plugins' ) ) { + return self::create_error_response( __( 'You do not have permission to update plugins.', 'code-snippets' ) ); + } + + $available_versions = self::get_available_versions(); + $validation = self::validate_target_version( $target_version, $available_versions ); + + if ( ! $validation['success'] ) { + return self::create_error_response( $validation['message'] ); + } + + if ( self::get_current_version() === $target_version ) { + return self::create_error_response( __( 'Already on the specified version.', 'code-snippets' ) ); + } + + set_transient( PROGRESS_KEY, $target_version, PROGRESS_TIMEOUT ); + + $install_result = self::perform_version_install( $validation['download_url'] ); + + delete_transient( PROGRESS_KEY ); + + if ( is_wp_error( $install_result ) ) { + return self::create_error_response( $install_result->get_error_message() ); + } + + if ( $install_result ) { + delete_transient( VERSION_CACHE_KEY ); + + return [ + 'success' => true, + 'message' => sprintf( __( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ), $target_version ), + ]; + } + + return self::handle_installation_failure( $target_version, $validation['download_url'], $install_result ); + } + + public static function render_version_switch_field( array $args ): void { + $current_version = self::get_current_version(); + $available_versions = self::get_available_versions(); + $is_switching = self::is_version_switch_in_progress(); + + ?> +
+

+ + +

+ + +
+

+
+ +

+ + +

+ +

+ +

+ + + +
__( 'You do not have permission to update plugins.', 'code-snippets' ), + ] ); + } + + $target_version = sanitize_text_field( $_POST['target_version'] ?? '' ); + + if ( empty( $target_version ) ) { + wp_send_json_error( [ + 'message' => __( 'No target version specified.', 'code-snippets' ), + ] ); + } + + $result = self::handle_version_switch( $target_version ); + + if ( $result['success'] ) { + wp_send_json_success( $result ); + } else { + wp_send_json_error( $result ); + } + } + + public static function render_refresh_versions_field( array $args ): void { + ?> + +

+ +

__( 'You do not have permission to manage options.', 'code-snippets' ), + ] ); + } + + delete_transient( VERSION_CACHE_KEY ); + self::get_available_versions(); + + wp_send_json_success( [ + 'message' => __( 'Available versions updated successfully.', 'code-snippets' ), + ] ); + } + + public static function render_version_switch_warning(): void { + ?> + + admin_url( 'admin-ajax.php' ), + 'nonce_switch' => wp_create_nonce( 'code_snippets_version_switch' ), + 'nonce_refresh' => wp_create_nonce( 'code_snippets_refresh_versions' ), + ); + + $strings = array( + 'selectDifferent' => esc_html__( 'Please select a different version to switch to.', 'code-snippets' ), + 'switching' => esc_html__( 'Switching...', 'code-snippets' ), + 'processing' => esc_html__( 'Processing version switch. Please wait...', 'code-snippets' ), + 'error' => esc_html__( 'An error occurred.', 'code-snippets' ), + 'errorSwitch' => esc_html__( 'An error occurred while switching versions. Please try again.', 'code-snippets' ), + 'refreshing' => esc_html__( 'Refreshing...', 'code-snippets' ), + 'refreshed' => esc_html__( 'Refreshed!', 'code-snippets' ), + ); + + wp_add_inline_script( 'code-snippets-settings-menu', 'var code_snippets_version_switch = ' . wp_json_encode( $version_switch ) . '; var __code_snippets_i18n = ' . wp_json_encode( $strings ) . ';', 'before' ); } /** diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php index 60514bff..50312ac0 100644 --- a/src/php/settings/settings-fields.php +++ b/src/php/settings/settings-fields.php @@ -47,6 +47,12 @@ function get_default_settings(): array { 'keymap' => 'default', 'theme' => 'default', ], + 'version-switch' => [ + 'selected_version' => '', + ], + 'debug' => [ + 'enable_version_change' => false, + ], ]; $defaults = apply_filters( 'code_snippets_settings_defaults', $defaults ); @@ -80,6 +86,29 @@ function get_settings_fields(): array { 'type' => 'action', 'desc' => __( 'Use this button to manually clear snippets caches.', 'code-snippets' ), ], + 'enable_version_change' => [ + 'name' => __( 'Version Change', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Enable the ability to switch or rollback versions of the Code Snippets core plugin.', 'code-snippets' ), + ], + ]; + + $fields['version-switch'] = [ + 'version_switcher' => [ + 'name' => __( 'Switch Version', 'code-snippets' ), + 'type' => 'callback', + 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_field' ], + ], + 'refresh_versions' => [ + 'name' => __( 'Refresh Versions', 'code-snippets' ), + 'type' => 'callback', + 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_refresh_versions_field' ], + ], + 'version_warning' => [ + 'name' => '', + 'type' => 'callback', + 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_warning' ], + ], ]; $fields['general'] = [ diff --git a/src/php/settings/settings.php b/src/php/settings/settings.php index cf94f8b9..249bdc24 100644 --- a/src/php/settings/settings.php +++ b/src/php/settings/settings.php @@ -136,11 +136,17 @@ function update_setting( string $section, string $field, $new_value ): bool { */ function get_settings_sections(): array { $sections = array( - 'general' => __( 'General', 'code-snippets' ), - 'editor' => __( 'Code Editor', 'code-snippets' ), - 'debug' => __( 'Debug', 'code-snippets' ), + 'general' => __( 'General', 'code-snippets' ), + 'editor' => __( 'Code Editor', 'code-snippets' ), + 'debug' => __( 'Debug', 'code-snippets' ), ); + // Only show the Version section when the debug setting to enable version changes is enabled. + $enable_version = get_setting( 'debug', 'enable_version_change' ); + if ( $enable_version ) { + $sections['version-switch'] = __( 'Version', 'code-snippets' ); + } + return apply_filters( 'code_snippets_settings_sections', $sections ); } @@ -168,8 +174,13 @@ function register_plugin_settings() { add_settings_section( $section_id, $section_name, '__return_empty_string', 'code-snippets' ); } - // Register settings fields. + // Register settings fields. Only register fields for sections that exist (some sections may be gated by settings). + $registered_sections = get_settings_sections(); foreach ( get_settings_fields() as $section_id => $fields ) { + if ( ! isset( $registered_sections[ $section_id ] ) ) { + continue; + } + foreach ( $fields as $field_id => $field ) { $field_object = new Setting_Field( $section_id, $field_id, $field ); add_settings_field( $field_id, $field['name'], [ $field_object, 'render' ], 'code-snippets', $section_id ); @@ -330,6 +341,8 @@ function sanitize_settings( array $input ): array { __( 'Settings saved.', 'code-snippets' ), 'updated' ); + + do_action( 'code_snippets/settings_updated', $settings, $input ); } return $settings; diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 10581eb3..9548c923 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -296,10 +296,12 @@ function activate_snippet( int $id, ?bool $network = null ) { // translators: %d: snippet identifier. return sprintf( __( 'Could not locate snippet with ID %d.', 'code-snippets' ), $id ); } - - $validator = new Validator( $snippet->code ); - if ( $validator->validate() ) { - return __( 'Could not activate snippet: code did not pass validation.', 'code-snippets' ); + + if('php' == $snippet->type ){ + $validator = new Validator( $snippet->code ); + if ( $validator->validate() ) { + return __( 'Could not activate snippet: code did not pass validation.', 'code-snippets' ); + } } $result = $wpdb->update( @@ -315,7 +317,7 @@ function activate_snippet( int $id, ?bool $network = null ) { } update_shared_network_snippets( [ $snippet ] ); - do_action( 'code_snippets/activate_snippet', $snippet ); + do_action( 'code_snippets/activate_snippet', $snippet, $network ); clean_snippets_cache( $table_name ); return $snippet; } @@ -393,7 +395,7 @@ function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet { $network = DB::validate_network_param( $network ); $table = code_snippets()->db->get_table_name( $network ); - // Set the snippet to active. + // Set the snippet to inactive. $result = $wpdb->update( $table, array( 'active' => '0' ), @@ -434,6 +436,8 @@ function delete_snippet( int $id, ?bool $network = null ): bool { $network = DB::validate_network_param( $network ); $table = code_snippets()->db->get_table_name( $network ); + $snippet = get_snippet( $id, $network ); + $result = $wpdb->delete( $table, array( 'id' => $id ), @@ -441,7 +445,7 @@ function delete_snippet( int $id, ?bool $network = null ): bool { ); if ( $result ) { - do_action( 'code_snippets/delete_snippet', $id, $network ); + do_action( 'code_snippets/delete_snippet', $snippet, $network ); clean_snippets_cache( $table ); code_snippets()->cloud_api->delete_snippet_from_transient_data( $id ); } @@ -672,3 +676,17 @@ function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = do_action( 'code_snippets/update_snippet', $snippet->id, $table ); clean_snippets_cache( $table ); } + +function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force = false ) { + if ( ! is_file( $file ) ) { + return execute_snippet( $code, $id, $force ); + } + + if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) { + return false; + } + + require_once $file; + + do_action( 'code_snippets/after_execute_snippet_from_flat_file', $file, $id ); +} diff --git a/src/php/uninstall.php b/src/php/uninstall.php index 4da58cfe..5afaefb6 100644 --- a/src/php/uninstall.php +++ b/src/php/uninstall.php @@ -18,7 +18,7 @@ function complete_uninstall_enabled(): bool { $unified = false; if ( is_multisite() ) { - $menu_perms = get_site_option( 'menu_items', array() ); + $menu_perms = get_site_option( 'menu_items', [] ); $unified = empty( $menu_perms['snippets_settings'] ); } @@ -72,6 +72,25 @@ function uninstall_multisite() { delete_site_option( 'recently_activated_snippets' ); } +function delete_flat_files_directory() { + $flat_files_dir = WP_CONTENT_DIR . '/code-snippets'; + + if ( ! is_dir( $flat_files_dir ) ) { + return; + } + + if ( ! function_exists( 'request_filesystem_credentials' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + global $wp_filesystem; + WP_Filesystem(); + + if ( $wp_filesystem && $wp_filesystem->is_dir( $flat_files_dir ) ) { + $wp_filesystem->delete( $flat_files_dir, true ); + } +} + /** * Uninstall the Code Snippets plugin. * @@ -85,5 +104,7 @@ function uninstall_plugin() { } else { uninstall_current_site(); } + + delete_flat_files_directory(); } } diff --git a/src/readme.txt b/src/readme.txt index ac8d5a2a..167f9bf3 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -104,7 +104,45 @@ You can report security bugs found in the source code of this plugin through the == Changelog == -= 3.7.0 (beta release) = + + += 3.7.1-beta.3 (2025-10-22) = + +__Added__ + +* Snippets REST API now supports pagination via page and per_page query parameters. + + += 3.7.1-beta.2 (2025-10-22) = + +__Added__ + +* Implemented version switching with a new 'Version Switch' section in Settings + += 3.7.1-beta.1 (2025-10-16) = + +__Added__ + +* Added @CarolinaOP and @louiswol94 as plugin contributors +* File-based execution mode for snippets (Optional in Plugin Settings) + +__Changed__ + +* Minor UI/UX tweaks to the editor form and sidebar +* Improved editor preview behavior. + +__Fixed__ + +* Improved reliability of snippet evaluation and front-end integration. +* Prefixed Composer packages to reduce collisions with other plugins, especially those using Guzzle. +* Functions conditions were loading before loop setup, resulting in some conditions not working. (PRO) +* JavaScript and CSS snippets loading twice due to a conditions bug. (PRO) + +__Removed__ + +* Removed CSS linting within the editor until a modern replacement can be implemented. + += 3.7.0 (2025-08-29) = __Added__ diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index 2f3426a2..de1204b2 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -19,7 +19,7 @@ test.describe('Code Snippets Admin', () => { test('Can add a new snippet', async () => { await helper.createSnippet({ name: TEST_SNIPPET_NAME, - code: 'echo "Hello World!";' + code: "add_filter('show_admin_bar', '__return_false');" }) }) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index c83b6515..07ecedf5 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -13,7 +13,7 @@ test.describe('Code Snippets List Page Actions', () => { await helper.createAndActivateSnippet({ name: TEST_SNIPPET_NAME, - code: 'echo "Test snippet for list actions";' + code: "add_filter('show_admin_bar', '__return_false');" }) await helper.navigateToSnippetsAdmin() }) diff --git a/tests/e2e/flat-files.setup.ts b/tests/e2e/flat-files.setup.ts new file mode 100644 index 00000000..3a9307c4 --- /dev/null +++ b/tests/e2e/flat-files.setup.ts @@ -0,0 +1,25 @@ +import { expect, test as setup } from '@playwright/test' + +setup('enable flat files', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=snippets-settings') + await page.waitForSelector('#wpbody-content') + + await page.waitForSelector('form') + + const flatFilesCheckbox = page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]') + await expect(flatFilesCheckbox).toBeVisible() + + const isChecked = await flatFilesCheckbox.isChecked() + if (!isChecked) { + await flatFilesCheckbox.check() + } + + await page.click('input[type="submit"][name="submit"]') + + await page.waitForSelector('.notice-success', { timeout: 10000 }) + await expect(page.locator('.notice-success')).toContainText('Settings saved') + + await page.reload() + await page.waitForSelector('input[name="code_snippets_settings[general][enable_flat_files]"]') + await expect(page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]')).toBeChecked() +}) diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 696f73de..cde090e2 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -29,16 +29,37 @@ export default defineConfig({ projects: [ { name: 'setup', - testMatch: /.*\.setup\.ts/ + testMatch: /auth\.setup\.ts/ }, { - name: 'chromium', + name: 'flat-files-setup', + testMatch: /flat-files\.setup\.ts/, use: { ...devices['Desktop Chrome'], storageState: join(__dirname, '../e2e/.auth/user.json') }, dependencies: ['setup'] + }, + + { + name: 'chromium-db-snippets', + use: { + ...devices['Desktop Chrome'], + storageState: join(__dirname, '../e2e/.auth/user.json') + }, + dependencies: ['setup'], + testIgnore: /.*\.setup\.ts/ + }, + + { + name: 'chromium-file-based-snippets', + use: { + ...devices['Desktop Chrome'], + storageState: join(__dirname, '../e2e/.auth/user.json') + }, + dependencies: ['setup', 'flat-files-setup'], + testIgnore: /.*\.setup\.ts/ } ],