diff --git a/config/modules/codemirror-keymaps.d.ts b/config/modules/codemirror-keymaps.d.ts new file mode 100644 index 000000000..9f5e587c1 --- /dev/null +++ b/config/modules/codemirror-keymaps.d.ts @@ -0,0 +1,5 @@ +declare module 'codemirror/src/input/keymap' { + import type { KeyMap } from 'codemirror' + + export const getKeyMap: (keyMap: string) => KeyMap +} diff --git a/config/webpack-js.ts b/config/webpack-js.ts index 3e2a19ce8..adcd5cb47 100644 --- a/config/webpack-js.ts +++ b/config/webpack-js.ts @@ -6,7 +6,7 @@ import { toCamelCase } from '../src/js/utils/text' import { dependencies } from '../package.json' import type { Configuration } from 'webpack' -const SOURCE_DIR = './src/js' +const SOURCE_DIR = './src/js/entries' const DEST_DIR = './src/dist' const babelConfig = { @@ -24,13 +24,14 @@ const babelConfig = { export const jsWebpackConfig: Configuration = { entry: { - edit: { import: `${SOURCE_DIR}/edit.tsx`, dependOn: 'editor' }, + edit: { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, editor: `${SOURCE_DIR}/editor.ts`, - import: `${SOURCE_DIR}/import.tsx`, + import: `${SOURCE_DIR}/import.ts`, manage: `${SOURCE_DIR}/manage.ts`, mce: `${SOURCE_DIR}/mce.ts`, prism: `${SOURCE_DIR}/prism.ts`, - settings: { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' } + settings: { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' }, + welcome: `${SOURCE_DIR}/welcome.ts`, }, output: { path: join(resolve(__dirname), '..', DEST_DIR), diff --git a/eslint.config.mjs b/eslint.config.mjs index c66739dc1..7e8a043f2 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -81,11 +81,11 @@ export default eslintTs.config( objectLiteralTypeAssertions: 'never' }], '@typescript-eslint/consistent-type-imports': 'error', - '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/no-confusing-void-expression': ['error', { ignoreArrowShorthand: true }], '@typescript-eslint/no-for-in-array': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', '@typescript-eslint/no-inferrable-types': ['error', { ignoreProperties: true, ignoreParameters: false }], + '@typescript-eslint/no-magic-numbers': ['error', { ignore: [-1, 0, 1], ignoreEnums: true }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', @@ -117,7 +117,6 @@ export default eslintTs.config( }], 'max-lines-per-function': ['warn', { skipBlankLines: true, skipComments: true }], 'no-invalid-this': 'error', - 'no-magic-numbers': ['error', { ignore: [-1, 0, 1] }], 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 'no-ternary': 'off', 'one-var': ['error', 'never'], diff --git a/package-lock.json b/package-lock.json index 3260a8438..b0bfa8729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-snippets", - "version": "3.9.3", + "version": "3.10.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-snippets", - "version": "3.9.3", + "version": "3.10.0-dev.1", "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", @@ -430,8 +430,6 @@ }, "node_modules/@babel/helpers": { "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dev": true, "license": "MIT", "dependencies": { @@ -444,8 +442,6 @@ }, "node_modules/@babel/parser": { "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "license": "MIT", "dependencies": { "@babel/types": "^7.26.10" @@ -1713,8 +1709,6 @@ }, "node_modules/@babel/template": { "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -1750,8 +1744,6 @@ }, "node_modules/@babel/types": { "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -1843,8 +1835,6 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", - "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", "dev": true, "funding": [ { @@ -1866,8 +1856,6 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", - "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", "dev": true, "funding": [ { @@ -1886,8 +1874,6 @@ }, "node_modules/@csstools/media-query-list-parser": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.2.tgz", - "integrity": "sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==", "dev": true, "funding": [ { @@ -1918,8 +1904,6 @@ }, "node_modules/@dual-bundle/import-meta-resolve": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==", "dev": true, "license": "MIT", "funding": { @@ -1929,8 +1913,7 @@ }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -1951,8 +1934,7 @@ }, "node_modules/@emotion/cache": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -1974,26 +1956,22 @@ }, "node_modules/@emotion/hash": { "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" } }, "node_modules/@emotion/memoize": { "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + "license": "MIT" }, "node_modules/@emotion/react": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2015,8 +1993,7 @@ }, "node_modules/@emotion/serialize": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", @@ -2027,13 +2004,11 @@ }, "node_modules/@emotion/sheet": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + "license": "MIT" }, "node_modules/@emotion/styled": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2054,26 +2029,22 @@ }, "node_modules/@emotion/unitless": { "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + "license": "MIT" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + "license": "MIT" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", @@ -2459,8 +2430,6 @@ }, "node_modules/@keyv/serialize": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", - "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", "dev": true, "license": "MIT", "dependencies": { @@ -2469,18 +2438,16 @@ }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1" } }, "node_modules/@kwsites/promise-deferred": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@lezer/common": { "version": "0.15.12", @@ -2598,9 +2565,8 @@ }, "node_modules/@playwright/test": { "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", - "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "playwright": "1.55.0" }, @@ -2623,9 +2589,8 @@ }, "node_modules/@sindresorhus/is": { "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -2635,8 +2600,6 @@ }, "node_modules/@stylistic/eslint-plugin": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", - "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2676,9 +2639,7 @@ } }, "node_modules/@stylistic/stylelint-plugin": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-3.1.2.tgz", - "integrity": "sha512-tylFJGMQo62alGazK74MNxFjMagYOHmBZiePZFOJK2n13JZta0uVkB3Bh5qodUmOLtRH+uxH297EibK14UKm8g==", + "version": "3.1.3", "dev": true, "license": "MIT", "dependencies": { @@ -2686,10 +2647,10 @@ "@csstools/css-tokenizer": "^3.0.1", "@csstools/media-query-list-parser": "^3.0.1", "is-plain-object": "^5.0.0", + "postcss": "^8.4.41", "postcss-selector-parser": "^6.1.2", "postcss-value-parser": "^4.2.0", - "style-search": "^0.1.0", - "stylelint": "^16.8.2" + "style-search": "^0.1.0" }, "engines": { "node": "^18.12 || >=20.9" @@ -2700,8 +2661,6 @@ }, "node_modules/@stylistic/stylelint-plugin/node_modules/@csstools/media-query-list-parser": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz", - "integrity": "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==", "dev": true, "funding": [ { @@ -2724,9 +2683,8 @@ }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", "dev": true, + "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.0" }, @@ -2800,9 +2758,8 @@ }, "node_modules/@types/cacheable-request": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", "dev": true, + "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", @@ -2851,9 +2808,8 @@ }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -2896,15 +2852,14 @@ }, "node_modules/@types/keyv": { "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/mousetrap": { - "version": "1.6.14", + "version": "1.6.15", "license": "MIT" }, "node_modules/@types/node": { @@ -2960,9 +2915,8 @@ }, "node_modules/@types/responselike": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -2990,8 +2944,6 @@ }, "node_modules/@types/web": { "version": "0.0.202", - "resolved": "https://registry.npmjs.org/@types/web/-/web-0.0.202.tgz", - "integrity": "sha512-2iO+wBir5OBnMlB9Z7aD/0SUZjR2mhiCLtPvGPboTqwBC4O3Yv6Vjwn5eMxGMXtRAm01OV9yUBi9C8pJa02TIA==", "dev": true, "license": "Apache-2.0" }, @@ -3403,14 +3355,12 @@ } }, "node_modules/@wordpress/a11y": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.20.0.tgz", - "integrity": "sha512-hyFKC3D1o0Cvy1HeFgujsuW9gTrwVL4DVIfnQytG2+gMFaDyux4Qmzyg2e3k71BKlHn7J28Q3i0xNqC2k7ZoFw==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/dom-ready": "^4.20.0", - "@wordpress/i18n": "^5.20.0" + "@wordpress/dom-ready": "^4.23.0", + "@wordpress/i18n": "^5.23.0" }, "engines": { "node": ">=18.12.0", @@ -3418,9 +3368,7 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.20.0.tgz", - "integrity": "sha512-UGfPuNFjN8RG1BsFc04jOHoJFi3ZINYo4nsmrrUx1PFSFD2qpttmV03dWFWfqSvLvrMlYPQPMkYyK5KS6THxVQ==", + "version": "8.23.0", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -3430,8 +3378,8 @@ "@babel/preset-env": "7.25.7", "@babel/preset-typescript": "7.25.7", "@babel/runtime": "7.25.7", - "@wordpress/browserslist-config": "^6.20.0", - "@wordpress/warning": "^3.20.0", + "@wordpress/browserslist-config": "^6.23.0", + "@wordpress/warning": "^3.23.0", "browserslist": "^4.21.10", "core-js": "^3.31.0", "react": "^18.3.0" @@ -3442,9 +3390,7 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.20.0.tgz", - "integrity": "sha512-n9Q1UN3QL4DuZLySZpbJoZbQvBTjMjRV5yaxnmQaEpOyqablX4GnYq39fwTY72hBN/c1b0oyOFcsbhsrx0wqzg==", + "version": "6.23.0", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -3453,9 +3399,7 @@ } }, "node_modules/@wordpress/components": { - "version": "29.6.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-29.6.0.tgz", - "integrity": "sha512-kk9GxGnoGBqHz0S4gT2UJHQBwudE1AgTPOc3v3k72kZkDaT88ZayBd/4/gHsa659zImgrwXZ6SjQ6Nczt80Bgg==", + "version": "29.9.0", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -3470,23 +3414,23 @@ "@types/gradient-parser": "0.1.3", "@types/highlight-words-core": "1.2.1", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^4.20.0", - "@wordpress/compose": "^7.20.0", - "@wordpress/date": "^5.20.0", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/dom": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/escape-html": "^3.20.0", - "@wordpress/hooks": "^4.20.0", - "@wordpress/html-entities": "^4.20.0", - "@wordpress/i18n": "^5.20.0", - "@wordpress/icons": "^10.20.0", - "@wordpress/is-shallow-equal": "^5.20.0", - "@wordpress/keycodes": "^4.20.0", - "@wordpress/primitives": "^4.20.0", - "@wordpress/private-apis": "^1.20.0", - "@wordpress/rich-text": "^7.20.0", - "@wordpress/warning": "^3.20.0", + "@wordpress/a11y": "^4.23.0", + "@wordpress/compose": "^7.23.0", + "@wordpress/date": "^5.23.0", + "@wordpress/deprecated": "^4.23.0", + "@wordpress/dom": "^4.23.0", + "@wordpress/element": "^6.23.0", + "@wordpress/escape-html": "^3.23.0", + "@wordpress/hooks": "^4.23.0", + "@wordpress/html-entities": "^4.23.0", + "@wordpress/i18n": "^5.23.0", + "@wordpress/icons": "^10.23.0", + "@wordpress/is-shallow-equal": "^5.23.0", + "@wordpress/keycodes": "^4.23.0", + "@wordpress/primitives": "^4.23.0", + "@wordpress/private-apis": "^1.23.0", + "@wordpress/rich-text": "^7.23.0", + "@wordpress/warning": "^3.23.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", @@ -3494,7 +3438,7 @@ "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", "framer-motion": "^11.1.9", - "gradient-parser": "^0.1.5", + "gradient-parser": "1.0.2", "highlight-words-core": "^1.2.2", "is-plain-object": "^5.0.0", "memize": "^2.1.0", @@ -3514,20 +3458,18 @@ } }, "node_modules/@wordpress/compose": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.20.0.tgz", - "integrity": "sha512-L84QUGXbXPdCAgNDNmmH+4tJuAl1MwH5an6CaQ+NaSXk4kM4xAc42znHo0n5LfsRmWxOPrtlGikxMXCaejvoyw==", + "version": "7.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/dom": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/is-shallow-equal": "^5.20.0", - "@wordpress/keycodes": "^4.20.0", - "@wordpress/priority-queue": "^3.20.0", - "@wordpress/undo-manager": "^1.20.0", + "@wordpress/deprecated": "^4.23.0", + "@wordpress/dom": "^4.23.0", + "@wordpress/element": "^6.23.0", + "@wordpress/is-shallow-equal": "^5.23.0", + "@wordpress/keycodes": "^4.23.0", + "@wordpress/priority-queue": "^3.23.0", + "@wordpress/undo-manager": "^1.23.0", "change-case": "^4.1.2", "clipboard": "^2.0.11", "mousetrap": "^1.6.5", @@ -3542,19 +3484,17 @@ } }, "node_modules/@wordpress/data": { - "version": "10.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.20.0.tgz", - "integrity": "sha512-oj1Ci7mPZ2kbmI2cdqk7apfvd4nlWziPstlIZIKCb02rCEMqP8dC0lc/CDt8GVOXJ23iMhZgkfkvnFNaMXmBNQ==", + "version": "10.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/compose": "^7.20.0", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/is-shallow-equal": "^5.20.0", - "@wordpress/priority-queue": "^3.20.0", - "@wordpress/private-apis": "^1.20.0", - "@wordpress/redux-routine": "^5.20.0", + "@wordpress/compose": "^7.23.0", + "@wordpress/deprecated": "^4.23.0", + "@wordpress/element": "^6.23.0", + "@wordpress/is-shallow-equal": "^5.23.0", + "@wordpress/priority-queue": "^3.23.0", + "@wordpress/private-apis": "^1.23.0", + "@wordpress/redux-routine": "^5.23.0", "deepmerge": "^4.3.0", "equivalent-key-map": "^0.2.2", "is-plain-object": "^5.0.0", @@ -3572,13 +3512,11 @@ } }, "node_modules/@wordpress/date": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.20.0.tgz", - "integrity": "sha512-V34zSLveuXTe8wvnIpUXroP7dP9FK1HzMmGNB5JtoPhrqJeNvP4fzju8RJwBGpU1sFaqO3w+EZoNdTV9k0hqxA==", + "version": "5.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/deprecated": "^4.20.0", + "@wordpress/deprecated": "^4.23.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40" }, @@ -3588,13 +3526,11 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.20.0.tgz", - "integrity": "sha512-36JbtGUSQ49SM33fvfSAvN8ZGDqCxCPAj2PByAney4WhoVbznxGWnao8qKwWrNNG5xec1reQvXFxOsD7qab4rg==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/hooks": "^4.20.0" + "@wordpress/hooks": "^4.23.0" }, "engines": { "node": ">=18.12.0", @@ -3602,13 +3538,11 @@ } }, "node_modules/@wordpress/dom": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.20.0.tgz", - "integrity": "sha512-uLYH7hKfJDUHkooAy0uoFJXMCkraTP3gdybblAJT9a/dqAOVcsMODH9gTGI99IoFhsvJwWo5Vk94/kgqeOdarA==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/deprecated": "^4.20.0" + "@wordpress/deprecated": "^4.23.0" }, "engines": { "node": ">=18.12.0", @@ -3616,9 +3550,7 @@ } }, "node_modules/@wordpress/dom-ready": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.20.0.tgz", - "integrity": "sha512-FkdfoITfj1yBSUMn+IKIqpm7zwA4AbHPkYdCXNgP9w5BRBpoTqXMGgDbe8rt4aSWkSEiRChZ9rGmtG84LByRTA==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -3630,8 +3562,6 @@ }, "node_modules/@wordpress/element": { "version": "6.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.28.0.tgz", - "integrity": "sha512-FSojQxfsaDXwc11nMgc/OlIgq1BgjpNf9m2Smw1Z3GmVq8J4E6wAFpJuoUPwyjON4i1apiWl/bqQA84yT9C84g==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -3650,9 +3580,8 @@ }, "node_modules/@wordpress/env": { "version": "9.10.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-9.10.0.tgz", - "integrity": "sha512-GqUg1XdrUXI3l5NhHhEZisrccW+VPqJSU5xO1IXybI6KOvmSecidxWEqlMj26vzu2P5aLCWZcx28QkrrY3jvdg==", "dev": true, + "license": "GPL-2.0-or-later", "dependencies": { "chalk": "^4.0.0", "copy-dir": "^1.3.0", @@ -3673,18 +3602,29 @@ }, "node_modules/@wordpress/env/node_modules/argparse": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, + "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } }, + "node_modules/@wordpress/env/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wordpress/env/node_modules/js-yaml": { "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -3695,14 +3635,60 @@ }, "node_modules/@wordpress/env/node_modules/sprintf-js": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@wordpress/env/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@wordpress/env/node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@wordpress/env/node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wordpress/env/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/@wordpress/escape-html": { "version": "3.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.28.0.tgz", - "integrity": "sha512-LDcr26vX7OkcvHMjAFxg0vNmI7cP5lzLs+HbnwM1H9h0dsj3svIWXXFF/7lQl7sbI9+rjF0GkR1Fgd+DvF7zxw==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -3713,9 +3699,7 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.20.0.tgz", - "integrity": "sha512-nn6RbAER5EitMJVr+jpOg5HDIUEEOEv6jC/P1s5C0HvsOaldBeJ80A73Gsd/NFGlUqCc7o51uoZO36wGoPjIpg==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -3726,9 +3710,7 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.20.0.tgz", - "integrity": "sha512-ZOQ9zsfs5p32K+uAEy2vbY7rnAG5KjMdXwOn4v2FPeXF6A6jWQudK/smV7nRB3ZMaSZnzQ54tiUXbuSpCmmGYA==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -3739,13 +3721,11 @@ } }, "node_modules/@wordpress/i18n": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-5.20.0.tgz", - "integrity": "sha512-JrgVe5QT+nDHFbujeD0lJifDpdgmOt1SSnEK631jIISjfGjriYwphoOEAzBGRh9S9ThqOOfW4mLOOeXPYmJR7w==", + "version": "5.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/hooks": "^4.20.0", + "@wordpress/hooks": "^4.23.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "sprintf-js": "^1.1.1", @@ -3760,14 +3740,12 @@ } }, "node_modules/@wordpress/icons": { - "version": "10.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-10.20.0.tgz", - "integrity": "sha512-wGmmGDQoDKjmuGdC2I8C3JA9GlqVM9DK5FJZuUukHTh+Nz72W8CA30PzGKavxWOYd7cZ0B97VioE85aVwOAe3g==", + "version": "10.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/element": "^6.20.0", - "@wordpress/primitives": "^4.20.0" + "@wordpress/element": "^6.23.0", + "@wordpress/primitives": "^4.23.0" }, "engines": { "node": ">=18.12.0", @@ -3775,9 +3753,7 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.20.0.tgz", - "integrity": "sha512-/m8P/6AQgZchMbeDhne5z8Wzde07mv8+l7qsYK6VhChEWonrYN7Sfig9uGPtWijkWwOkxYjWE6ggcJ5xn8KVlg==", + "version": "5.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -3788,13 +3764,11 @@ } }, "node_modules/@wordpress/keycodes": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.20.0.tgz", - "integrity": "sha512-GLzp9uTSNOPvX378FInwvLj4riqq1N/By1kd40iAr1hXfRAjy0H//vktJ70r+AkwK0R07txtCPiLnDcW53hLmg==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "^5.20.0" + "@wordpress/i18n": "^5.23.0" }, "engines": { "node": ">=18.12.0", @@ -3802,13 +3776,11 @@ } }, "node_modules/@wordpress/primitives": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.20.0.tgz", - "integrity": "sha512-fVs9EnuI2UV1xfAYY//OOfO+O3n4VvPVGcI/zHMAfIdJGWEbCQVDatAnteX/2hkjBe85jqErkU+0bAKsddhpcA==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/element": "^6.20.0", + "@wordpress/element": "^6.23.0", "clsx": "^2.1.1" }, "engines": { @@ -3820,9 +3792,7 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.20.0.tgz", - "integrity": "sha512-2gOa8LQaTLPgk1GDkkXWALA9yH47yhDZKHKBHy8YH61c+m8ai8RctWegzXA6pSInPW77nbBUNHSOzxWTsDN1Sw==", + "version": "3.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -3834,9 +3804,7 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.20.0.tgz", - "integrity": "sha512-DngnywYj6zDt9D0HgnX7k0il5SsdDYUxEg82GqNu3Jd879LlG9MtIxcoV+ErCsH7ryTydXw4sC17W09m2LEMBQ==", + "version": "1.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -3847,9 +3815,7 @@ } }, "node_modules/@wordpress/redux-routine": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.20.0.tgz", - "integrity": "sha512-6JZI75oMAWGBgo+x2rmfIGzqVuxiZ3wQBqNCdVDDOGYH9qcRzYgBWRSPVfh4rvGLTtpVnFHnnBQ+jr5iPGHOxQ==", + "version": "5.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -3866,20 +3832,18 @@ } }, "node_modules/@wordpress/rich-text": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.20.0.tgz", - "integrity": "sha512-irx6cvmoxSSajzGGt5iVxek3vNfG5LslORQ1g7HXcNawfFBxhptU3vzPF2+ywvs6o3BCbTZVfa98rOfX3C2J/Q==", + "version": "7.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.20.0", - "@wordpress/compose": "^7.20.0", - "@wordpress/data": "^10.20.0", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/escape-html": "^3.20.0", - "@wordpress/i18n": "^5.20.0", - "@wordpress/keycodes": "^4.20.0", + "@wordpress/a11y": "^4.23.0", + "@wordpress/compose": "^7.23.0", + "@wordpress/data": "^10.23.0", + "@wordpress/deprecated": "^4.23.0", + "@wordpress/element": "^6.23.0", + "@wordpress/escape-html": "^3.23.0", + "@wordpress/i18n": "^5.23.0", + "@wordpress/keycodes": "^4.23.0", "memize": "^2.1.0" }, "engines": { @@ -3891,13 +3855,11 @@ } }, "node_modules/@wordpress/undo-manager": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.20.0.tgz", - "integrity": "sha512-IG3/u0uR0nfZ/kXRfC6DVFK52hbbNx4aMB/c5DAMQgKtJElE7Mz1Mf5zgU1XNlpBOdguQp6oo/nMpyJUIasipQ==", + "version": "1.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/is-shallow-equal": "^5.20.0" + "@wordpress/is-shallow-equal": "^5.23.0" }, "engines": { "node": ">=18.12.0", @@ -3905,9 +3867,7 @@ } }, "node_modules/@wordpress/url": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.20.0.tgz", - "integrity": "sha512-IUkph25ewBDTxuSC9wXvMbec6IB2A3pNz0Xkm1Ffzm2ngk/f+0+Ko2WSKdXqqR8U67Eyb+ZUZFtBPmEsKvEZ4A==", + "version": "4.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -3919,9 +3879,7 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.20.0.tgz", - "integrity": "sha512-IQRvlWwNWO6kncZ/qQEX/KCvsrm/0FIcuCXrTXlGP4OslRG7XtU9xs2lOP34Y6G3onMwhpD8mXFUK7udq305EQ==", + "version": "3.23.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -4023,9 +3981,8 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -4046,8 +4003,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -4061,15 +4016,11 @@ } }, "node_modules/ansis": { - "version": "1.5.2", + "version": "4.0.0-node10", "dev": true, "license": "ISC", "engines": { - "node": ">=12.13" - }, - "funding": { - "type": "patreon", - "url": "https://patreon.com/biodiscus" + "node": ">=10" } }, "node_modules/archiver": { @@ -4255,8 +4206,6 @@ }, "node_modules/array-union": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { @@ -4372,8 +4321,6 @@ }, "node_modules/astral-regex": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", "engines": { @@ -4449,8 +4396,6 @@ }, "node_modules/axios": { "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -4746,9 +4691,8 @@ }, "node_modules/buffer-crc32": { "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } @@ -4759,30 +4703,26 @@ "license": "MIT" }, "node_modules/cacheable": { - "version": "1.8.10", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.8.10.tgz", - "integrity": "sha512-0ZnbicB/N2R6uziva8l6O6BieBklArWyiGx4GkwAhLKhSHyQtRfM9T1nx7HHuHDKkYB/efJQhz3QJ6x/YqoZzA==", + "version": "1.9.0", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.8.1", - "keyv": "^5.3.2" + "hookified": "^1.8.2", + "keyv": "^5.3.3" } }, "node_modules/cacheable-lookup": { "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.6.0" } }, "node_modules/cacheable-request": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", "dev": true, + "license": "MIT", "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -4798,8 +4738,6 @@ }, "node_modules/cacheable/node_modules/keyv": { "version": "5.3.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.3.tgz", - "integrity": "sha512-Rwu4+nXI9fqcxiEHtbkvoes2X+QfkTRo1TMkPfwzipGsJlJO/z69vqB4FNl9xJ3xCpAcbkvmEabZfPzrwN3+gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4878,8 +4816,6 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "dev": true, "funding": [ { @@ -4941,9 +4877,8 @@ }, "node_modules/chardet": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/chokidar": { "version": "4.0.1", @@ -4987,9 +4922,8 @@ }, "node_modules/cli-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, + "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" }, @@ -4999,9 +4933,8 @@ }, "node_modules/cli-spinners": { "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -5011,9 +4944,8 @@ }, "node_modules/cli-width": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "dev": true, + "license": "ISC", "engines": { "node": ">= 10" } @@ -5027,25 +4959,10 @@ "tiny-emitter": "^2.0.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/clone": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8" } @@ -5076,9 +4993,8 @@ }, "node_modules/clone-response": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", "dev": true, + "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" }, @@ -5099,8 +5015,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5112,8 +5026,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -5194,12 +5106,11 @@ }, "node_modules/concat-stream": { "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "dev": true, "engines": [ "node >= 0.8" ], + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", @@ -5223,9 +5134,8 @@ }, "node_modules/copy-dir": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", - "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/core-js": { "version": "3.33.2", @@ -5352,8 +5262,6 @@ }, "node_modules/css-functions-list": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", - "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", "dev": true, "license": "MIT", "engines": { @@ -5619,8 +5527,6 @@ }, "node_modules/debug": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5636,9 +5542,8 @@ }, "node_modules/decompress-response": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" }, @@ -5651,9 +5556,8 @@ }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -5675,9 +5579,8 @@ }, "node_modules/defaults": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "dev": true, + "license": "MIT", "dependencies": { "clone": "^1.0.2" }, @@ -5687,9 +5590,8 @@ }, "node_modules/defer-to-connect": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -5759,8 +5661,6 @@ }, "node_modules/dir-glob": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5772,9 +5672,8 @@ }, "node_modules/docker-compose": { "version": "0.24.8", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", - "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", "dev": true, + "license": "MIT", "dependencies": { "yaml": "^2.2.2" }, @@ -5897,9 +5796,8 @@ }, "node_modules/end-of-stream": { "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, + "license": "MIT", "dependencies": { "once": "^1.4.0" } @@ -6576,9 +6474,8 @@ }, "node_modules/esprima": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, + "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -6643,9 +6540,8 @@ }, "node_modules/external-editor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "dev": true, + "license": "MIT", "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -6657,9 +6553,8 @@ }, "node_modules/external-editor/node_modules/iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -6669,9 +6564,8 @@ }, "node_modules/extract-zip": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "concat-stream": "^1.6.2", "debug": "^2.6.9", @@ -6684,18 +6578,16 @@ }, "node_modules/extract-zip/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/extract-zip/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -6708,8 +6600,6 @@ }, "node_modules/fast-glob": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -6751,18 +6641,16 @@ }, "node_modules/fd-slicer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "dev": true, + "license": "MIT", "dependencies": { "pend": "~1.2.0" } }, "node_modules/figures": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -6775,9 +6663,8 @@ }, "node_modules/figures/node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -6845,8 +6732,6 @@ }, "node_modules/flatted": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -6940,16 +6825,13 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, - "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -7002,9 +6884,8 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -7046,9 +6927,8 @@ }, "node_modules/get-stream": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, + "license": "MIT", "dependencies": { "pump": "^3.0.0" }, @@ -7204,8 +7084,6 @@ }, "node_modules/global-modules": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", "dev": true, "license": "MIT", "dependencies": { @@ -7217,8 +7095,6 @@ }, "node_modules/global-prefix": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", "dev": true, "license": "MIT", "dependencies": { @@ -7232,8 +7108,6 @@ }, "node_modules/global-prefix/node_modules/which": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, "license": "ISC", "dependencies": { @@ -7271,8 +7145,6 @@ }, "node_modules/globby": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { @@ -7292,8 +7164,6 @@ }, "node_modules/globjoin": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", - "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", "dev": true, "license": "MIT" }, @@ -7317,9 +7187,8 @@ }, "node_modules/got": { "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", "dev": true, + "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", @@ -7346,7 +7215,7 @@ "license": "ISC" }, "node_modules/gradient-parser": { - "version": "0.1.5", + "version": "1.0.2", "engines": { "node": ">=0.10.0" } @@ -7459,16 +7328,12 @@ "license": "MIT" }, "node_modules/hookified": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.8.2.tgz", - "integrity": "sha512-5nZbBNP44sFCDjSoB//0N7m508APCgbQ4mGGo1KJGBYyCKNHfry1Pvd0JVHZIxjdnqn8nFRBAN/eFB6Rk/4w5w==", + "version": "1.9.0", "dev": true, "license": "MIT" }, "node_modules/html-tags": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", "dev": true, "license": "MIT", "engines": { @@ -7480,15 +7345,13 @@ }, "node_modules/http-cache-semantics": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/http2-wrapper": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", "dev": true, + "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" @@ -7592,10 +7455,8 @@ }, "node_modules/inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -7608,16 +7469,13 @@ }, "node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true, "license": "ISC" }, "node_modules/inquirer": { "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.0", @@ -7823,8 +7681,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -7861,9 +7717,8 @@ }, "node_modules/is-interactive": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -7911,8 +7766,6 @@ }, "node_modules/is-promise": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, "node_modules/is-regex": { @@ -8065,8 +7918,6 @@ }, "node_modules/isnumeric": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/isnumeric/-/isnumeric-0.2.0.tgz", - "integrity": "sha512-uSJoAwnN1eCKDFKi8hL3UCYJSkQv+NwhKzhevUPIn/QZ8ILO21f+wQnlZHU0eh1rsLO1gI4w/HQdeOSTKwlqMg==", "dev": true, "engines": { "node": ">= 0.8.x" @@ -8248,8 +8099,6 @@ }, "node_modules/known-css-properties": { "version": "0.36.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.36.0.tgz", - "integrity": "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==", "dev": true, "license": "MIT" }, @@ -8335,8 +8184,6 @@ }, "node_modules/lodash.truncate": { "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true, "license": "MIT" }, @@ -8347,9 +8194,8 @@ }, "node_modules/log-symbols": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", - "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^2.4.2" }, @@ -8359,9 +8205,8 @@ }, "node_modules/log-symbols/node_modules/ansi-styles": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -8371,9 +8216,8 @@ }, "node_modules/log-symbols/node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -8385,42 +8229,37 @@ }, "node_modules/log-symbols/node_modules/color-convert": { "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "1.1.3" } }, "node_modules/log-symbols/node_modules/color-name": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/log-symbols/node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/log-symbols/node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/log-symbols/node_modules/supports-color": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -8447,9 +8286,8 @@ }, "node_modules/lowercase-keys": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8477,8 +8315,6 @@ }, "node_modules/mathml-tag-names": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", - "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", "dev": true, "license": "MIT", "funding": { @@ -8501,8 +8337,6 @@ }, "node_modules/meow": { "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", "dev": true, "license": "MIT", "engines": { @@ -8556,18 +8390,16 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/mimic-response": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -8612,9 +8444,8 @@ }, "node_modules/mkdirp": { "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.6" }, @@ -8630,7 +8461,7 @@ } }, "node_modules/moment-timezone": { - "version": "0.5.45", + "version": "0.5.48", "license": "MIT", "dependencies": { "moment": "^2.29.4" @@ -8645,15 +8476,12 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mute-stream": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/nanoid": { "version": "3.3.8", @@ -8719,9 +8547,8 @@ }, "node_modules/normalize-url": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -8847,18 +8674,16 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, + "license": "ISC", "dependencies": { "wrappy": "1" } }, "node_modules/onetime": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -8887,9 +8712,8 @@ }, "node_modules/ora": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz", - "integrity": "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^3.0.0", "cli-cursor": "^3.1.0", @@ -8909,9 +8733,8 @@ }, "node_modules/ora/node_modules/chalk": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -8922,9 +8745,8 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8947,9 +8769,8 @@ }, "node_modules/p-cancelable": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -9055,9 +8876,8 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -9115,9 +8935,8 @@ }, "node_modules/pend": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/php-parser": { "version": "3.2.2", @@ -9199,9 +9018,8 @@ }, "node_modules/playwright": { "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "playwright-core": "1.55.0" }, @@ -9217,9 +9035,8 @@ }, "node_modules/playwright-core": { "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -9237,8 +9054,6 @@ }, "node_modules/postcss": { "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -9281,8 +9096,6 @@ }, "node_modules/postcss-color-hsl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postcss-color-hsl/-/postcss-color-hsl-2.0.0.tgz", - "integrity": "sha512-4DNpOj3NWejHtjV4mLxf+rmE1KA+IKDJH8QSThgJOrjGFuiqOPxkFSZX1RQJ+XQISZD3MW/JDaZoNnmxS9pSBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9293,8 +9106,6 @@ }, "node_modules/postcss-color-hsl/node_modules/ansi-styles": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", "dependencies": { @@ -9306,8 +9117,6 @@ }, "node_modules/postcss-color-hsl/node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9321,8 +9130,6 @@ }, "node_modules/postcss-color-hsl/node_modules/color-convert": { "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", "dependencies": { @@ -9331,15 +9138,11 @@ }, "node_modules/postcss-color-hsl/node_modules/color-name": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, "license": "MIT" }, "node_modules/postcss-color-hsl/node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", "engines": { @@ -9348,8 +9151,6 @@ }, "node_modules/postcss-color-hsl/node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", "engines": { @@ -9358,8 +9159,6 @@ }, "node_modules/postcss-color-hsl/node_modules/postcss": { "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", "dev": true, "license": "MIT", "dependencies": { @@ -9373,15 +9172,11 @@ }, "node_modules/postcss-color-hsl/node_modules/postcss-value-parser": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", - "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", "dev": true, "license": "MIT" }, "node_modules/postcss-color-hsl/node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -9390,8 +9185,6 @@ }, "node_modules/postcss-color-hsl/node_modules/supports-color": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", "dependencies": { @@ -9600,8 +9393,6 @@ }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, "license": "MIT" }, @@ -9923,15 +9714,11 @@ }, "node_modules/postcss-resolve-nested-selector": { "version": "0.1.6", - "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", - "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", "dev": true, "license": "MIT" }, "node_modules/postcss-safe-parser": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, "funding": [ { @@ -9957,8 +9744,6 @@ }, "node_modules/postcss-scss": { "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", "dev": true, "funding": [ { @@ -10038,8 +9823,6 @@ }, "node_modules/prismjs": { "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { "node": ">=6" @@ -10077,9 +9860,8 @@ }, "node_modules/pump": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -10119,9 +9901,8 @@ }, "node_modules/quick-lru": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -10176,8 +9957,6 @@ }, "node_modules/react-is": { "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", "dev": true, "license": "MIT" }, @@ -10290,8 +10069,6 @@ }, "node_modules/redux": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, "node_modules/reflect.getprototypeof": { @@ -10402,15 +10179,12 @@ }, "node_modules/requestidlecallback": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/requestidlecallback/-/requestidlecallback-0.3.0.tgz", - "integrity": "sha512-TWHFkT7S9p7IxLC5A1hYmAYQx2Eb9w1skrXmQ+dS1URyvR8tenMLl4lHbqEOUnpEYxNKpkVMXUgknVpBZWXXfQ==", "license": "MIT" }, "node_modules/require-directory": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10440,9 +10214,8 @@ }, "node_modules/resolve-alpn": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/resolve-cwd": { "version": "3.0.0", @@ -10480,9 +10253,8 @@ }, "node_modules/responselike": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", "dev": true, + "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" }, @@ -10492,9 +10264,8 @@ }, "node_modules/restore-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, + "license": "MIT", "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -10505,9 +10276,8 @@ }, "node_modules/restore-cursor/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/reusify": { "version": "1.0.4", @@ -10520,10 +10290,8 @@ }, "node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -10536,10 +10304,8 @@ }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -10557,9 +10323,8 @@ }, "node_modules/run-async": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -10588,15 +10353,12 @@ }, "node_modules/rungen": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/rungen/-/rungen-0.3.2.tgz", - "integrity": "sha512-zWl10xu2D7zoR8zSC2U6bg5bYF6T/Wk7rxwp8IPaJH7f0Ge21G03kNHVgHR7tyVkSSfAOG0Rqf/Cl38JftSmtw==", "license": "MIT" }, "node_modules/rxjs": { "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" }, @@ -10606,9 +10368,8 @@ }, "node_modules/rxjs/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "dev": true, + "license": "0BSD" }, "node_modules/safe-array-concat": { "version": "1.1.3", @@ -10936,8 +10697,6 @@ }, "node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", "engines": { @@ -10949,9 +10708,8 @@ }, "node_modules/simple-git": { "version": "3.28.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", - "integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==", "dev": true, + "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", @@ -10964,8 +10722,6 @@ }, "node_modules/slash": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { @@ -10974,8 +10730,6 @@ }, "node_modules/slice-ansi": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11063,8 +10817,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -11097,8 +10849,6 @@ }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, @@ -11253,8 +11003,6 @@ }, "node_modules/style-search": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", - "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", "dev": true, "license": "ISC" }, @@ -11275,8 +11023,6 @@ }, "node_modules/stylelint": { "version": "16.19.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.19.1.tgz", - "integrity": "sha512-C1SlPZNMKl+d/C867ZdCRthrS+6KuZ3AoGW113RZCOL0M8xOGpgx7G70wq7lFvqvm4dcfdGFVLB/mNaLFChRKw==", "dev": true, "funding": [ { @@ -11338,8 +11084,6 @@ }, "node_modules/stylelint-config-recommended": { "version": "16.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-16.0.0.tgz", - "integrity": "sha512-4RSmPjQegF34wNcK1e1O3Uz91HN8P1aFdFzio90wNK9mjgAI19u5vsU868cVZboKzCaa5XbpvtTzAAGQAxpcXA==", "dev": true, "funding": [ { @@ -11361,8 +11105,6 @@ }, "node_modules/stylelint-config-recommended-scss": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-14.1.0.tgz", - "integrity": "sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==", "dev": true, "license": "MIT", "dependencies": { @@ -11385,8 +11127,6 @@ }, "node_modules/stylelint-config-recommended-scss/node_modules/stylelint-config-recommended": { "version": "14.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", - "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", "dev": true, "funding": [ { @@ -11408,8 +11148,6 @@ }, "node_modules/stylelint-config-standard": { "version": "38.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-38.0.0.tgz", - "integrity": "sha512-uj3JIX+dpFseqd/DJx8Gy3PcRAJhlEZ2IrlFOc4LUxBX/PNMEQ198x7LCOE2Q5oT9Vw8nyc4CIL78xSqPr6iag==", "dev": true, "funding": [ { @@ -11434,8 +11172,6 @@ }, "node_modules/stylelint-config-standard-scss": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-14.0.0.tgz", - "integrity": "sha512-6Pa26D9mHyi4LauJ83ls3ELqCglU6VfCXchovbEqQUiEkezvKdv6VgsIoMy58i00c854wVmOw0k8W5FTpuaVqg==", "dev": true, "license": "MIT", "dependencies": { @@ -11457,8 +11193,6 @@ }, "node_modules/stylelint-config-standard-scss/node_modules/stylelint-config-recommended": { "version": "14.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", - "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", "dev": true, "funding": [ { @@ -11480,8 +11214,6 @@ }, "node_modules/stylelint-config-standard-scss/node_modules/stylelint-config-standard": { "version": "36.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz", - "integrity": "sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==", "dev": true, "funding": [ { @@ -11506,8 +11238,6 @@ }, "node_modules/stylelint-scss": { "version": "6.12.0", - "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.12.0.tgz", - "integrity": "sha512-U7CKhi1YNkM1pXUXl/GMUXi8xKdhl4Ayxdyceie1nZ1XNIdaUgMV6OArpooWcDzEggwgYD0HP/xIgVJo9a655w==", "dev": true, "license": "MIT", "dependencies": { @@ -11529,8 +11259,6 @@ }, "node_modules/stylelint-scss/node_modules/css-tree": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, "license": "MIT", "dependencies": { @@ -11543,22 +11271,16 @@ }, "node_modules/stylelint-scss/node_modules/css-tree/node_modules/mdn-data": { "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true, "license": "CC0-1.0" }, "node_modules/stylelint-scss/node_modules/mdn-data": { "version": "2.21.0", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.21.0.tgz", - "integrity": "sha512-+ZKPQezM5vYJIkCxaC+4DTnRrVZR1CgsKLu5zsQERQx6Tea8Y+wMx5A24rq8A8NepCeatIQufVAekKNgiBMsGQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/stylelint-scss/node_modules/postcss-selector-parser": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", "dependencies": { @@ -11571,8 +11293,6 @@ }, "node_modules/stylelint-use-logical": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/stylelint-use-logical/-/stylelint-use-logical-2.1.2.tgz", - "integrity": "sha512-4ffvPNk/swH4KS3izExWuzQOuzLmi0gb0uOhvxWJ20vDA5W5xKCjcHHtLoAj1kKvTIX6eGIN5xGtaVin9PD0wg==", "dev": true, "license": "CC0-1.0", "engines": { @@ -11584,8 +11304,6 @@ }, "node_modules/stylelint/node_modules/@csstools/selector-specificity": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", "dev": true, "funding": [ { @@ -11607,15 +11325,11 @@ }, "node_modules/stylelint/node_modules/balanced-match": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", - "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", "dev": true, "license": "MIT" }, "node_modules/stylelint/node_modules/cosmiconfig": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", "dependencies": { @@ -11641,8 +11355,6 @@ }, "node_modules/stylelint/node_modules/css-tree": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, "license": "MIT", "dependencies": { @@ -11654,31 +11366,25 @@ } }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.0.8.tgz", - "integrity": "sha512-FGXHpfmI4XyzbLd3HQ8cbUcsFGohJpZtmQRHr8z8FxxtCe2PcpgIlVLwIgunqjvRmXypBETvwhV4ptJizA+Y1Q==", + "version": "10.1.0", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.8" + "flat-cache": "^6.1.9" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.8.tgz", - "integrity": "sha512-R6MaD3nrJAtO7C3QOuS79ficm2pEAy++TgEUD8ii1LVlbcgZ9DtASLkt9B+RZSFCzm7QHDMlXPsqqB6W2Pfr1Q==", + "version": "6.1.9", "dev": true, "license": "MIT", "dependencies": { - "cacheable": "^1.8.9", + "cacheable": "^1.9.0", "flatted": "^3.3.3", - "hookified": "^1.8.1" + "hookified": "^1.8.2" } }, "node_modules/stylelint/node_modules/ignore": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", "dev": true, "license": "MIT", "engines": { @@ -11687,15 +11393,11 @@ }, "node_modules/stylelint/node_modules/mdn-data": { "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true, "license": "CC0-1.0" }, "node_modules/stylelint/node_modules/postcss-selector-parser": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", "dependencies": { @@ -11708,8 +11410,6 @@ }, "node_modules/stylelint/node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", "engines": { @@ -11733,8 +11433,6 @@ }, "node_modules/supports-hyperlinks": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", "dependencies": { @@ -11760,8 +11458,6 @@ }, "node_modules/svg-tags": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", - "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, "node_modules/svgo": { @@ -11790,8 +11486,6 @@ }, "node_modules/table": { "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -11832,9 +11526,8 @@ }, "node_modules/terminal-link": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" @@ -11848,9 +11541,8 @@ }, "node_modules/terminal-link/node_modules/supports-hyperlinks": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -11961,9 +11653,8 @@ }, "node_modules/through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tiny-emitter": { "version": "2.1.0", @@ -11971,9 +11662,8 @@ }, "node_modules/tmp": { "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "dev": true, + "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" }, @@ -12005,8 +11695,6 @@ }, "node_modules/ts-loader": { "version": "9.5.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", - "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -12025,9 +11713,7 @@ } }, "node_modules/ts-loader/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.1", "dev": true, "license": "ISC", "bin": { @@ -12039,8 +11725,6 @@ }, "node_modules/ts-loader/node_modules/source-map": { "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12119,9 +11803,8 @@ }, "node_modules/type-fest": { "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -12201,9 +11884,8 @@ }, "node_modules/typedarray": { "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/typescript": { "version": "5.7.3", @@ -12298,8 +11980,6 @@ }, "node_modules/units-css": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/units-css/-/units-css-0.4.0.tgz", - "integrity": "sha512-WijzYC+chwzg2D6HmNGUSzPAgFRJfuxVyG9oiY28Ei5E+g6fHoPkhXUr5GV+5hE/RTHZNd9SuX2KLioYHdttoA==", "dev": true, "license": "MIT", "dependencies": { @@ -12407,8 +12087,6 @@ }, "node_modules/viewport-dimensions": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/viewport-dimensions/-/viewport-dimensions-0.2.0.tgz", - "integrity": "sha512-94JqlKxEP4m7WO+N3rm4tFRGXZmXXwSPQCoV+EPxDnn8YAGiLU3T+Ha1imLreAjXsHl0K+ELnIqv64i1XZHLFQ==", "dev": true, "license": "MIT" }, @@ -12430,9 +12108,8 @@ }, "node_modules/wcwidth": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "dev": true, + "license": "MIT", "dependencies": { "defaults": "^1.0.3" } @@ -12545,11 +12222,11 @@ } }, "node_modules/webpack-remove-empty-scripts": { - "version": "1.0.4", + "version": "1.1.1", "dev": true, "license": "ISC", "dependencies": { - "ansis": "1.5.2" + "ansis": "4.0.0-node10" }, "engines": { "node": ">=12.14" @@ -12733,23 +12410,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", @@ -12769,14 +12429,11 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/write-file-atomic": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { @@ -12787,15 +12444,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "dev": true, @@ -12803,9 +12451,8 @@ }, "node_modules/yaml": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -12813,38 +12460,10 @@ "node": ">= 14.6" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/yauzl": { "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "dev": true, + "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" diff --git a/package.json b/package.json index 16738a3d7..1741bc21d 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.9.3", + "version": "3.10.0-dev.1", "main": "src/dist/edit.js", "directories": { "test": "tests" diff --git a/src/phpcs.xml b/phpcs.xml similarity index 83% rename from src/phpcs.xml rename to phpcs.xml index 3d7e8ca71..5d3d57ccc 100644 --- a/src/phpcs.xml +++ b/phpcs.xml @@ -8,8 +8,10 @@ - - + + + + @@ -34,21 +36,12 @@ - - - - *\.php$ - - - class-*\.php$ - - diff --git a/src/code-snippets.php b/src/code-snippets.php index ad9b318f5..639e68a81 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.9.3 + * Version: 3.10.0-dev.1 * Requires PHP: 7.4 - * Requires at least: 5.0 + * Requires at least: 5.5 * - * @version 3.9.3 + * @version 3.10.0-dev.1 * @package Code_Snippets * @author Shea Bunge * @copyright 2012-2024 Code Snippets Pro @@ -37,7 +37,7 @@ * * @const string */ - define( 'CODE_SNIPPETS_VERSION', '3.9.3' ); + define( 'CODE_SNIPPETS_VERSION', '3.10.0-dev.1' ); /** * The full path to the main file of this plugin. @@ -58,7 +58,7 @@ */ define( 'CODE_SNIPPETS_PRO', false ); - require_once dirname( __FILE__ ) . '/php/load.php'; + require_once dirname( __FILE__ ) . '/php/Core/load.php'; } else { - require_once dirname( __FILE__ ) . '/php/deactivation-notice.php'; + require_once dirname( __FILE__ ) . '/php/Core/deactivation-notice.php'; } diff --git a/src/composer.json b/src/composer.json index 2a10819af..29777d318 100644 --- a/src/composer.json +++ b/src/composer.json @@ -21,10 +21,9 @@ "source": "https://github.com/codesnippetspro/code-snippets" }, "autoload": { - "classmap": [ - "php/" - ], + "files": ["php/Admin/Menus/Manage_Menu.php"], "psr-4": { + "Code_Snippets\\": "php/" } }, "require": { diff --git a/src/css/common/_banners.scss b/src/css/common/_banners.scss new file mode 100644 index 000000000..8769e0ac7 --- /dev/null +++ b/src/css/common/_banners.scss @@ -0,0 +1,43 @@ +@use '../common/theme'; +@use 'sass:map'; +@use 'sass:list'; + +@mixin banners { + .banner { + border: 0; + border-radius: 5px; + display: flex; + align-items: center; + padding: 6px 10px; + gap: 8px; + margin: 0; + + .banner-dismiss { + position: unset; + margin-inline-start: auto; + padding: 0; + + &, &::before { + color: inherit; + } + } + + .wp-core-ui &.is-dismissible { + position: unset; + padding-inline-end: 10px; + } + } + + @each $name, $colors in theme.$notices { + .banner-#{$name} { + color: list.nth($colors, 2); + background-color: list.nth($colors, 1); + } + } + + .banner-success::before { + content: '✓'; + font-weight: bold; + font-size: 16px; + } +} diff --git a/src/css/common/_theme.scss b/src/css/common/_theme.scss index 4cb4346ce..7ef72fe22 100644 --- a/src/css/common/_theme.scss +++ b/src/css/common/_theme.scss @@ -33,6 +33,9 @@ $notices: ( success: #d3e9d3 #377a37, warning: #f2ebc3 #b0730a, error: #f8d7da #721c24, + info: #d2e6f4 #2b71a3, + neutral: #e2e5e5 #6c7e7e, + special: #dfc5ef #6e249c ); @function contrasting-text-color($bg-color) { diff --git a/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss new file mode 100644 index 000000000..7346d8293 --- /dev/null +++ b/src/css/common/_toolbar.scss @@ -0,0 +1,113 @@ +@use 'upsell'; + +$toolbar-block-size: 150px; +$wpbody-block-indent: 20px; + +#wpbody { + padding-block-start: $toolbar-block-size; +} + +.code-snippets-toolbar { + color: #2c3337; + background: #fff; + font-family: 'SF Pro', sans-serif; + margin-inline-start: -$wpbody-block-indent; + position: absolute; + inline-size: calc(100% + $wpbody-block-indent); + block-size: $toolbar-block-size; + inset-block-start: 0; + display: flex; + flex-direction: column; + + ul, li { + margin: 0; + padding: 0; + list-style: none; + } +} + +.code-snippets-toolbar-upper { + justify-content: space-between; + block-size: 77px; + padding-inline: 35px; + display: flex; + align-items: center; + + .logo { + display: flex; + align-items: center; + gap: 8px; + + img { + block-size: 42px; + } + } + + h1 { + font-size: 18px; + font-weight: 700; + line-height: 1.5; + } + + ul { + display: flex; + gap: 22px; + align-items: center; + } + + a { + color: inherit; + text-decoration: none; + + &.active-link, &:hover, &:focus, &:active { + color: #2271b1; + } + } +} + +.code-snippets-toolbar-lower { + border: 1px solid #c3c4c7; + + ul { + display: flex; + align-items: end; + } + + li a { + display: flex; + margin: 0; + padding: 24px 24px 20px; + color: inherit; + gap: 12px; + align-items: center; + font-size: 16px; + text-decoration: none; + font-weight: bold; + box-sizing: border-box; + transition: border unset; + border-block-end: 4px solid transparent; + cursor: pointer; + + &.active-link, &:hover, &:focus, &:active { + color: #2271b1; + border-block-end-color: currentcolor; + } + } + + li:last-of-type { + margin-inline-start: auto; + } + + .pro-chip { + color: #d46f4d; + text-transform: uppercase; + border: 2px solid currentcolor; + border-radius: 999px; + font-size: 12px; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + padding: 3px 9px; + } +} diff --git a/src/css/edit.scss b/src/css/edit.scss index 2485a6d43..e47bfd312 100644 --- a/src/css/edit.scss +++ b/src/css/edit.scss @@ -10,13 +10,14 @@ @use 'common/tooltips'; @use 'common/modal'; @use 'common/upsell'; +@use 'common/toolbar'; @use 'edit/form'; @use 'edit/sidebar'; @use 'edit/editor'; @use 'edit/conditions'; @use 'edit/gpt'; -.notice.error blockquote { +.banner.error blockquote { margin-block-end: 0; } diff --git a/src/css/edit/_editor.scss b/src/css/edit/_editor.scss index 8063c8f68..2fd9e9a2b 100644 --- a/src/css/edit/_editor.scss +++ b/src/css/edit/_editor.scss @@ -59,9 +59,9 @@ } .snippet-editor-help { - position: absolute; inset-inline-end: 5px; inset-block-start: 5px; + position: absolute; td { &:first-child { @@ -72,18 +72,27 @@ white-space: nowrap; } } +} + +.mac-keyboard-shortcut { + text-align: end; - .mac-key { - display: none; + kbd { + font-family: inherit; + padding: 0; + margin: 0; } +} - .platform-mac { - .mac-key { - display: inline; - } +.pc-keyboard-shortcut { + display: inline-flex; + align-items: center; + gap: 3px; - .pc-key { - display: none; - } + kbd { + font-family: inherit; + margin: 0; + padding-block: 0; + padding-inline: 2px; } } diff --git a/src/css/edit/_gpt.scss b/src/css/edit/_gpt.scss index 70702bb75..2caa70f46 100644 --- a/src/css/edit/_gpt.scss +++ b/src/css/edit/_gpt.scss @@ -12,7 +12,7 @@ box-shadow: none; } - .notice { + .banner { margin-inline: 0; } } diff --git a/src/css/manage/_cloud.scss b/src/css/manage-legacy.scss similarity index 63% rename from src/css/manage/_cloud.scss rename to src/css/manage-legacy.scss index a24a6a8b1..167f19b8c 100644 --- a/src/css/manage/_cloud.scss +++ b/src/css/manage-legacy.scss @@ -1,5 +1,218 @@ -@use '../common/theme'; -@use '../common/tooltips'; +/** + * Custom styling for the snippets table + */ + +@use 'sass:map'; +@use 'sass:color'; +@use 'common/theme'; +@use 'common/badges'; +@use 'common/switch'; +@use 'common/select'; + +.column-name, +.column-type { + .dashicons { + font-size: 16px; + inline-size: 16px; + block-size: 16px; + vertical-align: middle; + } + + .dashicons-clock { + vertical-align: middle; + } +} + +.active-snippet .column-name > a { + font-weight: 600; +} + +.active-snippet { + td, th { + background-color: rgba(#78c8e6, 0.06); + } + + th.check-column { + border-inline-start: 2px solid #2ea2cc; + } +} + +.column-priority input { + appearance: none; + background: none; + border: none; + box-shadow: none; + inline-size: 4em; + color: #666; + text-align: center; + + &:hover, &:focus, &:active { + color: #000; + background-color: #f5f5f5; + background-color: rgb(0 0 0 / 10%); + border-radius: 6px; + } + + &:disabled { + color: inherit; + } +} + +.clear-filters { + vertical-align: baseline !important; +} + +.snippets { + td.column-id { + text-align: center; + } + + tr { + background: #fff; + } + + ol, ul { + margin: 0 0 1.5em 1.5em; + } + + ul { + list-style: disc; + } + + th.sortable a, th.sorted a { + display: flex; + flex-direction: row; + } + + .row-actions { + color: #ddd; + position: relative; + inset-inline-start: 0; + } + + .column-activate { + padding-inline-end: 0 !important; + } + + .clear-filters { + vertical-align: middle; + } + + tfoot th.check-column { + padding: 13px 0 0 3px; + } + + thead th.check-column, + tfoot th.check-column, + .inactive-snippet th.check-column { + padding-inline-start: 5px; + } + + .active-snippet, .inactive-snippet { + td, th { + padding: 10px 9px; + border: none; + box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); + } + } + + tr.active-snippet + tr.inactive-snippet th, + tr.active-snippet + tr.inactive-snippet td { + border-block-start: 1px solid rgb(0 0 0 / 3%); + box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; + } + + &, #all-snippets-table, #search-snippets-table { + a.delete:hover { + border-block-end: 1px solid #f00; + color: #f00; + } + } + + #wpbody-content & .column-name { + white-space: nowrap; /* prevents wrapping of snippet title */ + } +} + +td.column-description { + max-inline-size: 700px; + + pre { + white-space: unset; + } +} + +.inactive-snippet { + @include theme.link-colors(#579); +} + +@media screen and (width <= 782px) { + p.search-box { + float: inline-start; + position: initial; + margin: 1em 0 0; + block-size: auto; + } +} + +.wp-list-table .is-expanded td.column-activate.activate { + /* fix for mobile layout */ + display: table-cell !important; +} + +.nav-tab-wrapper + .subsubsub, p.search-box { + margin: 10px 0 0; +} + +.snippet-type-description { + border-block-end: 1px solid #ccc; + margin: 0; + padding: 1em 0; +} + +.code-snippets-notice a.banner-dismiss { + text-decoration: none; +} + +.refresh-button-container { + display: flex; + align-items: center; + justify-content: flex-start; + margin-block: 15px -39px; + gap: 7px; +} + +#refresh-button { + inline-size: 30px; + padding: 0; + font-size: 20px; + line-height: 1.4; +} + +.wrap h2.nav-tab-wrapper { + display: flex; + flex-flow: row wrap-reverse; + gap: 0.5em; + + .nav-tab { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + margin: 0; + } +} + +@media screen and (width <= 1190px) { + .nav-tab { + .snippet-label { + display: none; + } + } +} + +/** Cloud */ + .cloud-legend-tooltip { h3 { diff --git a/src/css/manage.scss b/src/css/manage.scss index f3a4a3737..bf7facd99 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -1,18 +1,45 @@ -/** - * Custom styling for the snippets table - */ - -@use 'sass:map'; -@use 'sass:color'; @use 'common/theme'; @use 'common/badges'; @use 'common/switch'; @use 'common/direction'; @use 'common/select'; -@use 'manage/cloud'; +@use 'common/upsell'; +@use 'common/toolbar'; +@use 'prism'; +@use 'manage/snippets-table'; +@use 'manage/cloud-community'; + +.wrap h1:first-of-type { + font-size: 1.6rem; + padding: 0; + margin-block: 50px 1.4rem; +} + +.create-snippet-button { + margin-inline-start: auto; + float: inline-end; +} + +.nav-tab { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + + @media screen and (width <= 1190px) { + span:first-child:not(:last-child) { + display: none; + } + } +} + +.nav-tab-wrapper + .subsubsub, p.search-box { + margin-block: 10px 0; + margin-inline: 0; +} -.column-name, -.column-type { +.name-column, +.type-column { .dashicons { font-size: 16px; inline-size: 16px; @@ -25,7 +52,7 @@ } } -.active-snippet .column-name > .snippet-name { +.active-snippet .name-column > .snippet-name { font-weight: 600; } @@ -39,7 +66,7 @@ } } -.column-priority input { +.priority-column input { appearance: none; background: none; border: none; @@ -50,9 +77,9 @@ &:hover, &:focus, &:active { color: #000; - background-color: #f5f5f5; background-color: rgb(0 0 0 / 10%); border-radius: 6px; + appearance: unset; } &:disabled { @@ -60,11 +87,22 @@ } } -.clear-filters { - vertical-align: baseline !important; +.snippets-table-heading { + display: flex; + align-items: center; + margin-block: 50px 1.4rem; + + h1 { + font-size: 1.6rem; + padding: 0; + } + + .button-primary { + margin-inline-start: auto; + } } -.snippets { +.wp-list-table { td.column-id { text-align: center; } @@ -91,6 +129,15 @@ color: #ddd; position: relative; inset-inline-start: 0; + + .button-link { + min-block-size: unset; + line-height: unset; + + &:hover { + background: none; + } + } } .column-activate { @@ -127,8 +174,10 @@ box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; } - &, #all-snippets-table, #search-snippets-table { - a.delete:hover { + .delete { + color: #b32d2e; + + &:hover, &:focus, &:active { border-block-end: 1px solid #f00; color: #f00; } @@ -136,15 +185,12 @@ #wpbody-content & .column-name { white-space: nowrap; /* prevents wrapping of snippet title */ + vertical-align: top; } } -td.column-description { - max-inline-size: 700px; - - pre { - white-space: unset; - } +.wp-core-ui .button.clear-filters { + vertical-align: baseline; } .inactive-snippet { diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss new file mode 100644 index 000000000..47f12057f --- /dev/null +++ b/src/css/manage/_cloud-community.scss @@ -0,0 +1,141 @@ +@use '../common/theme'; +@use '../common/banners'; + +.cloud-search { + @include banners.banners; + + .banner { + justify-content: center; + } +} + +.cloud-search-form { + display: flex; + gap: 8px; + margin-block: 31px 47px; + block-size: 54px; + + select { + flex: 0 0 250px; + } + + .button { + flex: 0 0 165px; + } + + .cloud-search-query { + flex: 1; + position: relative; + + input { + inline-size: 100%; + block-size: 100%; + } + + .components-spinner { + position: absolute; + inset-inline-end: 1.5em; + inset-block-start: 25%; + } + } +} + +.tablenav.top { + display: flex; + gap: 20px; + block-size: 40px; + margin-block-end: 31px; + align-items: center; + + select { + inline-size: 245px; + block-size: 100%; + } + + .tablenav-pages { + margin-inline-start: auto; + } +} + +.cloud-search-results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(580px, 1fr)); + gap: 20px; +} + +.cloud-search-result { + background: #fff; + border: 1px solid #c3c4c7; + display: flex; + flex-flow: column; + box-sizing: border-box; + + a { + text-decoration: none; + } + + p:last-child { + margin-block-end: 0; + } + + .cloud-snippet { + padding: 24px; + } + + .cloud-snippet-meta { + display: flex; + gap: 16px; + } + + footer { + display: flex; + gap: 8px; + background: #f6f7f7; + margin-block-start: auto; + border-block-start: 1px solid #c3c4c7; + padding-inline: 24px; + padding-block: 12px; + align-items: center; + + .button:first-of-type { + margin-inline-start: auto; + } + } + + .cloud-snippet-votes { + .dashicons { + color: theme.$accent; + padding-inline-end: 8px; + } + } + + $status-colors: ( + public #64baba, + private #cc96fb, + unverified #ea835e, + ai-verified #1cabcf, + pro-verified #7cd68a, + ); + + .cloud-snippet-status { + text-transform: uppercase; + color: #646970; + font-size: 12px; + font-weight: 700; + + &::before { + content: ''; + block-size: 10px; + inline-size: 10px; + border-radius: 50%; + display: inline-block; + margin-inline-end: 8px; + } + } + + @each $status, $color in $status-colors { + .cloud-snippet-status-#{$status}::before { + background: $color; + } + } +} diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss new file mode 100644 index 000000000..b0cdbc9bb --- /dev/null +++ b/src/css/manage/_snippets-table.scss @@ -0,0 +1,143 @@ +@use '../common/theme'; + +.name-column, +.type-column { + .dashicons { + font-size: 16px; + inline-size: 16px; + block-size: 16px; + vertical-align: middle; + } + + .dashicons-clock { + vertical-align: middle; + } +} + +.active-snippet .name-column > a { + font-weight: 600; +} + +.active-snippet { + td, th { + background-color: rgba(#78c8e6, 0.06); + } + + th.check-column { + border-inline-start: 2px solid #2ea2cc; + } +} + +.priority-column input { + appearance: none; + background: none; + border: none; + box-shadow: none; + inline-size: 4em; + color: #666; + text-align: center; + + &:hover, &:focus, &:active { + color: #000; + background-color: rgb(0 0 0 / 10%); + border-radius: 6px; + appearance: unset; + } + + &:disabled { + color: inherit; + } +} + +.wp-list-table { + td.column-id { + text-align: center; + } + + tr { + background: #fff; + } + + ol, ul { + margin-block: 0 1.5em; + margin-inline: 1.5em 0; + } + + ul { + list-style: disc; + } + + th.sortable a, th.sorted a { + display: flex; + flex-direction: row; + } + + .row-actions { + color: #ddd; + position: relative; + inset-inline-start: 0; + + .button-link { + min-block-size: unset; + line-height: unset; + + &:hover { + background: none; + } + } + } + + .column-activate { + padding-inline-end: 0 !important; + } + + .clear-filters { + vertical-align: middle; + } + + tfoot th.check-column { + padding: 13px 0 0 3px; + } + + thead th.check-column, + tfoot th.check-column, + .inactive-snippet th.check-column { + padding-inline-start: 5px; + } + + .active-snippet, .inactive-snippet { + td, th { + padding: 10px 9px; + border: none; + box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); + } + } + + tr.active-snippet + tr.inactive-snippet th, + tr.active-snippet + tr.inactive-snippet td { + border-block-start: 1px solid rgb(0 0 0 / 3%); + box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; + } + + .delete { + color: #b32d2e; + + &:hover, &:focus, &:active { + border-block-end: 1px solid #f00; + color: #f00; + } + } + + #wpbody-content & .column-name { + white-space: nowrap; /* prevents wrapping of snippet title */ + vertical-align: top; + } +} + +.wp-core-ui .button.clear-filters { + vertical-align: baseline; +} + +.inactive-snippet { + @include theme.link-colors(#579); +} diff --git a/src/css/settings.scss b/src/css/settings.scss index e2a040ae4..09b962131 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -1,4 +1,5 @@ @use 'common/codemirror'; +@use 'common/toolbar'; $sections: general, editor, debug, version-switch; @@ -16,7 +17,7 @@ p.submit { margin-block-end: 1em; } -input[type="number"] { +input[type='number'] { inline-size: 4em; } @@ -84,7 +85,7 @@ body.js { color: #dc3232; } -.wrap[data-active-tab="license"] .submit { +.wrap[data-active-tab='license'] .submit { display: none; } diff --git a/src/css/welcome.scss b/src/css/welcome.scss index 94a56851e..fdd34ca30 100644 --- a/src/css/welcome.scss +++ b/src/css/welcome.scss @@ -1,62 +1,43 @@ @use 'sass:color'; @use 'common/theme'; @use 'common/badges'; +@use 'common/toolbar'; $breakpoint: 1060px; -.csp-welcome-wrap { +.code-snippets-welcome { padding: 25px; - h1, h2, h3 { - font-weight: 700; - margin-block: 10px; - - .dashicons { - font-size: 90%; - line-height: inherit; - inline-size: auto; - } - } - h1 { - font-size: 1.6rem; + font-size: 32px; + margin-block: 48px 32px; } +} - h2 { - font-size: 1.4rem; - } +.code-snippets-updates { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; - .dashicons-external { - float: inline-end; - color: #666; + > * { + background: #fff; + border-radius: 8px; + padding: 32px; } -} - -.csp-welcome-header { - display: flex; - flex-flow: row wrap; - justify-content: space-between; - align-items: center; header { display: flex; - flex-direction: row; + flex-flow: row; + justify-content: space-between; align-items: center; - gap: 10px; + border-block-end: 1px solid #e2e2e4; + margin-block-end: 20px; + padding-block-end: 20px; + } - h1 { - font-size: 1.4rem; - font-weight: bold; - line-height: 1; - margin: 0; - - span { - text-decoration: underline theme.$primary wavy 3px; - text-decoration-skip-ink: none; - text-underline-offset: 11px; - text-transform: capitalize; - } - } + h2 { + font-size: 18px; + margin: 0; } } @@ -70,301 +51,191 @@ $breakpoint: 1060px; gap: 5px; margin: 0; } +} - li { - margin-block-end: 0; - } - - li a { - margin-block: 10px; - align-items: center; - border-width: 1px; - border-style: solid; - color: white; - cursor: pointer; - display: flex; - font-weight: 400; - gap: 3px; - text-decoration: none; - transition: all .1s ease-in-out; - border-radius: 3px; - padding: 8px; - - &:hover { - background: transparent; - } - - .dashicons, svg { - text-decoration: none; - margin-block-start: -1px; - margin-inline-start: 3px; - } +.code-snippets-hero { + display: flex; + flex-flow: column; - svg { - fill: #fff; - inline-size: 20px; - block-size: 20px; - font-size: 20px; - vertical-align: top; - } + figure { + margin-block: 1em 0; + margin-inline: 0; + overflow: hidden; + border-radius: 0.5rem; + position: relative; + block-size: auto; + background: #efefef; + flex: 1; + text-align: center; - &:hover svg { - fill: currentcolor; + img { + inline-size: 100%; + block-size: 100%; + overflow: hidden; + object-fit: cover; } } +} - $link-colors: ( - pro: theme.$secondary, - cloud: #08c5d1, - resources: #424242, - discord: theme.$brand-discord, - facebook: theme.$brand-facebook - ); +.code-snippets-partners, +.code-snippets-articles { + display: flex; + flex-direction: row;; + gap: 16px; - @each $link-name, $color in $link-colors { - .csp-link-#{$link-name} { - background: $color; - border-color: $color; + figure { + margin: 0; + padding: 0; - &:hover { - color: $color; - } + img { + inline-size: 100%; + block-size: 220px; + overflow: hidden; + object-fit: cover; } } } -.csp-cards { - display: grid; - grid-auto-rows: 1fr; - grid-template-columns: repeat(4, 1fr); - gap: 40px 15px; - - @media (width <= $breakpoint) { - grid-template-columns: 1fr !important; - } -} - -.csp-card { - border: 1px solid theme.$outline; - background: white; - border-radius: 10px; +.code-snippets-card { + flex: 1; + background: #fff; + border-radius: 8px; display: flex; flex-flow: column; -} - -a.csp-card { - text-decoration: none; + color: #2c3337; - &:hover { - background: color.adjust(theme.$primary, $lightness: 55%); - transition: .5s background-color; - box-shadow: 0 1px 1px rgb(255 255 255 / 50%); - - .dashicons-external { - color: #000; - } + figure img { + border-start-start-radius: 8px; + border-start-end-radius: 8px; } } -.csp-section-changes { - border: 1px solid theme.$outline; - border-inline-start: 0; - border-inline-end: 0; - padding-block: 40px 50px; - padding-inline: 0; - display: flex; - flex-direction: column; - row-gap: 20px; - margin-block-start: 30px; - - .csp-cards { - grid-template-columns: 2fr 1fr; - gap: 20px; +.code-snippets-partners { + header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 24px; } +} - .csp-card { - padding: 20px; - box-shadow: 0 1px 1px rgb(0 0 0 / 5%); - - h2 { - color: theme.$primary; - } +.code-snippets-articles { + header { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 24px; + gap: 14px; + block-size: 100%; } - .csp-changelog-wrapper { - overflow-y: scroll; + .button { + margin-block-start: auto; } - .csp-section-changelog { - font-size: 0.9rem; - line-height: 1.5; - color: #333; - block-size: 400px; - - h3 { - float: inline-end; - color: #666; - } - - h4 { - margin-block: 30px 10px; - margin-inline: 0; - } - - ul { - margin-block-start: 5px; - } + h2 { + font-size: 18px; + margin: 0; + } - li { - display: grid; - grid-template-columns: 40px 1fr; - grid-template-rows: 1fr; - align-items: baseline; - gap: 7px; - } + p { + margin: 0; + } - li .badge { - text-align: center; - } + .item-category { + color: white; + background: theme.$secondary; + display: block; + font-size: 12px; + letter-spacing: 1px; + margin-block: 0; + text-transform: uppercase; + inline-size: fit-content; + padding: 3px 10px; + border-radius: 3px; + font-weight: bold; + } +} - > article::after { - border-block-end: 1px solid #666; - content: ' '; - display: block; - inline-size: 50%; - margin-block: 3em 0; - margin-inline: auto; - } +.code-snippets-changelog-entries { + font-size: 0.9rem; + line-height: 1.5; + overflow-y: scroll; + max-block-size: 500px; + color: #2c3337; + padding-inline-end: 1em; - > article:last-child { - padding-block-end: 1px; + header { + border: 0; + margin: 0; + padding: 0; - &::after { - border: 0; - } + p { + font-size: 14px; } } - figure { - margin-block: 1em 0; - margin-inline: 0; - overflow: hidden; - border-radius: 0.5rem; - border: 1px solid grey; - position: relative; - block-size: auto; - background: #646970; - - img { - inline-size: 100%; - block-size: 100%; - overflow: hidden; - object-fit: cover; - } + h3 { + font-size: 16px; } - .dashicons-lightbulb { - color: #f1c40f; + h4 { + font-size: 12px; + text-transform: uppercase; + margin: 0 0 16px; + display: flex; + gap: 5px; + align-items: center; } - .dashicons-chart-line { - color: #85144b; + ul { + margin-block-start: 5px; } - .dashicons-buddicons-replies { - color: #3d9970; + li { + display: grid; + grid-template-columns: 40px 1fr; + grid-template-rows: 1fr; + align-items: baseline; + gap: 10px; } -} - -.csp-section-links { - padding-block: 40px 50px; - padding-inline: 0; - .csp-card { - margin-block-start: 20px; - justify-content: flex-start; - color: black; - position: relative; - overflow: hidden; - row-gap: 10px; - padding: 1rem; - inline-size: 85%; - - header { - flex: 1; - } - - figure { - margin-block: 1em 0; - margin-inline: 0; - - img { - border-radius: 5px; - inline-size: 100%; - block-size: 100%; - max-block-size: 300px; - overflow: hidden; - object-fit: cover; - } - } - - .csp-card-item-category { - color: white; - background: theme.$secondary; - display: block; - font-size: .9rem; - letter-spacing: 1px; - margin-block: 0; - text-transform: uppercase; - inline-size: fit-content; - padding-block: 5px; - padding-inline: 15px; - border-radius: 50px; - } - - h3 { - font-size: 1.7rem; - color: theme.$primary; - line-height: normal; - } - - .csp-card-item-description { - color: #51525c; - font-size: 1rem; - font-weight: 300; - } - - footer { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } + > article::after { + border-block-end: 1px solid #666; + content: ' '; + display: block; + inline-size: 50%; + margin-block: 3em; + margin-inline: auto; } - &.csp-section-partners { - border-block-start: 1px solid theme.$outline; + > article:last-child { + padding-block-end: 1px; - header { - display: flex; - flex-direction: row-reverse; - justify-content: space-between; - align-items: center; + &::after { + border: 0; } } - &.csp-section-articles { - h2 { - font-size: 1.1rem; - } + $icon-colors: ( + lightbulb #f1c40f, + chart-line #85144b, + buddicons-replies #3d9970, + remove #ffbf00, + trash #c0c0c0, + shield #0074d9, + open-folder #5d2f27 + ); - figure img { - aspect-ratio: 1; + @each $icon, $color in $icon-colors { + .dashicons-#{$icon} { + color: $color; } } } -.csp-loading-spinner { +.code-snippets-loading-spinner { block-size: 0; inline-size: 0; padding: 15px; diff --git a/src/js/components/ConditionModal/ConditionModalButton.tsx b/src/js/components/EditMenu/ConditionModal/ConditionModalButton.tsx similarity index 80% rename from src/js/components/ConditionModal/ConditionModalButton.tsx rename to src/js/components/EditMenu/ConditionModal/ConditionModalButton.tsx index 270d3e38f..da567dd6c 100644 --- a/src/js/components/ConditionModal/ConditionModalButton.tsx +++ b/src/js/components/EditMenu/ConditionModal/ConditionModalButton.tsx @@ -1,11 +1,11 @@ import React from 'react' import classnames from 'classnames' import { __ } from '@wordpress/i18n' -import { isLicensed } from '../../utils/screen' -import { isCondition } from '../../utils/snippets/snippets' -import { Badge } from '../common/Badge' -import { Button } from '../common/Button' -import { useSnippetForm } from '../../hooks/useSnippetForm' +import { isLicensed } from '../../../utils/screen' +import { isCondition } from '../../../utils/snippets/snippets' +import { Badge } from '../../common/Badge' +import { Button } from '../../common/Button' +import { useSnippetForm } from '../../../hooks/useSnippetForm' import type { Dispatch, SetStateAction } from 'react' export interface ConditionModalButtonProps { diff --git a/src/js/components/EditMenu/EditMenu.tsx b/src/js/components/EditMenu/EditMenu.tsx new file mode 100644 index 000000000..2272559a9 --- /dev/null +++ b/src/js/components/EditMenu/EditMenu.tsx @@ -0,0 +1,5 @@ +import React from 'react' +import { SnippetForm } from './SnippetForm' + +export const EditMenu = () => + diff --git a/src/js/components/EditorSidebar/EditorSidebar.tsx b/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx similarity index 67% rename from src/js/components/EditorSidebar/EditorSidebar.tsx rename to src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx index f9808cd41..8cb34d3d3 100644 --- a/src/js/components/EditorSidebar/EditorSidebar.tsx +++ b/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx @@ -1,9 +1,11 @@ import React from 'react' import { Spinner } from '@wordpress/components' -import { isRTL } from '@wordpress/i18n' -import { useSnippetForm } from '../../hooks/useSnippetForm' -import { isNetworkAdmin } from '../../utils/screen' -import { isCondition } from '../../utils/snippets/snippets' +import { __, isRTL } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' +import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { isNetworkAdmin } from '../../../utils/screen' +import { isCondition } from '../../../utils/snippets/snippets' +import { DeleteButton } from '../../common/DeleteButton' import { ConditionModalButton } from '../ConditionModal/ConditionModalButton' import { SnippetLocationInput } from '../SnippetForm/fields/SnippetLocationInput' import { Notices } from '../SnippetForm/page/Notices' @@ -12,7 +14,6 @@ import { MultisiteSharingSettings } from './controls/MultisiteSharingSettings' import { ExportButtons } from './actions/ExportButtons' import { SubmitButtons } from './actions/SubmitButtons' import { ActivationSwitch } from './controls/ActivationSwitch' -import { DeleteButton } from './actions/DeleteButton' import { PriorityInput } from './controls/PriorityInput' import { RTLControl } from './controls/RTLControl' import type { Dispatch, SetStateAction } from 'react' @@ -22,7 +23,7 @@ export interface EditorSidebarProps { } export const EditorSidebar: React.FC = ({ setIsUpgradeDialogOpen }) => { - const { snippet, isWorking } = useSnippetForm() + const { snippet, isWorking, setIsWorking, handleRequestError } = useSnippetForm() return (
@@ -41,7 +42,15 @@ export const EditorSidebar: React.FC = ({ setIsUpgradeDialog {snippet.id ?
- + + window.location.replace(addQueryArgs(window.CODE_SNIPPETS?.urls.manage, { result: 'deleted' }))} + onError={error => + handleRequestError(error, __('Could not delete snippet.', 'code-snippets'))} + />
: null}
diff --git a/src/js/components/EditorSidebar/actions/ExportButtons.tsx b/src/js/components/EditMenu/EditorSidebar/actions/ExportButtons.tsx similarity index 77% rename from src/js/components/EditorSidebar/actions/ExportButtons.tsx rename to src/js/components/EditMenu/EditorSidebar/actions/ExportButtons.tsx index d4b08d818..25ba92d54 100644 --- a/src/js/components/EditorSidebar/actions/ExportButtons.tsx +++ b/src/js/components/EditMenu/EditorSidebar/actions/ExportButtons.tsx @@ -1,11 +1,11 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { useRestAPI } from '../../../hooks/useRestAPI' -import { Button } from '../../common/Button' -import { downloadSnippetExportFile } from '../../../utils/files' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import type { Snippet } from '../../../types/Snippet' -import type { SnippetsExport } from '../../../types/schema/SnippetsExport' +import { useRestAPI } from '../../../../hooks/useRestAPI' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { downloadSnippetExportFile } from '../../../../utils/files' +import { Button } from '../../../common/Button' +import type { SnippetsExport } from '../../../../types/schema/SnippetsExport' +import type { Snippet } from '../../../../types/Snippet' interface ExportButtonProps { name: string diff --git a/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx b/src/js/components/EditMenu/EditorSidebar/actions/ShortcodeInfo.tsx similarity index 94% rename from src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx rename to src/js/components/EditMenu/EditorSidebar/actions/ShortcodeInfo.tsx index c5128ada0..8edc1308e 100644 --- a/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx +++ b/src/js/components/EditMenu/EditorSidebar/actions/ShortcodeInfo.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react' import { CheckboxControl, ExternalLink, Modal } from '@wordpress/components' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { Button } from '../../common/Button' -import { CopyToClipboardButton } from '../../common/CopyToClipboardButton' -import type { Dispatch, SetStateAction } from 'react' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { Button } from '../../../common/Button' +import { CopyToClipboardButton } from '../../../common/CopyToClipboardButton' +import type { Dispatch, SetStateAction} from 'react' type ShortcodeAtts = Record diff --git a/src/js/components/EditorSidebar/actions/SubmitButtons.tsx b/src/js/components/EditMenu/EditorSidebar/actions/SubmitButtons.tsx similarity index 82% rename from src/js/components/EditorSidebar/actions/SubmitButtons.tsx rename to src/js/components/EditMenu/EditorSidebar/actions/SubmitButtons.tsx index 01167a09b..328338c26 100644 --- a/src/js/components/EditorSidebar/actions/SubmitButtons.tsx +++ b/src/js/components/EditMenu/EditorSidebar/actions/SubmitButtons.tsx @@ -1,11 +1,11 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { SubmitSnippetAction } from '../../../hooks/useSubmitSnippet' -import { isCondition } from '../../../utils/snippets/snippets' -import { isNetworkAdmin } from '../../../utils/screen' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { SubmitButton } from '../../common/SubmitButton' -import type { SubmitButtonProps } from '../../common/SubmitButton' +import { SubmitSnippetAction } from '../../../../hooks/useSubmitSnippet' +import { isCondition } from '../../../../utils/snippets/snippets' +import { isNetworkAdmin } from '../../../../utils/screen' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { SubmitButton } from '../../../common/SubmitButton' +import type { SubmitButtonProps } from '../../../common/SubmitButton' const SaveButton = (props: SubmitButtonProps) => { const { snippet } = useSnippetForm() diff --git a/src/js/components/EditorSidebar/controls/ActivationSwitch.tsx b/src/js/components/EditMenu/EditorSidebar/controls/ActivationSwitch.tsx similarity index 85% rename from src/js/components/EditorSidebar/controls/ActivationSwitch.tsx rename to src/js/components/EditMenu/EditorSidebar/controls/ActivationSwitch.tsx index 16f3cc460..3d7a2bc3c 100644 --- a/src/js/components/EditorSidebar/controls/ActivationSwitch.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/ActivationSwitch.tsx @@ -1,8 +1,8 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet' -import { handleUnknownError } from '../../../utils/errors' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { SubmitSnippetAction, useSubmitSnippet } from '../../../../hooks/useSubmitSnippet' +import { handleUnknownError } from '../../../../utils/errors' export const ActivationSwitch = () => { const { snippet, isWorking } = useSnippetForm() diff --git a/src/js/components/EditorSidebar/controls/MultisiteSharingSettings.tsx b/src/js/components/EditMenu/EditorSidebar/controls/MultisiteSharingSettings.tsx similarity index 89% rename from src/js/components/EditorSidebar/controls/MultisiteSharingSettings.tsx rename to src/js/components/EditMenu/EditorSidebar/controls/MultisiteSharingSettings.tsx index cefc8e818..d421fb41f 100644 --- a/src/js/components/EditorSidebar/controls/MultisiteSharingSettings.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/MultisiteSharingSettings.tsx @@ -1,7 +1,7 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { Tooltip } from '../../common/Tooltip' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { Tooltip } from '../../../common/Tooltip' export const MultisiteSharingSettings: React.FC = () => { const { snippet, setSnippet, isReadOnly } = useSnippetForm() diff --git a/src/js/components/EditorSidebar/controls/PriorityInput.tsx b/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx similarity index 86% rename from src/js/components/EditorSidebar/controls/PriorityInput.tsx rename to src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx index 2f8128df0..e21eb1cf2 100644 --- a/src/js/components/EditorSidebar/controls/PriorityInput.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx @@ -1,7 +1,7 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { Tooltip } from '../../common/Tooltip' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { Tooltip } from '../../../common/Tooltip' export const PriorityInput = () => { const { snippet, isReadOnly, setSnippet } = useSnippetForm() diff --git a/src/js/components/EditorSidebar/controls/RTLControl.tsx b/src/js/components/EditMenu/EditorSidebar/controls/RTLControl.tsx similarity index 90% rename from src/js/components/EditorSidebar/controls/RTLControl.tsx rename to src/js/components/EditMenu/EditorSidebar/controls/RTLControl.tsx index 2dce2b256..a3944bb9b 100644 --- a/src/js/components/EditorSidebar/controls/RTLControl.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/RTLControl.tsx @@ -1,6 +1,6 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' export const RTLControl: React.FC = () => { const { codeEditorInstance } = useSnippetForm() diff --git a/src/js/components/EditorSidebar/index.ts b/src/js/components/EditMenu/EditorSidebar/index.ts similarity index 100% rename from src/js/components/EditorSidebar/index.ts rename to src/js/components/EditMenu/EditorSidebar/index.ts diff --git a/src/js/components/SnippetForm/SnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx similarity index 86% rename from src/js/components/SnippetForm/SnippetForm.tsx rename to src/js/components/EditMenu/SnippetForm/SnippetForm.tsx index 10ac3ff68..d851a4f89 100644 --- a/src/js/components/SnippetForm/SnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx @@ -2,16 +2,17 @@ import React, { useState } from 'react' import classnames from 'classnames' import { __ } from '@wordpress/i18n' import { addQueryArgs } from '@wordpress/url' -import { WithRestAPIContext } from '../../hooks/useRestAPI' -import { WithSnippetsListContext, useSnippetsList } from '../../hooks/useSnippetsList' -import { SubmitSnippetAction, useSubmitSnippet } from '../../hooks/useSubmitSnippet' -import { handleUnknownError } from '../../utils/errors' -import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../utils/snippets/snippets' -import { WithSnippetFormContext, useSnippetForm } from '../../hooks/useSnippetForm' -import { ConfirmDialog } from '../common/ConfirmDialog' -import { UpsellDialog } from '../common/UpsellDialog' +import { WithRestAPIContext } from '../../../hooks/useRestAPI' +import { WithSnippetsListContext, useSnippetsList } from '../../../hooks/useSnippetsList' +import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet' +import { handleUnknownError } from '../../../utils/errors' +import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../../utils/snippets/snippets' +import { WithSnippetFormContext, useSnippetForm } from '../../../hooks/useSnippetForm' +import { ConfirmDialog } from '../../common/ConfirmDialog' +import { Toolbar } from '../../common/Toolbar' +import { UpsellBanner } from '../../common/UpsellBanner' +import { UpsellDialog } from '../../common/UpsellDialog' import { EditorSidebar } from '../EditorSidebar' -import { UpsellBanner } from '../common/UpsellBanner' import { SnippetTypeInput } from './fields/SnippetTypeInput' import { TagsEditor } from './fields/TagsEditor' import { CodeEditor } from './fields/CodeEditor' @@ -19,7 +20,7 @@ import { DescriptionEditor } from './fields/DescriptionEditor' import { NameInput } from './fields/NameInput' import { PageHeading } from './page/PageHeading' import type { PropsWithChildren } from 'react' -import type { Snippet } from '../../types/Snippet' +import type { Snippet } from '../../../types/Snippet' const editFormClassName = ({ snippet, isReadOnly, isExpanded }: { snippet: Snippet, @@ -89,8 +90,8 @@ const EditForm: React.FC = ({ children, className }) => { if (response && 0 !== response.id && window.CODE_SNIPPETS) { if (window.location.href.toString().includes(window.CODE_SNIPPETS.urls.addNew)) { document.title = document.title - .replace(__('Add New Snippet', 'code-snippets'), __('Edit Snippet', 'code-snippets')) - .replace(__('Add New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) + .replace(__('Create New Snippet', 'code-snippets'), __('Edit Snippet', 'code-snippets')) + .replace(__('Create New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) const newUrl = addQueryArgs(window.CODE_SNIPPETS.urls.edit, { id: response.id }) window.history.pushState({}, document.title, newUrl) @@ -186,6 +187,7 @@ export const SnippetForm: React.FC = () => createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)}> + diff --git a/src/js/components/SnippetForm/fields/CodeEditor.tsx b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx similarity index 86% rename from src/js/components/SnippetForm/fields/CodeEditor.tsx rename to src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx index 076e5d0cf..8d8d63295 100644 --- a/src/js/components/SnippetForm/fields/CodeEditor.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useRef } from 'react' import { __ } from '@wordpress/i18n' -import { useSubmitSnippet } from '../../../hooks/useSubmitSnippet' -import { handleUnknownError } from '../../../utils/errors' -import { isMacOS } from '../../../utils/screen' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { Button } from '../../common/Button' -import { ExpandIcon } from '../../common/icons/ExpandIcon' -import { MinimiseIcon } from '../../common/icons/MinimiseIcon' +import { useSubmitSnippet } from '../../../../hooks/useSubmitSnippet' +import { handleUnknownError } from '../../../../utils/errors' +import { isMacOS } from '../../../../utils/screen' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { Button } from '../../../common/Button' +import { ExpandIcon } from '../../../common/icons/ExpandIcon' +import { MinimiseIcon } from '../../../common/icons/MinimiseIcon' import { CodeEditorShortcuts } from './CodeEditorShortcuts' import type { Dispatch, RefObject, SetStateAction } from 'react' diff --git a/src/js/components/EditMenu/SnippetForm/fields/CodeEditorShortcuts.tsx b/src/js/components/EditMenu/SnippetForm/fields/CodeEditorShortcuts.tsx new file mode 100644 index 000000000..c034ac96d --- /dev/null +++ b/src/js/components/EditMenu/SnippetForm/fields/CodeEditorShortcuts.tsx @@ -0,0 +1,199 @@ +import { __, _x } from '@wordpress/i18n' +import { getKeyMap } from 'codemirror/src/input/keymap' +import React, { Fragment, useMemo } from 'react' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { isMacOS } from '../../../../utils/screen' +import type { KeyMap } from 'codemirror' + +const KEYBOARD_KEYS = { + 'Fn': _x('Fn', 'keyboard key', 'code-snippets'), + 'Cmd': _x('Ctrl', 'keyboard key', 'code-snippets'), + 'Ctrl': _x('Ctrl', 'keyboard key', 'code-snippets'), + 'Shift': _x('Shift', 'keyboard key', 'code-snippets'), + 'Alt': _x('Alt', 'keyboard key', 'code-snippets'), + 'Tab': _x('Tab', 'keyboard key', 'code-snippets'), + 'Up': _x('Up', 'keyboard key', 'code-snippets'), + 'Down': _x('Down', 'keyboard key', 'code-snippets'), + 'Left': _x('Left', 'keyboard key', 'code-snippets'), + 'Right': _x('Right', 'keyboard key', 'code-snippets'), + 'A': _x('A', 'keyboard key', 'code-snippets'), + 'B': _x('B', 'keyboard key', 'code-snippets'), + 'C': _x('C', 'keyboard key', 'code-snippets'), + 'D': _x('D', 'keyboard key', 'code-snippets'), + 'E': _x('E', 'keyboard key', 'code-snippets'), + 'F': _x('F', 'keyboard key', 'code-snippets'), + 'G': _x('G', 'keyboard key', 'code-snippets'), + 'H': _x('H', 'keyboard key', 'code-snippets'), + 'I': _x('I', 'keyboard key', 'code-snippets'), + 'J': _x('J', 'keyboard key', 'code-snippets'), + 'K': _x('K', 'keyboard key', 'code-snippets'), + 'L': _x('L', 'keyboard key', 'code-snippets'), + 'M': _x('M', 'keyboard key', 'code-snippets'), + 'N': _x('N', 'keyboard key', 'code-snippets'), + 'O': _x('O', 'keyboard key', 'code-snippets'), + 'P': _x('P', 'keyboard key', 'code-snippets'), + 'Q': _x('Q', 'keyboard key', 'code-snippets'), + 'R': _x('R', 'keyboard key', 'code-snippets'), + 'S': _x('S', 'keyboard key', 'code-snippets'), + 'T': _x('T', 'keyboard key', 'code-snippets'), + 'U': _x('U', 'keyboard key', 'code-snippets'), + 'V': _x('V', 'keyboard key', 'code-snippets'), + 'W': _x('W', 'keyboard key', 'code-snippets'), + 'X': _x('X', 'keyboard key', 'code-snippets'), + 'Y': _x('Y', 'keyboard key', 'code-snippets'), + 'Z': _x('Z', 'keyboard key', 'code-snippets'), + '/': _x('/', 'keyboard key', 'code-snippets'), + '[': _x(']', 'keyboard key', 'code-snippets'), + ']': _x(']', 'keyboard key', 'code-snippets') +} + +export const KEYBOARD_SYMBOLS: Partial = { + Cmd: '⌘', + Ctrl: '⌃', + Alt: '⌥', + Shift: '⇧', + Tab: '⇥', + Up: '↑', + Down: '↓', + Left: '←', + Right: '→' +} + +const keyMapLabels = { + saveChanges: __('Save changes', 'code-snippets'), + selectAll: __('Select all', 'code-snippets'), + find: __('Begin searching', 'code-snippets'), + findNext: __('Find next', 'code-snippets'), + findPrev: __('Find previous', 'code-snippets'), + replace: __('Replace', 'code-snippets'), + replaceAll: __('Replace all', 'code-snippets'), + findPersistent: __('Persistent search', 'code-snippets'), + toggleComment: __('Toggle comment', 'code-snippets'), + swapLineUp: __('Swap line up', 'code-snippets'), + swapLineDown: __('Swap line down', 'code-snippets'), + autoIndent: __('Auto-indent current line or selection', 'code-snippets') +} + +const KEY_ORDER: readonly (keyof typeof KEYBOARD_KEYS)[] = isMacOS() + ? ['Fn', 'Ctrl', 'Alt', 'Shift', 'Cmd'] + : ['Cmd', 'Ctrl', 'Shift', 'Alt', 'Fn'] + +const getKeyComparisonValue = (key: string): string => + KEY_ORDER.includes(key as keyof typeof KEYBOARD_KEYS) + ? String(KEY_ORDER.indexOf(key as keyof typeof KEYBOARD_KEYS)) + : key + +const unpackKeyMap = (keyMap: KeyMap): Map => { + const result = new Map() + + for (const [shortcut, action] of Object.entries(keyMap)) { + if ('string' === typeof action && keyMapLabels[action as keyof typeof keyMapLabels]) { + const keys = shortcut.split('-') + + keys.sort((a, b) => + getKeyComparisonValue(a).localeCompare(getKeyComparisonValue(b))) + + result.set(action, keys) + } + } + + return result +} + +interface KeyboardShortcutMacProps { + keys: string[] + keyLabels: Partial> + keySymbols: Partial> +} + +const KeyboardShortcutMac: React.FC = ({ keys, keyLabels, keySymbols }) => + + {keys.map(key => + {keySymbols[key] ?? keyLabels[key] ?? key})} + + +const SEP = _x('+', 'keyboard shortcut separator', 'code-snippets') + +interface KeyboardShortcutPCProps { + keys: string[] + keyLabels: Partial> +} + +const KeyboardShortcutPC: React.FC = ({ keys, keyLabels }) => + + {keys.map((key, index) => + + {keyLabels[key] ?? key} + {index < keys.length - 1 && {SEP}} + )} + + +const fallbackKeyMap: Partial> = { + 'Ctrl-S': 'saveChanges', + 'Shift-Tab': 'autoIndent' +} + +const fallbackKeyMapMac: typeof fallbackKeyMap = { + 'Cmd-S': 'saveChanges', + 'Shift-Tab': 'autoIndent' +} + +export interface CodeEditorShortcutsProps { + editorTheme: string +} + +export const CodeEditorShortcuts: React.FC = ({ editorTheme }) => { + const { codeEditorInstance } = useSnippetForm() + + const shortcutKeys: Map | undefined = useMemo(() => { + if (codeEditorInstance) { + const extraKeys = codeEditorInstance.codemirror.getOption('extraKeys') + const keyMapName = codeEditorInstance.codemirror.getOption('keyMap') + + const combinedKeyMap: KeyMap = { + ...isMacOS() ? fallbackKeyMapMac : fallbackKeyMap, + ...keyMapName && getKeyMap(keyMapName), + ...'object' === typeof extraKeys ? extraKeys : undefined + } + + return unpackKeyMap(combinedKeyMap) + } + + return undefined + }, + [codeEditorInstance] + ) + + return shortcutKeys + ?
+ + +
+ + + {Object.entries(keyMapLabels).map(([action, label]) => { + const keys = shortcutKeys.get(action) + return keys + ? + + + + : null + })} + +
{label} + {isMacOS() + ? + : } +
+
+
+ : null +} diff --git a/src/js/components/SnippetForm/fields/DescriptionEditor.tsx b/src/js/components/EditMenu/SnippetForm/fields/DescriptionEditor.tsx similarity index 96% rename from src/js/components/SnippetForm/fields/DescriptionEditor.tsx rename to src/js/components/EditMenu/SnippetForm/fields/DescriptionEditor.tsx index 659635085..8e590d729 100644 --- a/src/js/components/SnippetForm/fields/DescriptionEditor.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/DescriptionEditor.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect } from 'react' import { __ } from '@wordpress/i18n' import domReady from '@wordpress/dom-ready' -import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' export const EDITOR_ID = 'snippet_description' diff --git a/src/js/components/SnippetForm/fields/NameInput.tsx b/src/js/components/EditMenu/SnippetForm/fields/NameInput.tsx similarity index 91% rename from src/js/components/SnippetForm/fields/NameInput.tsx rename to src/js/components/EditMenu/SnippetForm/fields/NameInput.tsx index eb3ae0cf0..0f3f7164c 100644 --- a/src/js/components/SnippetForm/fields/NameInput.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/NameInput.tsx @@ -1,6 +1,6 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' export const NameInput: React.FC = () => { const { snippet, setSnippet, isReadOnly } = useSnippetForm() diff --git a/src/js/components/SnippetForm/fields/SnippetLocationInput.tsx b/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx similarity index 87% rename from src/js/components/SnippetForm/fields/SnippetLocationInput.tsx rename to src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx index 2a39ac32d..4fa704ee3 100644 --- a/src/js/components/SnippetForm/fields/SnippetLocationInput.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx @@ -1,11 +1,11 @@ import { __ } from '@wordpress/i18n' import React from 'react' import Select from 'react-select' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { SNIPPET_TYPE_SCOPES } from '../../../types/Snippet' -import { getSnippetType, isCondition } from '../../../utils/snippets/snippets' -import type { SnippetCodeScope } from '../../../types/Snippet' -import type { SelectOption } from '../../../types/SelectOption' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { SNIPPET_TYPE_SCOPES } from '../../../../types/Snippet' +import { getSnippetType, isCondition } from '../../../../utils/snippets/snippets' +import type { SnippetCodeScope } from '../../../../types/Snippet' +import type { SelectOption } from '../../../../types/SelectOption' const SCOPE_ICONS: Record = { 'global': 'admin-site', diff --git a/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx b/src/js/components/EditMenu/SnippetForm/fields/SnippetTypeInput.tsx similarity index 78% rename from src/js/components/SnippetForm/fields/SnippetTypeInput.tsx rename to src/js/components/EditMenu/SnippetForm/fields/SnippetTypeInput.tsx index 3b6c00d15..5c298f295 100644 --- a/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/SnippetTypeInput.tsx @@ -2,15 +2,15 @@ import React, { useEffect } from 'react' import classnames from 'classnames' import { __, _x } from '@wordpress/i18n' import Select from 'react-select' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { SNIPPET_TYPE_SCOPES } from '../../../types/Snippet' -import { isLicensed } from '../../../utils/screen' -import { getSnippetType, isProType } from '../../../utils/snippets/snippets' -import { Badge } from '../../common/Badge' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { SNIPPET_TYPES, SNIPPET_TYPE_SCOPES } from '../../../../types/Snippet' +import { isLicensed } from '../../../../utils/screen' +import { SNIPPET_TYPE_LABELS, getSnippetType, isProType } from '../../../../utils/snippets/snippets' +import { Badge } from '../../../common/Badge' import type { FormatOptionLabelContext } from 'react-select' import type { Dispatch, SetStateAction } from 'react' -import type { SnippetCodeType, SnippetType } from '../../../types/Snippet' -import type { SelectOption } from '../../../types/SelectOption' +import type { SnippetCodeType, SnippetType } from '../../../../types/Snippet' +import type { SelectOption } from '../../../../types/SelectOption' import type { EditorConfiguration } from 'codemirror' export interface SnippetTypeInputProps { @@ -24,13 +24,9 @@ const EDITOR_MODES: Record = { html: 'application/x-httpd-php' } -const OPTIONS: SelectOption[] = [ - { value: 'php', label: __('Functions', 'code-snippets') }, - { value: 'html', label: __('Content', 'code-snippets') }, - { value: 'css', label: __('Styles', 'code-snippets') }, - { value: 'js', label: __('Scripts', 'code-snippets') }, - { value: 'cond', label: __('Conditions', 'code-snippets') } -] +const OPTIONS: SelectOption[] = + SNIPPET_TYPES.map(type => + ({ value: type, label: SNIPPET_TYPE_LABELS[type] })) interface SnippetTypeOptionProps { option: SelectOption diff --git a/src/js/components/SnippetForm/fields/TagsEditor.tsx b/src/js/components/EditMenu/SnippetForm/fields/TagsEditor.tsx similarity index 92% rename from src/js/components/SnippetForm/fields/TagsEditor.tsx rename to src/js/components/EditMenu/SnippetForm/fields/TagsEditor.tsx index 23c48c3d2..2d259fd5d 100644 --- a/src/js/components/SnippetForm/fields/TagsEditor.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/TagsEditor.tsx @@ -1,7 +1,7 @@ import React from 'react' import { __ } from '@wordpress/i18n' import { FormTokenField } from '@wordpress/components' -import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' const options = window.CODE_SNIPPETS_EDIT?.tagOptions diff --git a/src/js/components/SnippetForm/index.ts b/src/js/components/EditMenu/SnippetForm/index.ts similarity index 100% rename from src/js/components/SnippetForm/index.ts rename to src/js/components/EditMenu/SnippetForm/index.ts diff --git a/src/js/components/SnippetForm/page/Notices.tsx b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx similarity index 88% rename from src/js/components/SnippetForm/page/Notices.tsx rename to src/js/components/EditMenu/SnippetForm/page/Notices.tsx index 490d33d4e..c843bb29c 100644 --- a/src/js/components/SnippetForm/page/Notices.tsx +++ b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx @@ -1,8 +1,8 @@ import { createInterpolateElement } from '@wordpress/element' import React from 'react' import { __, sprintf } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { DismissibleNotice } from '../../common/DismissableNotice' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { DismissibleNotice } from '../../../common/Notice' export const Notices: React.FC = () => { const { currentNotice, setCurrentNotice, snippet, setSnippet } = useSnippetForm() diff --git a/src/js/components/SnippetForm/page/PageHeading.tsx b/src/js/components/EditMenu/SnippetForm/page/PageHeading.tsx similarity index 85% rename from src/js/components/SnippetForm/page/PageHeading.tsx rename to src/js/components/EditMenu/SnippetForm/page/PageHeading.tsx index 041dd3eb4..24e0e63cb 100644 --- a/src/js/components/SnippetForm/page/PageHeading.tsx +++ b/src/js/components/EditMenu/SnippetForm/page/PageHeading.tsx @@ -1,15 +1,15 @@ import { __, _x } from '@wordpress/i18n' import React from 'react' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { createSnippetObject } from '../../../utils/snippets/snippets' -import type { Snippet } from '../../../types/Snippet' +import { useSnippetForm } from '../../../../hooks/useSnippetForm' +import { createSnippetObject } from '../../../../utils/snippets/snippets' +import type { Snippet } from '../../../../types/Snippet' const OPTIONS = window.CODE_SNIPPETS_EDIT const getAddNewHeading = (snippet: Snippet): string => 'condition' === snippet.scope ? __('Add New Condition', 'code-snippets') - : __('Add New Snippet', 'code-snippets') + : __('Create New Snippet', 'code-snippets') export const PageHeading: React.FC = () => { const { snippet, updateSnippet, setCurrentNotice } = useSnippetForm() diff --git a/src/js/components/EditMenu/index.ts b/src/js/components/EditMenu/index.ts new file mode 100644 index 000000000..39daf0633 --- /dev/null +++ b/src/js/components/EditMenu/index.ts @@ -0,0 +1 @@ +export * from './EditMenu' diff --git a/src/js/components/Import/FromFileUpload/components/DuplicateActionSelector.tsx b/src/js/components/Import/FromFileUpload/components/DuplicateActionSelector.tsx deleted file mode 100644 index b4757da09..000000000 --- a/src/js/components/Import/FromFileUpload/components/DuplicateActionSelector.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import { __ } from '@wordpress/i18n' -import { ImportCard } from '../../shared' - -type DuplicateAction = 'ignore' | 'replace' | 'skip' - -interface DuplicateActionSelectorProps { - value: DuplicateAction - onChange: (action: DuplicateAction) => void -} - -export const DuplicateActionSelector: React.FC = ({ - value, - onChange -}) => { - return ( - -

{__('Duplicate Snippets', 'code-snippets')}

-

- {__('What should happen if an existing snippet is found with an identical name to an imported snippet?', 'code-snippets')} -

- -
-
- - - - - -
-
-
- ) -} diff --git a/src/js/components/Import/FromFileUpload/components/ImportResultDisplay.tsx b/src/js/components/Import/FromFileUpload/components/ImportResultDisplay.tsx deleted file mode 100644 index 92c762a99..000000000 --- a/src/js/components/Import/FromFileUpload/components/ImportResultDisplay.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import { __ } from '@wordpress/i18n' -import { ImportCard } from '../../shared' - -interface ImportResult { - success: boolean - message: string - imported?: number - warnings?: string[] -} - -interface ImportResultDisplayProps { - result: ImportResult -} - -export const ImportResultDisplay: React.FC = ({ result }) => { - return ( - -
-
- - {result.success ? '✓' : '✕'} - -
-
-

- {result.success - ? __('Import Successful!', 'code-snippets') - : __('Import Failed', 'code-snippets') - } -

-

- {result.message} -

- - {result.success && ( -

- {__('Go to ', 'code-snippets')} - - {__('All Snippets', 'code-snippets')} - - {__(' to activate your imported snippets.', 'code-snippets')} -

- )} - - {result.warnings && result.warnings.length > 0 && ( -
-

- {__('Warnings:', 'code-snippets')} -

-
    - {result.warnings.map((warning, index) => ( -
  • - {warning} -
  • - ))} -
-
- )} -
-
-
- ) -} diff --git a/src/js/components/Import/FromFileUpload/components/index.ts b/src/js/components/Import/FromFileUpload/components/index.ts deleted file mode 100644 index d05811037..000000000 --- a/src/js/components/Import/FromFileUpload/components/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { DuplicateActionSelector } from './DuplicateActionSelector' -export { DragDropUploadArea } from './DragDropUploadArea' -export { SelectedFilesList } from './SelectedFilesList' -export { SnippetSelectionTable } from './SnippetSelectionTable' -export { ImportResultDisplay } from './ImportResultDisplay' diff --git a/src/js/components/Import/FromFileUpload/hooks/index.ts b/src/js/components/Import/FromFileUpload/hooks/index.ts deleted file mode 100644 index 5826fc3eb..000000000 --- a/src/js/components/Import/FromFileUpload/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { useDragAndDrop } from './useDragAndDrop' -export { useFileSelection } from './useFileSelection' -export { useSnippetSelection } from './useSnippetSelection' -export { useImportWorkflow } from './useImportWorkflow' diff --git a/src/js/components/Import/FromOtherPlugins/components/ImportOptions.tsx b/src/js/components/Import/FromOtherPlugins/components/ImportOptions.tsx deleted file mode 100644 index ac084959e..000000000 --- a/src/js/components/Import/FromOtherPlugins/components/ImportOptions.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react' -import { __ } from '@wordpress/i18n' -import { ImportCard } from '../../shared' - -interface ImportOptionsProps { - autoAddTags: boolean - tagValue: string - onAutoAddTagsChange: (enabled: boolean) => void - onTagValueChange: (value: string) => void -} - -export const ImportOptions: React.FC = ({ - autoAddTags, - tagValue, - onAutoAddTagsChange, - onTagValueChange -}) => { - return ( - -

{__('Import options', 'code-snippets')}

- -
- ) -} diff --git a/src/js/components/Import/FromOtherPlugins/components/ImporterSelector.tsx b/src/js/components/Import/FromOtherPlugins/components/ImporterSelector.tsx deleted file mode 100644 index bed4d287c..000000000 --- a/src/js/components/Import/FromOtherPlugins/components/ImporterSelector.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' -import { __ } from '@wordpress/i18n' -import type { Importer } from '../../../../hooks/useImportersAPI' -import { ImportCard } from '../../shared' - -interface ImporterSelectorProps { - importers: Importer[] - selectedImporter: string - onImporterChange: (importerName: string) => void - isLoading: boolean -} - -export const ImporterSelector: React.FC = ({ - importers, - selectedImporter, - onImporterChange, - isLoading -}) => { - return ( - - - - {isLoading && ( -

- {__('Loading snippets...', 'code-snippets')} -

- )} -
- ) -} diff --git a/src/js/components/Import/FromOtherPlugins/components/index.ts b/src/js/components/Import/FromOtherPlugins/components/index.ts deleted file mode 100644 index 3ab18c68f..000000000 --- a/src/js/components/Import/FromOtherPlugins/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { ImporterSelector } from './ImporterSelector' -export { ImportOptions } from './ImportOptions' -export { SimpleSnippetTable } from './SimpleSnippetTable' -export { StatusDisplay } from './StatusDisplay' diff --git a/src/js/components/Import/FromOtherPlugins/hooks/index.ts b/src/js/components/Import/FromOtherPlugins/hooks/index.ts deleted file mode 100644 index e60c86328..000000000 --- a/src/js/components/Import/FromOtherPlugins/hooks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useImporterSelection } from './useImporterSelection' -export { useSnippetImport } from './useSnippetImport' -export { useImportSnippetSelection } from './useImportSnippetSelection' diff --git a/src/js/components/Import/FromOtherPlugins/index.ts b/src/js/components/Import/FromOtherPlugins/index.ts deleted file mode 100644 index 977c810cc..000000000 --- a/src/js/components/Import/FromOtherPlugins/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ImportForm' diff --git a/src/js/components/Import/shared/components/index.ts b/src/js/components/Import/shared/components/index.ts deleted file mode 100644 index c59b6b1ca..000000000 --- a/src/js/components/Import/shared/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { ImportCard } from './ImportCard' -export type { ImportCardProps } from './ImportCard' -export { ImportSection } from './ImportSection' -export type { ImportSectionProps } from './ImportSection' diff --git a/src/js/components/Import/shared/index.ts b/src/js/components/Import/shared/index.ts deleted file mode 100644 index cb64ac1b5..000000000 --- a/src/js/components/Import/shared/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './components' diff --git a/src/js/components/Import/FromFileUpload/FileUploadForm.tsx b/src/js/components/ImportMenu/FromFileUpload/FileUploadForm.tsx similarity index 73% rename from src/js/components/Import/FromFileUpload/FileUploadForm.tsx rename to src/js/components/ImportMenu/FromFileUpload/FileUploadForm.tsx index 6369b89e2..63e31d19d 100644 --- a/src/js/components/Import/FromFileUpload/FileUploadForm.tsx +++ b/src/js/components/ImportMenu/FromFileUpload/FileUploadForm.tsx @@ -1,19 +1,15 @@ -import React, { useState, useRef, useEffect } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { __ } from '@wordpress/i18n' import { Button } from '../../common/Button' -import { - DuplicateActionSelector, - DragDropUploadArea, - SelectedFilesList, - SnippetSelectionTable, - ImportResultDisplay -} from './components' -import { ImportCard } from '../shared' -import { - useFileSelection, - useSnippetSelection, - useImportWorkflow -} from './hooks' +import { ImportCard } from '../common/components/ImportCard' +import { DragDropUploadArea } from './components/DragDropUploadArea' +import { DuplicateActionSelector } from './components/DuplicateActionSelector' +import { ImportResultDisplay } from './components/ImportResultDisplay' +import { SelectedFilesList } from './components/SelectedFilesList' +import { SnippetSelectionTable } from './components/SnippetSelectionTable' +import { useFileSelection } from './hooks/useFileSelection' +import { useImportWorkflow } from './hooks/useImportWorkflow' +import { useSnippetSelection } from './hooks/useSnippetSelection' type DuplicateAction = 'ignore' | 'replace' | 'skip' type Step = 'upload' | 'select' @@ -28,10 +24,10 @@ export const FileUploadForm: React.FC = () => { const snippetSelection = useSnippetSelection(importWorkflow.availableSnippets) useEffect(() => { - if (currentStep === 'select' && selectSectionRef.current) { - selectSectionRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start' + if ('select' === currentStep && selectSectionRef.current) { + selectSectionRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'start' }) } }, [currentStep]) @@ -42,7 +38,7 @@ export const FileUploadForm: React.FC = () => { } const handleParseFiles = async () => { - if (!fileSelection.selectedFiles) return + if (!fileSelection.selectedFiles) {return} const success = await importWorkflow.parseFiles(fileSelection.selectedFiles) if (success) { @@ -63,18 +59,18 @@ export const FileUploadForm: React.FC = () => { importWorkflow.resetWorkflow() } - const isUploadDisabled = !fileSelection.selectedFiles || - fileSelection.selectedFiles.length === 0 || + const isUploadDisabled = !fileSelection.selectedFiles || + 0 === fileSelection.selectedFiles.length || importWorkflow.isUploading - const isImportDisabled = snippetSelection.selectedSnippets.size === 0 || + const isImportDisabled = 0 === snippetSelection.selectedSnippets.size || importWorkflow.isImporting return (

{__('Upload one or more Code Snippets export files and the snippets will be imported.', 'code-snippets')}

- +

{__('Afterward, you will need to visit the ', 'code-snippets')} @@ -83,10 +79,10 @@ export const FileUploadForm: React.FC = () => { {__(' page to activate the imported snippets.', 'code-snippets')}

- {currentStep === 'upload' && ( + {'upload' === currentStep && <> - {(!importWorkflow.uploadResult || !importWorkflow.uploadResult.success) && ( + {!importWorkflow.uploadResult?.success && <> { disabled={importWorkflow.isUploading} /> - {fileSelection.selectedFiles && fileSelection.selectedFiles.length > 0 && ( + {fileSelection.selectedFiles && 0 < fileSelection.selectedFiles.length && - )} + }
- )} + } - )} + } - {currentStep === 'select' && importWorkflow.availableSnippets.length > 0 && !importWorkflow.uploadResult?.success && ( + {'select' === currentStep && 0 < importWorkflow.availableSnippets.length && !importWorkflow.uploadResult?.success &&
- @@ -171,30 +166,29 @@ export const FileUploadForm: React.FC = () => { onSnippetToggle={snippetSelection.handleSnippetToggle} onSelectAll={snippetSelection.handleSelectAll} /> - +
-
- )} + } - {importWorkflow.uploadResult && ( - - )} + {importWorkflow.uploadResult && + }
) diff --git a/src/js/components/Import/FromFileUpload/components/DragDropUploadArea.tsx b/src/js/components/ImportMenu/FromFileUpload/components/DragDropUploadArea.tsx similarity index 88% rename from src/js/components/Import/FromFileUpload/components/DragDropUploadArea.tsx rename to src/js/components/ImportMenu/FromFileUpload/components/DragDropUploadArea.tsx index 1c8b60295..1a3aaab5b 100644 --- a/src/js/components/Import/FromFileUpload/components/DragDropUploadArea.tsx +++ b/src/js/components/ImportMenu/FromFileUpload/components/DragDropUploadArea.tsx @@ -1,9 +1,10 @@ import React from 'react' import { __ } from '@wordpress/i18n' import { useDragAndDrop } from '../hooks/useDragAndDrop' +import type { RefObject } from 'react' -interface DragDropUploadAreaProps { - fileInputRef: React.RefObject +export interface DragDropUploadAreaProps { + fileInputRef: RefObject onFileSelect: (files: FileList | null) => void disabled?: boolean } @@ -40,7 +41,7 @@ export const DragDropUploadArea: React.FC = ({ backgroundColor: dragOver ? '#f0f6fc' : disabled ? '#f6f7f7' : '#fafafa', marginBottom: '20px', transition: 'all 0.3s ease', - opacity: disabled ? 0.6 : 1 + opacity: disabled ? '0.6' : '1' }} >
📁
@@ -57,7 +58,7 @@ export const DragDropUploadArea: React.FC = ({ type="file" accept="application/json,.json,text/xml" multiple - onChange={(e) => onFileSelect(e.target.files)} + onChange={e => onFileSelect(e.target.files)} style={{ display: 'none' }} disabled={disabled} /> diff --git a/src/js/components/ImportMenu/FromFileUpload/components/DuplicateActionSelector.tsx b/src/js/components/ImportMenu/FromFileUpload/components/DuplicateActionSelector.tsx new file mode 100644 index 000000000..c5b69b3e0 --- /dev/null +++ b/src/js/components/ImportMenu/FromFileUpload/components/DuplicateActionSelector.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../common/components/ImportCard' + +export type DuplicateAction = 'ignore' | 'replace' | 'skip' + +export interface DuplicateActionSelectorProps { + value: DuplicateAction + onChange: (action: DuplicateAction) => void +} + +export const DuplicateActionSelector: React.FC = ({ + value, + onChange +}) => + +

{__('Duplicate Snippets', 'code-snippets')}

+

+ {__('What should happen if an existing snippet is found with an identical name to an imported snippet?', 'code-snippets')} +

+ +
+
+ + + + + +
+
+
diff --git a/src/js/components/ImportMenu/FromFileUpload/components/ImportResultDisplay.tsx b/src/js/components/ImportMenu/FromFileUpload/components/ImportResultDisplay.tsx new file mode 100644 index 000000000..164624951 --- /dev/null +++ b/src/js/components/ImportMenu/FromFileUpload/components/ImportResultDisplay.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../common/components/ImportCard' + +export interface ImportResult { + success: boolean + message: string + imported?: number + warnings?: string[] +} + +export interface ImportResultDisplayProps { + result: ImportResult +} + +export const ImportResultDisplay: React.FC = ({ result }) => + +
+
+ + {result.success ? '✓' : '✕'} + +
+ +
+

+ {result.success + ? __('Import Successful!', 'code-snippets') + : __('Import Failed', 'code-snippets') + } +

+

+ {result.message} +

+ + {result.success && +

+ {__('Go to ', 'code-snippets')} + + + {__('All Snippets', 'code-snippets')} + + {__(' to activate your imported snippets.', 'code-snippets')} +

} + + {result.warnings && 0 < result.warnings.length &&
+

+ {__('Warnings:', 'code-snippets')} +

+
    + {result.warnings.map((warning, index) => +
  • + {warning} +
  • )} +
+
} +
+
+ diff --git a/src/js/components/Import/FromFileUpload/components/SelectedFilesList.tsx b/src/js/components/ImportMenu/FromFileUpload/components/SelectedFilesList.tsx similarity index 95% rename from src/js/components/Import/FromFileUpload/components/SelectedFilesList.tsx rename to src/js/components/ImportMenu/FromFileUpload/components/SelectedFilesList.tsx index 3bf11aa3e..c3dbba898 100644 --- a/src/js/components/Import/FromFileUpload/components/SelectedFilesList.tsx +++ b/src/js/components/ImportMenu/FromFileUpload/components/SelectedFilesList.tsx @@ -17,7 +17,7 @@ export const SelectedFilesList: React.FC = ({ {__('Selected Files:', 'code-snippets')} ({files.length})
- {Array.from(files).map((file, index) => ( + {Array.from(files).map((file, index) =>
= ({
- ))} + )}
) diff --git a/src/js/components/Import/FromFileUpload/components/SnippetSelectionTable.tsx b/src/js/components/ImportMenu/FromFileUpload/components/SnippetSelectionTable.tsx similarity index 89% rename from src/js/components/Import/FromFileUpload/components/SnippetSelectionTable.tsx rename to src/js/components/ImportMenu/FromFileUpload/components/SnippetSelectionTable.tsx index 8a59d1977..6d01b74f8 100644 --- a/src/js/components/Import/FromFileUpload/components/SnippetSelectionTable.tsx +++ b/src/js/components/ImportMenu/FromFileUpload/components/SnippetSelectionTable.tsx @@ -2,7 +2,9 @@ import React from 'react' import { __ } from '@wordpress/i18n' import type { ImportableSnippet } from '../../../../hooks/useFileUploadAPI' -interface SnippetSelectionTableProps { +const DESC_MAX_LENGTH = 50 + +export interface SnippetSelectionTableProps { snippets: ImportableSnippet[] selectedSnippets: Set isAllSelected: boolean @@ -27,8 +29,8 @@ export const SnippetSelectionTable: React.FC = ({ } const truncateDescription = (description: string | undefined): string => { - const desc = description || __('No description', 'code-snippets') - return desc.length > 50 ? desc.substring(0, 50) + '...' : desc + const desc = description ?? __('No description', 'code-snippets') + return DESC_MAX_LENGTH < desc.length ? `${desc.substring(0, DESC_MAX_LENGTH) }…` : desc } return ( @@ -49,7 +51,7 @@ export const SnippetSelectionTable: React.FC = ({ - {snippets.map(snippet => ( + {snippets.map(snippet => = ({ {snippet.table_data.title} - {snippet.source_file && ( + {snippet.source_file &&
from {snippet.source_file}
- )} + } = ({ {snippet.table_data.tags || '—'} - ))} + )} ) diff --git a/src/js/components/Import/FromFileUpload/hooks/useDragAndDrop.ts b/src/js/components/ImportMenu/FromFileUpload/hooks/useDragAndDrop.ts similarity index 96% rename from src/js/components/Import/FromFileUpload/hooks/useDragAndDrop.ts rename to src/js/components/ImportMenu/FromFileUpload/hooks/useDragAndDrop.ts index f6c3b0bb1..4635ef8ac 100644 --- a/src/js/components/Import/FromFileUpload/hooks/useDragAndDrop.ts +++ b/src/js/components/ImportMenu/FromFileUpload/hooks/useDragAndDrop.ts @@ -22,7 +22,7 @@ export const useDragAndDrop = ({ onFilesDrop }: UseDragAndDropProps) => { setDragOver(false) const files = e.dataTransfer.files - if (files.length > 0) { + if (0 < files.length) { onFilesDrop(files) } } diff --git a/src/js/components/Import/FromFileUpload/hooks/useFileSelection.ts b/src/js/components/ImportMenu/FromFileUpload/hooks/useFileSelection.ts similarity index 92% rename from src/js/components/Import/FromFileUpload/hooks/useFileSelection.ts rename to src/js/components/ImportMenu/FromFileUpload/hooks/useFileSelection.ts index 333fa4526..028b7c9c6 100644 --- a/src/js/components/Import/FromFileUpload/hooks/useFileSelection.ts +++ b/src/js/components/ImportMenu/FromFileUpload/hooks/useFileSelection.ts @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useRef, useState } from 'react' import { removeFileFromList } from '../utils/fileUtils' export const useFileSelection = () => { @@ -10,7 +10,7 @@ export const useFileSelection = () => { } const removeFile = (index: number) => { - if (!selectedFiles) return + if (!selectedFiles) {return} const newFiles = removeFileFromList(selectedFiles, index) setSelectedFiles(newFiles) diff --git a/src/js/components/Import/FromFileUpload/hooks/useImportWorkflow.ts b/src/js/components/ImportMenu/FromFileUpload/hooks/useImportWorkflow.ts similarity index 88% rename from src/js/components/Import/FromFileUpload/hooks/useImportWorkflow.ts rename to src/js/components/ImportMenu/FromFileUpload/hooks/useImportWorkflow.ts index cbffe75eb..da42b5752 100644 --- a/src/js/components/Import/FromFileUpload/hooks/useImportWorkflow.ts +++ b/src/js/components/ImportMenu/FromFileUpload/hooks/useImportWorkflow.ts @@ -1,6 +1,6 @@ import { useState } from 'react' import { __ } from '@wordpress/i18n' -import { useFileUploadAPI, type ImportableSnippet } from '../../../../hooks/useFileUploadAPI' +import { type ImportableSnippet, useFileUploadAPI } from '../../../../hooks/useFileUploadAPI' import { isNetworkAdmin } from '../../../../utils/screen' type DuplicateAction = 'ignore' | 'replace' | 'skip' @@ -17,11 +17,11 @@ export const useImportWorkflow = () => { const [isImporting, setIsImporting] = useState(false) const [availableSnippets, setAvailableSnippets] = useState([]) const [uploadResult, setUploadResult] = useState(null) - + const fileUploadAPI = useFileUploadAPI() - const parseFiles = async (files: FileList): Promise => { - if (!files || files.length === 0) { + const parseFiles = async (files: FileList | undefined): Promise => { + if (!files || 0 === files.length) { alert(__('Please select files to upload.', 'code-snippets')) return false } @@ -33,8 +33,8 @@ export const useImportWorkflow = () => { const response = await fileUploadAPI.parseFiles({ files }) setAvailableSnippets(response.data.snippets) - - if (response.data.warnings && response.data.warnings.length > 0) { + + if (response.data.warnings && 0 < response.data.warnings.length) { setUploadResult({ success: true, message: response.data.message, @@ -43,7 +43,6 @@ export const useImportWorkflow = () => { } return true - } catch (error) { console.error('Parse error:', error) setUploadResult({ @@ -57,10 +56,10 @@ export const useImportWorkflow = () => { } const importSnippets = async ( - snippetsToImport: ImportableSnippet[], + snippetsToImport: ImportableSnippet[], duplicateAction: DuplicateAction ): Promise => { - if (snippetsToImport.length === 0) { + if (0 === snippetsToImport.length) { alert(__('Please select snippets to import.', 'code-snippets')) return false } @@ -82,7 +81,6 @@ export const useImportWorkflow = () => { }) return true - } catch (error) { console.error('Import error:', error) setUploadResult({ diff --git a/src/js/components/Import/FromFileUpload/hooks/useSnippetSelection.ts b/src/js/components/ImportMenu/FromFileUpload/hooks/useSnippetSelection.ts similarity index 96% rename from src/js/components/Import/FromFileUpload/hooks/useSnippetSelection.ts rename to src/js/components/ImportMenu/FromFileUpload/hooks/useSnippetSelection.ts index 6d16cfe7c..0b0795b41 100644 --- a/src/js/components/Import/FromFileUpload/hooks/useSnippetSelection.ts +++ b/src/js/components/ImportMenu/FromFileUpload/hooks/useSnippetSelection.ts @@ -32,7 +32,7 @@ export const useSnippetSelection = (availableSnippets: ImportableSnippet[]) => { ) } - const isAllSelected = selectedSnippets.size === availableSnippets.length && availableSnippets.length > 0 + const isAllSelected = selectedSnippets.size === availableSnippets.length && 0 < availableSnippets.length return { selectedSnippets, diff --git a/src/js/components/Import/FromFileUpload/utils/fileUtils.ts b/src/js/components/ImportMenu/FromFileUpload/utils/fileUtils.ts similarity index 70% rename from src/js/components/Import/FromFileUpload/utils/fileUtils.ts rename to src/js/components/ImportMenu/FromFileUpload/utils/fileUtils.ts index 6ade360c8..e007a3a0d 100644 --- a/src/js/components/Import/FromFileUpload/utils/fileUtils.ts +++ b/src/js/components/ImportMenu/FromFileUpload/utils/fileUtils.ts @@ -1,9 +1,13 @@ +const FILE_SIZE_FRACTION_DIGITS = 2 + export const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 Bytes' + if (0 === bytes) { + return '0 Bytes' + } const k = 1024 const sizes = ['Bytes', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(FILE_SIZE_FRACTION_DIGITS))} ${sizes[i]}` } export const removeFileFromList = (fileList: FileList, indexToRemove: number): FileList => { diff --git a/src/js/components/Import/FromOtherPlugins/ImportForm.tsx b/src/js/components/ImportMenu/FromOtherPlugins/ImportForm.tsx similarity index 62% rename from src/js/components/Import/FromOtherPlugins/ImportForm.tsx rename to src/js/components/ImportMenu/FromOtherPlugins/ImportForm.tsx index 5c61cc508..a1710b28b 100644 --- a/src/js/components/Import/FromOtherPlugins/ImportForm.tsx +++ b/src/js/components/ImportMenu/FromOtherPlugins/ImportForm.tsx @@ -1,21 +1,17 @@ import React, { useState } from 'react' import { __ } from '@wordpress/i18n' -import { - ImporterSelector, - ImportOptions, - SimpleSnippetTable, - StatusDisplay -} from './components' -import { ImportCard } from '../shared' -import { - useImporterSelection, - useSnippetImport, - useImportSnippetSelection -} from './hooks' +import { ImportCard } from '../common/components/ImportCard' +import { ImporterSelector } from './components/ImporterSelector' +import { ImportOptions } from './components/ImportOptions' +import { SimpleSnippetTable } from './components/SimpleSnippetTable' +import { StatusDisplay } from './components/StatusDisplay' +import { useImporterSelection } from './hooks/useImporterSelection' +import { useImportSnippetSelection } from './hooks/useImportSnippetSelection' +import { useSnippetImport } from './hooks/useSnippetImport' export const ImportForm: React.FC = () => { - const [autoAddTags, setAutoAddTags] = useState(false) - + const [autoAddTags, setAutoAddTags] = useState(false) + const importerSelection = useImporterSelection() const snippetImport = useSnippetImport() const snippetSelection = useImportSnippetSelection(snippetImport.snippets) @@ -24,7 +20,7 @@ export const ImportForm: React.FC = () => { importerSelection.handleImporterChange(newImporter) snippetSelection.clearSelection() snippetImport.resetAll() - + if (newImporter) { await snippetImport.loadSnippets(newImporter) } @@ -38,7 +34,7 @@ export const ImportForm: React.FC = () => { autoAddTags, importerSelection.tagValue ) - + if (success) { snippetSelection.clearSelection() } @@ -66,58 +62,58 @@ export const ImportForm: React.FC = () => {

{__('If you are using another Snippets plugin, you can import all existing snippets to your Code Snippets library.', 'code-snippets')}

- + void handleImporterChange(newImporter)} isLoading={snippetImport.isLoadingSnippets} /> - {snippetImport.snippetsError && ( + {snippetImport.snippetsError && - )} + } - {snippetImport.importError && ( + {snippetImport.importError && - )} + } - {snippetImport.importSuccess.length > 0 && ( + {0 < snippetImport.importSuccess.length && - )} - - {importerSelection.selectedImporter && - !snippetImport.isLoadingSnippets && - !snippetImport.snippetsError && - snippetImport.snippets.length === 0 && - snippetImport.importSuccess.length === 0 && ( - -
-
📭
-

- {__('No snippets found', 'code-snippets')} -

-

- {__('No snippets were found for the selected plugin. Make sure the plugin is installed and has snippets configured.', 'code-snippets')} -

-
-
- )} - - {snippetImport.snippets.length > 0 && ( + } + + {importerSelection.selectedImporter && + !snippetImport.isLoadingSnippets && + !snippetImport.snippetsError && + 0 === snippetImport.snippets.length && + 0 === snippetImport.importSuccess.length && + +
+
📭
+

+ {__('No snippets found', 'code-snippets')} +

+

+ {__('No snippets were found for the selected plugin. Make sure the plugin is installed and has snippets configured.', 'code-snippets')} +

+
+
+ } + + {0 < snippetImport.snippets.length && <> { selectedSnippets={snippetSelection.selectedSnippets} onSnippetToggle={snippetSelection.handleSnippetToggle} onSelectAll={snippetSelection.handleSelectAll} - onImport={handleImport} + onImport={() => void handleImport()} isImporting={snippetImport.isImporting} /> - )} + }
) diff --git a/src/js/components/ImportMenu/FromOtherPlugins/components/ImportOptions.tsx b/src/js/components/ImportMenu/FromOtherPlugins/components/ImportOptions.tsx new file mode 100644 index 000000000..0a5b5d449 --- /dev/null +++ b/src/js/components/ImportMenu/FromOtherPlugins/components/ImportOptions.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../common/components/ImportCard' + +export interface ImportOptionsProps { + autoAddTags: boolean + tagValue: string + onAutoAddTagsChange: (enabled: boolean) => void + onTagValueChange: (value: string) => void +} + +export const ImportOptions: React.FC = ({ + autoAddTags, + tagValue, + onAutoAddTagsChange, + onTagValueChange +}) => + +

{__('Import options', 'code-snippets')}

+ +
diff --git a/src/js/components/ImportMenu/FromOtherPlugins/components/ImporterSelector.tsx b/src/js/components/ImportMenu/FromOtherPlugins/components/ImporterSelector.tsx new file mode 100644 index 000000000..d32752edd --- /dev/null +++ b/src/js/components/ImportMenu/FromOtherPlugins/components/ImporterSelector.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../common/components/ImportCard' +import type { Importer } from '../../../../hooks/useImportersAPI' + +export interface ImporterSelectorProps { + importers: Importer[] + selectedImporter: string + onImporterChange: (importerName: string) => void + isLoading: boolean +} + +export const ImporterSelector: React.FC = ({ + importers, + selectedImporter, + onImporterChange, + isLoading +}) => + + + + {isLoading &&

+ {__('Loading snippets...', 'code-snippets')} +

} +
diff --git a/src/js/components/Import/FromOtherPlugins/components/SimpleSnippetTable.tsx b/src/js/components/ImportMenu/FromOtherPlugins/components/SimpleSnippetTable.tsx similarity index 90% rename from src/js/components/Import/FromOtherPlugins/components/SimpleSnippetTable.tsx rename to src/js/components/ImportMenu/FromOtherPlugins/components/SimpleSnippetTable.tsx index f6c7f8ecf..97663b784 100644 --- a/src/js/components/Import/FromOtherPlugins/components/SimpleSnippetTable.tsx +++ b/src/js/components/ImportMenu/FromOtherPlugins/components/SimpleSnippetTable.tsx @@ -1,8 +1,8 @@ import React from 'react' import { __ } from '@wordpress/i18n' import { Button } from '../../../common/Button' +import { ImportCard } from '../../common/components/ImportCard' import type { ImportableSnippet } from '../../../../hooks/useImportersAPI' -import { ImportCard } from '../../shared' interface SimpleSnippetTableProps { snippets: ImportableSnippet[] @@ -21,7 +21,7 @@ export const SimpleSnippetTable: React.FC = ({ onImport, isImporting }) => { - const isAllSelected = selectedSnippets.size === snippets.length && snippets.length > 0 + const isAllSelected = selectedSnippets.size === snippets.length && 0 < snippets.length return ( @@ -37,12 +37,12 @@ export const SimpleSnippetTable: React.FC = ({ : __('Select All', 'code-snippets') } - @@ -64,7 +64,7 @@ export const SimpleSnippetTable: React.FC = ({ - {snippets.map(snippet => ( + {snippets.map(snippet => = ({ {snippet.table_data.title} {snippet.table_data.id} - ))} + )} - +
- diff --git a/src/js/components/Import/FromOtherPlugins/components/StatusDisplay.tsx b/src/js/components/ImportMenu/FromOtherPlugins/components/StatusDisplay.tsx similarity index 74% rename from src/js/components/Import/FromOtherPlugins/components/StatusDisplay.tsx rename to src/js/components/ImportMenu/FromOtherPlugins/components/StatusDisplay.tsx index 09e0b39a2..a3159062b 100644 --- a/src/js/components/Import/FromOtherPlugins/components/StatusDisplay.tsx +++ b/src/js/components/ImportMenu/FromOtherPlugins/components/StatusDisplay.tsx @@ -1,8 +1,8 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { ImportCard } from '../../shared' +import { ImportCard } from '../../common/components/ImportCard' -interface StatusDisplayProps { +export interface StatusDisplayProps { type: 'error' | 'success' title: string message: string @@ -15,17 +15,17 @@ export const StatusDisplay: React.FC = ({ message, showSnippetsLink = false }) => { - const isError = type === 'error' - + const isError = 'error' === type + return ( -
= ({

{message} - {showSnippetsLink && ( + {showSnippetsLink && <> {' '} {__('Code Snippets Library', 'code-snippets')} . - )} + }

diff --git a/src/js/components/Import/FromOtherPlugins/hooks/useImportSnippetSelection.ts b/src/js/components/ImportMenu/FromOtherPlugins/hooks/useImportSnippetSelection.ts similarity index 96% rename from src/js/components/Import/FromOtherPlugins/hooks/useImportSnippetSelection.ts rename to src/js/components/ImportMenu/FromOtherPlugins/hooks/useImportSnippetSelection.ts index 298f6a6a2..8fd1e6196 100644 --- a/src/js/components/Import/FromOtherPlugins/hooks/useImportSnippetSelection.ts +++ b/src/js/components/ImportMenu/FromOtherPlugins/hooks/useImportSnippetSelection.ts @@ -32,7 +32,7 @@ export const useImportSnippetSelection = (availableSnippets: ImportableSnippet[] ) } - const isAllSelected = selectedSnippets.size === availableSnippets.length && availableSnippets.length > 0 + const isAllSelected = selectedSnippets.size === availableSnippets.length && 0 < availableSnippets.length return { selectedSnippets, diff --git a/src/js/components/Import/FromOtherPlugins/hooks/useImporterSelection.ts b/src/js/components/ImportMenu/FromOtherPlugins/hooks/useImporterSelection.ts similarity index 87% rename from src/js/components/Import/FromOtherPlugins/hooks/useImporterSelection.ts rename to src/js/components/ImportMenu/FromOtherPlugins/hooks/useImporterSelection.ts index 71080a3a7..c5662cea8 100644 --- a/src/js/components/Import/FromOtherPlugins/hooks/useImporterSelection.ts +++ b/src/js/components/ImportMenu/FromOtherPlugins/hooks/useImporterSelection.ts @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { useImportersAPI, type Importer } from '../../../../hooks/useImportersAPI' +import { useEffect, useState } from 'react' +import { type Importer, useImportersAPI } from '../../../../hooks/useImportersAPI' export const useImporterSelection = () => { const [importers, setImporters] = useState([]) @@ -7,7 +7,7 @@ export const useImporterSelection = () => { const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [tagValue, setTagValue] = useState('') - + const importersAPI = useImportersAPI() useEffect(() => { @@ -22,7 +22,7 @@ export const useImporterSelection = () => { } } - fetchImporters() + void fetchImporters() }, [importersAPI]) const handleImporterChange = (newImporter: string) => { diff --git a/src/js/components/Import/FromOtherPlugins/hooks/useSnippetImport.ts b/src/js/components/ImportMenu/FromOtherPlugins/hooks/useSnippetImport.ts similarity index 94% rename from src/js/components/Import/FromOtherPlugins/hooks/useSnippetImport.ts rename to src/js/components/ImportMenu/FromOtherPlugins/hooks/useSnippetImport.ts index a20a60b16..d4b7821b3 100644 --- a/src/js/components/Import/FromOtherPlugins/hooks/useSnippetImport.ts +++ b/src/js/components/ImportMenu/FromOtherPlugins/hooks/useSnippetImport.ts @@ -1,6 +1,6 @@ import { useState } from 'react' import { __ } from '@wordpress/i18n' -import { useImportersAPI, type ImportableSnippet } from '../../../../hooks/useImportersAPI' +import { type ImportableSnippet, useImportersAPI } from '../../../../hooks/useImportersAPI' import { isNetworkAdmin } from '../../../../utils/screen' export const useSnippetImport = () => { @@ -42,7 +42,7 @@ export const useSnippetImport = () => { autoAddTags: boolean, tagValue: string ): Promise => { - if (selectedSnippetIds.length === 0) { + if (0 === selectedSnippetIds.length) { alert(__('Please select snippets to import.', 'code-snippets')) return false } @@ -66,7 +66,7 @@ export const useSnippetImport = () => { setImportSuccess(response.data.imported) - if (response.data.imported.length > 0) { + if (0 < response.data.imported.length) { setSnippets([]) return true } else { diff --git a/src/js/components/Import/ImportApp.tsx b/src/js/components/ImportMenu/ImportMenu.tsx similarity index 65% rename from src/js/components/Import/ImportApp.tsx rename to src/js/components/ImportMenu/ImportMenu.tsx index 2d036bddf..8fe02f533 100644 --- a/src/js/components/Import/ImportApp.tsx +++ b/src/js/components/ImportMenu/ImportMenu.tsx @@ -1,25 +1,28 @@ -import React, { useState, useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { __ } from '@wordpress/i18n' import { FileUploadForm } from './FromFileUpload/FileUploadForm' import { ImportForm } from './FromOtherPlugins/ImportForm' -import { ImportSection } from './shared' +import { ImportSection } from './common/components/ImportSection' type TabType = 'upload' | 'plugins' -export const ImportApp: React.FC = () => { +const isTabType = (value: string): value is TabType => + 'upload' === value || 'plugins' === value + +export const ImportMenu: React.FC = () => { const [activeTab, setActiveTab] = useState('upload') useEffect(() => { const urlParams = new URLSearchParams(window.location.search) - const tabParam = urlParams.get('tab') as TabType - if (tabParam === 'plugins' || tabParam === 'upload') { + const tabParam = urlParams.get('tab') + if (tabParam && isTabType(tabParam)) { setActiveTab(tabParam) } }, []) const handleTabChange = (tab: TabType) => { setActiveTab(tab) - + const url = new URL(window.location.href) url.searchParams.set('tab', tab) window.history.replaceState({}, '', url) @@ -29,9 +32,9 @@ export const ImportApp: React.FC = () => { diff --git a/src/js/components/Import/shared/components/ImportCard.tsx b/src/js/components/ImportMenu/common/components/ImportCard.tsx similarity index 68% rename from src/js/components/Import/shared/components/ImportCard.tsx rename to src/js/components/ImportMenu/common/components/ImportCard.tsx index 64d8182fa..04f8732b0 100644 --- a/src/js/components/Import/shared/components/ImportCard.tsx +++ b/src/js/components/ImportMenu/common/components/ImportCard.tsx @@ -1,21 +1,21 @@ -import React from 'react' +import React, { forwardRef } from 'react' import classnames from 'classnames' -import type { HTMLAttributes } from 'react' +import type { CSSProperties , HTMLAttributes, ReactNode} from 'react' export interface ImportCardProps extends Omit, 'className'> { - children: React.ReactNode + children: ReactNode className?: string variant?: 'default' | 'controls' } -export const ImportCard = React.forwardRef(({ +export const ImportCard = forwardRef(({ children, className, variant = 'default', style, ...props }, ref) => { - const cardStyle: React.CSSProperties = { + const cardStyle: CSSProperties = { backgroundColor: '#ffffff', padding: '25px', borderRadius: '5px', @@ -30,7 +30,7 @@ export const ImportCard = React.forwardRef(({ ref={ref} className={classnames( { - 'import-controls': variant === 'controls' + 'import-controls': 'controls' === variant }, className )} diff --git a/src/js/components/Import/shared/components/ImportSection.tsx b/src/js/components/ImportMenu/common/components/ImportSection.tsx similarity index 84% rename from src/js/components/Import/shared/components/ImportSection.tsx rename to src/js/components/ImportMenu/common/components/ImportSection.tsx index 262798c99..21158cffc 100644 --- a/src/js/components/Import/shared/components/ImportSection.tsx +++ b/src/js/components/ImportMenu/common/components/ImportSection.tsx @@ -1,5 +1,5 @@ import React from 'react' -import type { HTMLAttributes } from 'react' +import type { CSSProperties, HTMLAttributes } from 'react' export interface ImportSectionProps extends Omit, 'style'> { children: React.ReactNode @@ -15,7 +15,7 @@ export const ImportSection: React.FC = ({ style, ...props }) => { - const sectionStyle: React.CSSProperties = { + const sectionStyle: CSSProperties = { display: active ? 'block' : 'none', paddingTop: 0, ...style diff --git a/src/js/components/ImportMenu/index.ts b/src/js/components/ImportMenu/index.ts new file mode 100644 index 000000000..611b9fa7a --- /dev/null +++ b/src/js/components/ImportMenu/index.ts @@ -0,0 +1 @@ +export * from './ImportMenu' diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx new file mode 100644 index 000000000..b0a04d3b2 --- /dev/null +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx @@ -0,0 +1,112 @@ +import { __ } from '@wordpress/i18n' +import React from 'react' +import { Spinner } from '@wordpress/components' +import { useCloudSearch } from '../../../hooks/useCloudSearch' +import { WithCloudSearchFiltersContext, useCloudSearchFilters } from '../../../hooks/useCloudSearchFilters' +import { TablePagination } from '../../common/ListTable/TablePagination' +import { SubmitButton } from '../../common/SubmitButton' +import { SearchFilters } from './SearchFilters' +import { SearchResults } from './SearchResults' +import type { FormEventHandler } from 'react' + +const SearchBox = () => { + const { query, searchByCodevault, setQuery, setSearchByCodevault, isSearching, doSearch } = useCloudSearch() + + const handleSubmit: FormEventHandler = event => { + event.preventDefault() + doSearch() + } + + return ( +
+ + + +
+ + setQuery(event.target.value)} + placeholder={__('e.g. Remove unused JavaScript…', 'code-snippets')} + /> + {isSearching && } +
+ + + + ) +} + +const SearchResultsTable = () => { + const { page, totalItems, totalPages, setPage } = useCloudSearch() + const { filteredSearchResults } = useCloudSearchFilters() + + return filteredSearchResults + ? <> +
+ + + +
+ + + +
+ +
+ + : null +} + +const ErrorBanner = () => +
+

{__('An error occurred while fetching search results. Please try again.')}

+
+ +const NoSearchResultsBanner = () => +
+

{__('No snippets or codevault could be found with that search term. Please try again.', 'code-snippets')}

+
+ +export const CloudSearch = () => { + const { searchResults, error, page } = useCloudSearch() + + return ( +
+ + + {error && } + + {0 < page && searchResults && 0 === searchResults.length + ? + : + + } +
+ ) +} diff --git a/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx b/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx new file mode 100644 index 000000000..2fbaad1f1 --- /dev/null +++ b/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react' +import { __ } from '@wordpress/i18n' +import classnames from 'classnames' +import { addQueryArgs } from '@wordpress/url' +import { WithCloudSearchContext } from '../../../hooks/useCloudSearch' +import { WithRestAPIContext } from '../../../hooks/useRestAPI' +import { fetchQueryParam, updateQueryParam } from '../../../utils/urls' +import { CloudSearch } from './CloudSearch' + +const TABS = ['snippets', 'bundles'] as const +type TabName = typeof TABS[number] + +const TAB_LABELS: Record = { + snippets: __('Code Snippets', 'code-snippets'), + bundles: __('Bundles', 'code-snippets') +} + +interface NavTabsProps { + currentTab: TabName + setCurrentTab: (tab: TabName) => void +} + +const NavTabs: React.FC = ({ currentTab, setCurrentTab }) => +

+ {TABS.map(tab => + { + event.preventDefault() + updateQueryParam('tab', tab) + setCurrentTab(tab) + }} + > + {TAB_LABELS[tab]} + )} +

+ +export const CommunityCloud = () => { + const [currentTab, setCurrentTab] = useState(() => fetchQueryParam('tab') as TabName | null ?? TABS[0]) + + return ( +
+

{__('Community Cloud', 'code-snippets')}

+ + + + {'snippets' === currentTab + ? + + + + + : null} +
+ ) +} diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchFilters.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchFilters.tsx new file mode 100644 index 000000000..e791cb212 --- /dev/null +++ b/src/js/components/ManageMenu/CommunityCloud/SearchFilters.tsx @@ -0,0 +1,99 @@ +import React, { useMemo } from 'react' +import { __ } from '@wordpress/i18n' +import { useCloudSearch } from '../../../hooks/useCloudSearch' +import { useCloudSearchFilters } from '../../../hooks/useCloudSearchFilters' +import { CloudStatus } from '../../../types/schema/CloudSnippetSchema' +import { updateQueryParam } from '../../../utils/urls' +import type { Dispatch, SetStateAction } from 'react' + +export const STATUS_LABELS: Record = { + [CloudStatus.Public]: __('Public', 'code-snippets'), + [CloudStatus.Private]: __('Private', 'code-snippets'), + [CloudStatus.Unverified]: __('Unverified', 'code-snippets'), + [CloudStatus.AI_Verified]: __('AI Verified', 'code-snippets'), + [CloudStatus.Pro_Verified]: __('Pro Verified', 'code-snippets') +} + +export interface CloudSearchFilters { + tags: string + status: number +} + +interface SearchFilterProps { + label: string + filter: keyof CloudSearchFilters + filters: CloudSearchFilters + setFilters: Dispatch> + options: [string | number, string][] + allOptionLabel: string +} + +const SearchFilter: React.FC = ({ options, filter, filters, setFilters, label, allOptionLabel }) => + <> + + + + + +export const SearchFilters = () => { + const { searchResults: snippets } = useCloudSearch() + const { filters, setFilters } = useCloudSearchFilters() + + const options: { [K in keyof CloudSearchFilters]: [CloudSearchFilters[K], string][] } = useMemo( + () => { + const tags = new Set() + const statuses = new Set() + + snippets?.forEach(snippet => { + snippet.tags.forEach(tag => tags.add(tag)) + statuses.add(snippet.status) + }) + + return { + tags: Array.from(tags).sort().map(tag => [tag, tag]), + status: Array.from(statuses).sort().map(status => [status, STATUS_LABELS[status]]) + } + }, + [snippets]) + + return ( + <> + + + + + ) +} diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchResults.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchResults.tsx new file mode 100644 index 000000000..60932f6d7 --- /dev/null +++ b/src/js/components/ManageMenu/CommunityCloud/SearchResults.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useState } from 'react' +import classnames from 'classnames' +import { __, _x } from '@wordpress/i18n' +import { Modal } from '@wordpress/components' +import { CloudStatus } from '../../../types/schema/CloudSnippetSchema' +import { Prism } from '../../../utils/Prism' +import { getSnippetType } from '../../../utils/snippets/snippets' +import { Badge } from '../../common/Badge' +import { Button } from '../../common/Button' +import { STATUS_LABELS } from './SearchFilters' +import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' + +const MAX_DESCRIPTION_LENGTH = 150 + +interface CloudSnippetDetailsProps { + snippet: CloudSnippetSchema + setIsPreviewOpen: (isOpen: boolean) => void +} + +const CloudSnippetDetails: React.FC = ({ snippet, setIsPreviewOpen }) => +
+

+ { + event.preventDefault() + setIsPreviewOpen(true) + }} + > + {snippet.name} + +

+ +
+ + + + {snippet.vote_count} + + {0 < snippet.tags.length + ? + {__('Category: ', 'code-snippets')} + {snippet.tags[0]} + + : null} +
+ +

+ {snippet.description.length > MAX_DESCRIPTION_LENGTH + ? `${snippet.description.slice(0, MAX_DESCRIPTION_LENGTH)}…` + : snippet.description} +

+ +

+ {_x('by ', 'snippet author', 'code-snippets')} + + {snippet.codevault} + +

+
+ +interface PreviewModalProps { + isOpen: boolean + snippet: CloudSnippetSchema + setIsOpen: (isOpen: boolean) => void +} + +const PreviewModal: React.FC = ({ snippet, isOpen, setIsOpen }) => { + const snippetType = getSnippetType(snippet) + + return isOpen + ? setIsOpen(false)} title={snippet.name}> +
+				
+					{'php' === snippetType ? '
+			
+
+ : null +} + +interface SearchResultProps { + snippet: CloudSnippetSchema +} + +const SearchResult: React.FC = ({ snippet }) => { + const [isPreviewOpen, setIsPreviewOpen] = useState(false) + + useEffect(() => { + if (isPreviewOpen) { + Prism.highlightAll() + } + }, [isPreviewOpen]) + + return ( +
+ + +
+ + {STATUS_LABELS[snippet.status]} + + + +
+ + +
+ ) +} + +interface SearchResultsProps { + results: CloudSnippetSchema[] +} + +export const SearchResults: React.FC = ({ results }) => +
+ {results.map(result => + )} +
diff --git a/src/js/components/ManageMenu/ManageMenu.tsx b/src/js/components/ManageMenu/ManageMenu.tsx new file mode 100644 index 000000000..8710cedcb --- /dev/null +++ b/src/js/components/ManageMenu/ManageMenu.tsx @@ -0,0 +1,19 @@ +import React, { useMemo } from 'react' +import { fetchQueryParam } from '../../utils/urls' +import { Toolbar } from '../common/Toolbar' +import { CommunityCloud } from './CommunityCloud/CommunityCloud' +import { SnippetsTable } from './SnippetsTable' + +export const ManageMenu = () => { + const subpage = useMemo(() => fetchQueryParam('subpage'), []) + + return ( + <> + + + {'cloud-community' === subpage + ? + : } + + ) +} diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx new file mode 100644 index 000000000..2d545b5dc --- /dev/null +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx @@ -0,0 +1,199 @@ +import { __, _x, sprintf } from '@wordpress/i18n' +import React, { Fragment, useMemo } from 'react' +import { addQueryArgs } from '@wordpress/url' +import { useFilteredSnippets } from '../../../hooks/useFilteredSnippets' +import { useRestAPI } from '../../../hooks/useRestAPI' +import { useSnippetsFilters } from '../../../hooks/useSnippetsFilters' +import { useSnippetsList } from '../../../hooks/useSnippetsList' +import { handleUnknownError } from '../../../utils/errors' +import { REST_API_NAMESPACE, REST_BASE } from '../../../utils/restAPI' +import { getSnippetType } from '../../../utils/snippets/snippets' +import { ListTable } from '../../common/ListTable' +import { SubmitButton } from '../../common/SubmitButton' +import { TableColumns } from './TableColumns' +import type { ListTableBulkAction } from '../../common/ListTable' +import type { Snippet, SnippetStatus } from '../../../types/Snippet' + +const actions: ListTableBulkAction[] = [ + { + name: __('Activate', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Deactivate', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Clone', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Export', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Export code', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Delete', 'code-snippets'), + apply: () => Promise.resolve() + } +] + +const STATUS_LABELS: [SnippetStatus | undefined, string][] = [ + [undefined, __('All', 'code-snippets')], + ['active', __('Active', 'code-snippets')], + ['inactive', __('Inactive', 'code-snippets')], + ['recently_activated', __('Recently Activated', 'code-snippets')] +] + +const SnippetStatusCounts = () => { + const { currentStatus, setCurrentStatus } = useSnippetsFilters() + const { snippetsByStatus } = useFilteredSnippets() + const visibleStatuses = STATUS_LABELS.filter(([status]) => snippetsByStatus.has(status)) + + return ( + + ) +} + +const ClearRecentlyActiveButton: React.FC = () => { + const { api } = useRestAPI() + const { refreshSnippetsList } = useSnippetsList() + const { currentStatus } = useSnippetsFilters() + + return 'recently_activated' === currentStatus + ?
+ { + event.preventDefault() + api.del(`${REST_BASE}/${REST_API_NAMESPACE}/v1/recently-active`) + .then(refreshSnippetsList) + .catch(handleUnknownError) + }} + /> +
+ : null +} + +interface ExtraTableNavProps { + visibleSnippets: Snippet[] +} + +const FilterByTagControl: React.FC = ({ visibleSnippets }) => { + const { currentTag, setCurrentTag } = useSnippetsFilters() + + const tagsList: Set = useMemo( + () => visibleSnippets.reduce((tags, snippet) => { + snippet.tags.forEach(tag => tags.add(tag)) + return tags + }, new Set()), + [visibleSnippets]) + + return 0 < tagsList.size + ?
+ +
+ : null +} + +const SearchBox = () => { + const { searchQuery, setSearchQuery } = useSnippetsFilters() + + return ( +

+ + setSearchQuery(event.target.value)} + placeholder={__('Search snippets', 'code-snippets')} + /> +

+ ) +} + +const NoItemsMessage = () => { + const { currentType, currentTag, searchQuery } = useSnippetsFilters() + + return searchQuery || currentTag + ? <> + {__('No snippets were found matching the current search query.', 'code-snippets')} + {__(' Please enter a new query or use the "Clear Filters" button above.', 'code-snippets')} + + : <>{currentType + ? __("It looks like you don't have any snippets of this type.", 'code-snippets') + : __("It looks like you don't have any snippets.", 'code-snippets')} + + {' '} + + {__('Perhaps you would like to add a new one?', 'code-snippets')} + + +} + +export const SnippetsListTable: React.FC = () => { + const { currentStatus } = useSnippetsFilters() + const { snippetsByStatus } = useFilteredSnippets() + + const totalItems = snippetsByStatus.get(currentStatus)?.length ?? 0 + const itemsPerPage = window.CODE_SNIPPETS_MANAGE?.snippetsPerPage + + return ( + <> + + + + snippet.id} + columns={TableColumns} + actions={actions} + totalPages={itemsPerPage && Math.ceil(totalItems / itemsPerPage)} + extraTableNav={which => + <> + {'top' === which && } + + } + rowClassName={snippet => + `snippet ${snippet.active ? 'active' : 'inactive'}-snippet ${getSnippetType(snippet)}-snippet ${snippet.scope}-snippet`} + noItems={} + /> + + ) +} diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx new file mode 100644 index 000000000..a2042a095 --- /dev/null +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx @@ -0,0 +1,145 @@ +import { __, sprintf } from '@wordpress/i18n' +import { createInterpolateElement } from '@wordpress/element' +import React, { useState } from 'react' +import classnames from 'classnames' +import { addQueryArgs } from '@wordpress/url' +import { WithFilteredSnippetsContext } from '../../../hooks/useFilteredSnippets' +import { WithRestAPIContext } from '../../../hooks/useRestAPI' +import { WithSnippetsListContext } from '../../../hooks/useSnippetsList' +import { WithSnippetsTableFiltersContext, useSnippetsFilters } from '../../../hooks/useSnippetsFilters' +import { SNIPPET_TYPES } from '../../../types/Snippet' +import { isLicensed } from '../../../utils/screen' +import { SNIPPET_TYPE_LABELS, getSnippetEditUrl, isProType } from '../../../utils/snippets/snippets' +import { Badge } from '../../common/Badge' +import { Button } from '../../common/Button' +import { Notice } from '../../common/Notice' +import { UpsellDialog } from '../../common/UpsellDialog' +import { SnippetsListTable } from './SnippetsListTable' +import type { SnippetType } from '../../../types/Snippet' + +interface SnippetTypeTabProps { + type?: SnippetType + setIsUpgradeDialogOpen: (isOpen: boolean) => void +} + +const SnippetTypeTab: React.FC = ({ type, setIsUpgradeDialogOpen }) => { + const { currentType, setCurrentType } = useSnippetsFilters() + const tabName = type ?? 'all' + + return ( + { + event.preventDefault() + + if (type && !isLicensed() && isProType(type)) { + setIsUpgradeDialogOpen(true) + } else { + setCurrentType(type) + } + }} + > + + {type ? SNIPPET_TYPE_LABELS[type] : __('All Snippets', 'code-snippets')} + + {type && + } + + ) +} + +const PageHeading = () => { + const { searchQueryText, searchLineNumber, currentTag, setSearchQuery, setCurrentTag } = useSnippetsFilters() + return ( + <> + + {__('Create new snippet', 'code-snippets')} + + +

+ {__('Manage Code Snippets', 'code-snippets')} + + {searchQueryText || currentTag + ? + {__('Search results', 'code-snippets')} + + {/* translators: %s: search query. */} + {searchQueryText && sprintf(__(' for “%s”', 'code-snippets'), searchQueryText)} + + {/* translators: %s: search query. */} + {searchLineNumber && sprintf(__(' on line “%d”', 'code-snippets'), searchLineNumber)} + + {/* translators: %s: tag name. */} + {currentTag && sprintf(__(' in tag “%s”', 'code-snippets'), currentTag)} + + {' '} + + + : null} +

+ + + + ) +} + +const SafeModeNotice = () => + window.CODE_SNIPPETS_MANAGE?.isSafeModeActive + ? +

+ {__('Warning:', 'code-snippets')}{'\n'} + {__('Safe mode is active and snippets will not execute!', 'code-snippets')}{'\n'} + + {createInterpolateElement( + __('Remove the CODE_SNIPPETS_SAFE_MODE constant from wp-config.php file to turn off safe mode.', 'code-snippets'), + { + code: + } + )}{'\n'} + + + {__('Read more', 'code-snippets')} + +

+
+ : null + +const SnippetsTableInner = () => { + const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) + + return ( +
+ + +

+ + {SNIPPET_TYPES.map(type => + )} +

+ + + + + + +
+ ) +} + +export const SnippetsTable: React.FC = () => + + + + + + + diff --git a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx new file mode 100644 index 000000000..2b9d6c97c --- /dev/null +++ b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx @@ -0,0 +1,248 @@ +import React, { Fragment, useState } from 'react' +import { __, sprintf } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' +import { humanTimeDiff } from '@wordpress/date' +import { RawHTML } from '@wordpress/element' +import { useFilteredSnippets } from '../../../hooks/useFilteredSnippets' +import { useRestAPI } from '../../../hooks/useRestAPI' +import { useSnippetsFilters } from '../../../hooks/useSnippetsFilters' +import { useSnippetsList } from '../../../hooks/useSnippetsList' +import { handleUnknownError } from '../../../utils/errors' +import { downloadSnippetExportFile } from '../../../utils/files' +import { isNetworkAdmin } from '../../../utils/screen' +import { getSnippetDisplayName, getSnippetEditUrl, getSnippetType } from '../../../utils/snippets/snippets' +import { Badge } from '../../common/Badge' +import { Button } from '../../common/Button' +import { DeleteButton } from '../../common/DeleteButton' +import type { Snippet } from '../../../types/Snippet' +import type { ListTableColumn } from '../../common/ListTable' + +interface ColumnProps { + snippet: Snippet +} + +const ActivateColumn: React.FC = ({ snippet }) => { + const { snippetsAPI: { activate, deactivate } } = useRestAPI() + const { activeByCondition } = useFilteredSnippets() + const { refreshSnippetsList } = useSnippetsList() + + switch (snippet.scope) { + case 'single-use': + return ( + +   + + ) + + case 'condition': + return ( + + {activeByCondition.get(snippet.id)?.length ?? 0} + + ) + + default: { + const actionText = snippet.network && !snippet.shared_network + ? snippet.active ? __('Network Deactivate', 'code-snippets') : __('Network Activate', 'code-snippets') + : snippet.active ? __('Deactivate', 'code-snippets') : __('Activate', 'code-snippets') + + return ( + <> + + + { + (snippet.active ? deactivate(snippet) : activate(snippet)) + .then(() => refreshSnippetsList()) + .catch(handleUnknownError) + }} + /> + + ) + } + } +} + +const RowActions: React.FC = ({ snippet }) => { + const { snippetsAPI } = useRestAPI() + const { refreshSnippetsList } = useSnippetsList() + + if (!isNetworkAdmin() && snippet.network && !snippet.shared_network) { + return ( +
+ {snippet.active + ? {__('Network Active', 'code-snippets')} + : {__('Network Only', 'code-snippets')}} +
+ ) + } + + if (snippet.shared_network && !window.CODE_SNIPPETS_MANAGE?.hasNetworkCap) { + return undefined + } + + return ( +
+ {__('Edit', 'code-snippets')}{' | '} + + {' | '} + + {' | '} + + +
+ ) +} + +const NameColumn: React.FC = ({ snippet }) => + <> + {isNetworkAdmin() || !snippet.network || window.CODE_SNIPPETS_MANAGE?.hasNetworkCap + ? {getSnippetDisplayName(snippet)} + : getSnippetDisplayName(snippet)} + + {snippet.shared_network && {__('Shared on Network', 'code-snippets')}} + + + + +const TypeColumn: React.FC = ({ snippet }) => { + const { setCurrentType } = useSnippetsFilters() + const type = getSnippetType(snippet) + + return ( + { + event.preventDefault() + setCurrentType(type) + }} + > + + + ) +} + +const TagsColumn: React.FC = ({ snippet }) => + snippet.tags.map((tag, index) => + + + {tag} + + {index < snippet.tags.length - 1 ? ', ' : ''} + ) + +const DateColumn: React.FC = ({ snippet }) => + snippet.modified + ? + + + : <>— + +const PriorityColumn: React.FC = ({ snippet }) => { + const [value, setValue] = useState(snippet.priority) + const { snippetsAPI } = useRestAPI() + const { refreshSnippetsList } = useSnippetsList() + const id = `snippet-${snippet.id}-priority` + + const handleUpdate = () => { + snippetsAPI.update({ ...snippet, priority: value }) + .then(response => { + if (response.id === snippet.id) { + setValue(response.priority) + } + }) + .then(refreshSnippetsList) + .catch(handleUnknownError) + } + + return ( +
{ + event.preventDefault() + handleUpdate() + }}> + + setValue(Number(event.target.value))} + /> +
+ ) +} + +export const TableColumns: ListTableColumn[] = [ + { + id: 'activate', + render: snippet => + }, + { + id: 'name', + title: __('Name', 'code-snippets'), + isPrimary: true, + sortedValue: snippet => getSnippetDisplayName(snippet).toLowerCase(), + render: snippet => + }, + { + id: 'type', + title: __('Type', 'code-snippets'), + sortedValue: snippet => getSnippetType(snippet), + render: snippet => + }, + { + id: 'desc', + title: __('Description', 'code-snippets'), + render: snippet => {snippet.desc} + }, + { + id: 'tags', + title: __('Tags', 'code-snippets'), + render: snippet => + }, + { + id: 'date', + title: __('Modified', 'code-snippets'), + sortedValue: snippet => snippet.modified ? new Date(snippet.modified).toISOString() : '', + render: snippet => + }, + { + id: 'priority', + title: __('Priority', 'code-snippets'), + sortedValue: snippet => snippet.priority, + render: snippet => + } +] diff --git a/src/js/components/ManageMenu/SnippetsTable/index.ts b/src/js/components/ManageMenu/SnippetsTable/index.ts new file mode 100644 index 000000000..b8db6e572 --- /dev/null +++ b/src/js/components/ManageMenu/SnippetsTable/index.ts @@ -0,0 +1 @@ +export * from './SnippetsTable' diff --git a/src/js/components/ManageMenu/index.ts b/src/js/components/ManageMenu/index.ts new file mode 100644 index 000000000..c60601a70 --- /dev/null +++ b/src/js/components/ManageMenu/index.ts @@ -0,0 +1 @@ +export * from './ManageMenu' diff --git a/src/js/components/SnippetForm/fields/CodeEditorShortcuts.tsx b/src/js/components/SnippetForm/fields/CodeEditorShortcuts.tsx deleted file mode 100644 index 9b04cb60b..000000000 --- a/src/js/components/SnippetForm/fields/CodeEditorShortcuts.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { __, _x } from '@wordpress/i18n' -import classnames from 'classnames' -import React from 'react' -import { KEYBOARD_KEYS } from '../../../types/KeyboardShortcut' -import { isMacOS } from '../../../utils/screen' -import type { KeyboardKey, KeyboardShortcut } from '../../../types/KeyboardShortcut' - -const shortcuts: Record = { - saveChanges: { - label: __('Save changes', 'code-snippets'), - mod: 'Cmd', - key: 'S' - }, - selectAll: { - label: __('Select all', 'code-snippets'), - mod: 'Cmd', - key: 'A' - }, - beginSearch: { - label: __('Begin searching', 'code-snippets'), - mod: 'Cmd', - key: 'F' - }, - findNext: { - label: __('Find next', 'code-snippets'), - mod: 'Cmd', - key: 'G' - }, - findPrevious: { - label: __('Find previous', 'code-snippets'), - mod: ['Shift', 'Cmd'], - key: 'G' - }, - replace: { - label: __('Replace', 'code-snippets'), - mod: ['Shift', 'Cmd'], - key: 'F' - }, - replaceAll: { - label: __('Replace all', 'code-snippets'), - mod: ['Shift', 'Cmd', 'Option'], - key: 'R' - }, - search: { - label: __('Persistent search', 'code-snippets'), - mod: 'Alt', - key: 'F' - }, - toggleComment: { - label: __('Toggle comment', 'code-snippets'), - mod: 'Cmd', - key: '/' - }, - swapLineUp: { - label: __('Swap line up', 'code-snippets'), - mod: 'Option', - key: 'Up' - }, - swapLineDown: { - label: __('Swap line down', 'code-snippets'), - mod: 'Option', - key: 'Down' - }, - autoIndent: { - label: __('Auto-indent current line or selection', 'code-snippets'), - mod: 'Shift', - key: 'Tab' - } -} - -const SEP = _x('-', 'keyboard shortcut separator', 'code-snippets') - -const ModifierKey: React.FC<{ modifier: KeyboardKey }> = ({ modifier }) => { - switch (modifier) { - case 'Ctrl': - case 'Cmd': - return ( - <> - {KEYBOARD_KEYS.Ctrl} - {KEYBOARD_KEYS.Cmd} - {SEP} - - ) - - case 'Option': - return ( - - {KEYBOARD_KEYS.Option}{SEP} - - ) - - default: - return <>{KEYBOARD_KEYS[modifier]}{SEP} - } -} - -export interface CodeEditorShortcutsProps { - editorTheme: string -} - -export const CodeEditorShortcuts: React.FC = ({ editorTheme }) => -
- - -
- - - {Object.entries(shortcuts).map(([name, { label, mod, key }]) => - - - - )} - -
{label} - {(Array.isArray(mod) ? mod : [mod]).map(modifier => - - - - )} - {KEYBOARD_KEYS[key]} -
-
-
diff --git a/src/js/components/WelcomeMenu/Changelog.tsx b/src/js/components/WelcomeMenu/Changelog.tsx new file mode 100644 index 000000000..303667320 --- /dev/null +++ b/src/js/components/WelcomeMenu/Changelog.tsx @@ -0,0 +1,85 @@ +import React, { Fragment } from 'react' +import { __, sprintf } from '@wordpress/i18n' +import { CHANGELOG_SECTIONS } from '../../types/schema/WelcomeSchema' +import type { ChangelogSectionTitle } from '../../types/schema/WelcomeSchema' + +const CHANGELOG_LABELS: Record = { + Added: __('New features', 'code-snippets'), + Changed: __('Improvements', 'code-snippets'), + Deprecated: __('Deprecated features', 'code-snippets'), + Removed: __('Removed features', 'code-snippets'), + Fixed: __('Bug fixes', 'code-snippets'), + Security: __('Security updates', 'code-snippets'), + Other: __('Other', 'code-snippets') +} + +const CHANGELOG_ICONS: Record = { + Added: 'lightbulb', + Changed: 'chart-line', + Deprecated: 'remove', + Removed: 'trash', + Fixed: 'buddicons-replies', + Security: 'shield', + Other: 'open-folder' +} + +const PLUGIN_TYPE_LABELS: Record = { + core: __('Core', 'code-snippets'), + pro: __('Pro', 'code-snippets') +} + +const CHANGELOG_DATA = window.CODE_SNIPPETS_WELCOME?.changelog + +interface ChangelogSectionProps { + section: ChangelogSectionTitle + entries: Record +} + +const ChangelogSection: React.FC = ({ section, entries }) => + <> +

+ + {CHANGELOG_LABELS[section]} +

+
    + {Object.entries(entries).map(([pluginType, changes]) => + changes.map(change => +
  • + + {PLUGIN_TYPE_LABELS[pluginType] ?? pluginType} + + {change} +
  • ) + )} +
+ + +export const Changelog = () => +
+
+

{__('Latest changes', 'code-snippets')}

+ + {__('View changelog', 'code-snippets')} + +
+
+ {CHANGELOG_DATA?.map(({ version, date, entries }) => + +
+ {/* translators: %s: version number. */} +

{sprintf(__('Version %s', 'code-snippets'), version)}

+

{date}

+
+
+ {CHANGELOG_SECTIONS.map(section => + entries[section] + ? + : null)} +
+
)} +
+
diff --git a/src/js/components/WelcomeMenu/WelcomeMenu.tsx b/src/js/components/WelcomeMenu/WelcomeMenu.tsx new file mode 100644 index 000000000..145a03d29 --- /dev/null +++ b/src/js/components/WelcomeMenu/WelcomeMenu.tsx @@ -0,0 +1,99 @@ +import { __ } from '@wordpress/i18n' +import React, { useState } from 'react' +import { Toolbar } from '../common/Toolbar' +import { Changelog } from './Changelog' +import type { ImageLinkSchema } from '../../types/schema/WelcomeSchema' + +const DATA = window.CODE_SNIPPETS_WELCOME + +const HeroImage = () => { + const [isImageLoaded, setImageLoaded] = useState(false) + + return ( +
+
+

{DATA?.hero.name}

+ + {__('Read more', 'code-snippets')} + +
+
+ {!isImageLoaded &&
} + {__('Latest setImageLoaded(true)} + /> +
+
+ ) +} + +interface PartnersProps { + partners: ImageLinkSchema[] +} + +const Partners: React.FC = ({ partners }) => + <> +

{__('Exclusive deals from our partners', 'code-snippets')}

+
+ {partners.map(({ title, follow_url, image_url }) => + )} +
+ + +interface ArticlesProps { + articles: ImageLinkSchema[] +} + +const Articles: React.FC = ({ articles }) => + <> +

{__('Helpful articles', 'code-snippets')}

+
+ {articles.map(({ title, follow_url, image_url, description, category }) => + )} +
+ + +export const WelcomeMenu = () => + <> + +
+

{__('Resources and Updates', 'code-snippets')}

+ +
+ + +
+ + {DATA?.features && } + {DATA?.partners && } +
+ diff --git a/src/js/components/WelcomeMenu/index.ts b/src/js/components/WelcomeMenu/index.ts new file mode 100644 index 000000000..c6aa4faaa --- /dev/null +++ b/src/js/components/WelcomeMenu/index.ts @@ -0,0 +1 @@ +export * from './WelcomeMenu' diff --git a/src/js/components/EditorSidebar/actions/DeleteButton.tsx b/src/js/components/common/DeleteButton.tsx similarity index 50% rename from src/js/components/EditorSidebar/actions/DeleteButton.tsx rename to src/js/components/common/DeleteButton.tsx index 7d584e582..ba28262a2 100644 --- a/src/js/components/EditorSidebar/actions/DeleteButton.tsx +++ b/src/js/components/common/DeleteButton.tsx @@ -1,22 +1,34 @@ -import { addQueryArgs } from '@wordpress/url' import React, { useState } from 'react' import { __ } from '@wordpress/i18n' -import { useRestAPI } from '../../../hooks/useRestAPI' -import { Button } from '../../common/Button' -import { ConfirmDialog } from '../../common/ConfirmDialog' -import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { useRestAPI } from '../../hooks/useRestAPI' +import { Button } from './Button' +import { ConfirmDialog } from './ConfirmDialog' +import type { Snippet } from '../../types/Snippet' +import type { ButtonProps } from './Button' -export const DeleteButton: React.FC = () => { +export interface DeleteButtonProps extends ButtonProps { + snippet: Snippet + setIsWorking?: (isWorking: boolean) => void + onSuccess?: () => Promise | void + onError?: (error: unknown) => void +} + +export const DeleteButton: React.FC = ({ + snippet, + onSuccess, + onError, + className = 'delete-button', + setIsWorking, + ...buttonProps +}) => { const { snippetsAPI } = useRestAPI() - const { snippet, setIsWorking, isWorking, handleRequestError } = useSnippetForm() const [isDialogOpen, setIsDialogOpen] = useState(false) return ( <> -
diff --git a/src/js/components/common/ListTable/ListTable.tsx b/src/js/components/common/ListTable/ListTable.tsx new file mode 100644 index 000000000..19adf6848 --- /dev/null +++ b/src/js/components/common/ListTable/ListTable.tsx @@ -0,0 +1,144 @@ +import React, { useMemo, useState } from 'react' +import classnames from 'classnames' +import { fetchQueryParam } from '../../../utils/urls' +import { TableHeadings } from './TableHeadings' +import { TableItems } from './TableItems' +import { TableNav } from './TableNav' +import type { TableNavProps } from './TableNav' +import type { TableHeadingsProps } from './TableHeadings' +import type { Key, ReactNode } from 'react' + +export interface ListTableColumn { + id: Key + title?: ReactNode + render: (item: T) => ReactNode + isHidden?: boolean + isPrimary?: boolean + isHeading?: boolean + sortedValue?: (item: T) => Key + defaultSortDirection?: ListTableSortDirection +} + +export interface ListTableBulkAction { + name: string + apply: (selected: Set) => Promise +} + +export interface ListTableBulkActionGroup { + name: string + actions: ListTableBulkAction[] +} + +export type ListTableSortDirection = 'asc' | 'desc' + +export interface ListTableNavProps { + actions?: readonly (ListTableBulkAction | ListTableBulkActionGroup)[] + isDisabled?: boolean + extraTableNav?: (which: 'top' | 'bottom') => ReactNode +} + +export interface ListTableItemsProps { + items: T[] + getKey: (item: T) => K + columns: ListTableColumn[] + noItems?: ReactNode + rowClassName?: (item: T) => string +} + +export interface ListTablePaginationProps { + totalPages?: number + useQueryVars?: boolean +} + +export interface ListTableProps extends ListTableItemsProps, ListTableNavProps, ListTablePaginationProps { + fixed?: boolean + striped?: boolean + className?: string +} + +const sortItems = ( + items: T[], + sortColumn: ListTableColumn | undefined, + sortDirection: ListTableSortDirection +): T[] => + items.toSorted((itemA, itemB) => { + const valueA = sortColumn?.sortedValue?.(itemA) + const valueB = sortColumn?.sortedValue?.(itemB) + + if (valueA === undefined || valueB === undefined) { + return 0 + } + + if (valueA < valueB) { + return 'asc' === sortDirection ? -1 : 1 + } + + if (valueA > valueB) { + return 'asc' === sortDirection ? 1 : -1 + } + + return 0 + }) + +const pageItems = ( + items: T[], + { currentPage, totalPages }: { currentPage: number; totalPages?: number } +): T[] => { + if (totalPages) { + const itemsPerPage = Math.ceil(items.length / totalPages) + const start = (currentPage - 1) * itemsPerPage + const end = start + itemsPerPage + return items.slice(start, end) + } else { + return items + } +} + +export const ListTable = ({ + items, + fixed, + striped, + getKey, + columns, + actions, + noItems, + className, + totalPages, + rowClassName, + extraTableNav, + useQueryVars = true, + isDisabled = false +}: ListTableProps) => { + const [selected, setSelected] = useState(new Set()) + const [sortColumn, setSortColumn] = useState>() + const [currentPage, setCurrentPage] = useState(() => useQueryVars && Number(fetchQueryParam('paged')) || 1) + const [sortDirection, setSortDirection] = useState('asc') + + const visibleItems: T[] = useMemo( + () => pageItems(sortItems(items, sortColumn, sortDirection), { currentPage, totalPages }), + [items, sortColumn, sortDirection, currentPage, totalPages]) + + const tableNavProps: Omit, 'which'> = + { totalItems: items.length, actions, extraTableNav, selected, isDisabled, currentPage, totalPages, setCurrentPage, useQueryVars } + + const tableHeadingsProps: Omit, 'which'> = + { items: visibleItems, setSelected, columns, getKey, sortColumn, setSortColumn, sortDirection, setSortDirection } + + return ( + <> + + + + + + + + + + + +
+ + + ) +} diff --git a/src/js/components/common/ListTable/TableHeadings.tsx b/src/js/components/common/ListTable/TableHeadings.tsx new file mode 100644 index 000000000..55cbf9096 --- /dev/null +++ b/src/js/components/common/ListTable/TableHeadings.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import classnames from 'classnames' +import { __ } from '@wordpress/i18n' +import type { ListTableColumn, ListTableProps, ListTableSortDirection } from './ListTable' +import type { Dispatch, Key, SetStateAction, ThHTMLAttributes } from 'react' + +interface SortableHeadingProps { + column: ListTableColumn + cellProps: ThHTMLAttributes + sortColumn: ListTableColumn | undefined + sortDirection: ListTableSortDirection + setSortColumn: Dispatch | undefined>> + setSortDirection: Dispatch> +} + +const SortableHeading = ({ + column, + cellProps, + sortColumn, + setSortColumn, + sortDirection, + setSortDirection +}: SortableHeadingProps) => { + const isCurrent = column.id === sortColumn?.id + + const newSortDirection = isCurrent + ? 'asc' === sortDirection ? 'desc' : 'asc' + : column.defaultSortDirection ?? 'asc' + + return ( + + { + event.preventDefault() + setSortColumn(column) + setSortDirection(newSortDirection) + }}> + {column.title} + + + + + {isCurrent ? null + : + {/* translators: Hidden accessibility text. */} + {'asc' === newSortDirection ? __('Sort ascending.', 'code-snippets') : __('Sort descending.', 'code-snippets')} + } + + + ) +} + +export interface TableHeadingsProps extends Pick, 'columns' | 'getKey' | 'items'> { + which: 'head' | 'foot' + sortColumn: ListTableColumn | undefined + setSelected: Dispatch>> + sortDirection: ListTableSortDirection + setSortColumn: Dispatch | undefined>> + setSortDirection: Dispatch> +} + +export const TableHeadings = ({ + items, + which, + getKey, + columns, + sortColumn, + setSelected, + setSortColumn, + sortDirection, + setSortDirection +}: TableHeadingsProps) => + + + { + setSelected(new Set(event.target.checked ? items.map(getKey) : null)) + }} + /> + + + {columns.map(column => { + const cellProps: ThHTMLAttributes = { + id: 'head' === which ? column.id.toString() : undefined, + scope: 'col', + className: classnames( + 'manage-column', + `column-${column.id}`, + `${column.id}-column`, + { 'hidden': column.isHidden, 'column-primary': column.isPrimary } + ) + } + + return column.sortedValue + ? + : {column.title} + })} + diff --git a/src/js/components/common/ListTable/TableItems.tsx b/src/js/components/common/ListTable/TableItems.tsx new file mode 100644 index 000000000..7fd3ea00e --- /dev/null +++ b/src/js/components/common/ListTable/TableItems.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import type { Dispatch, Key, SetStateAction } from 'react' +import type { ListTableColumn, ListTableItemsProps } from './ListTable' + +interface CheckboxCellProps extends Pick, 'getKey'> { + item: T + setSelected: Dispatch>> +} + +const CheckboxCell = ({ item, setSelected, getKey }: CheckboxCellProps) => + + { + setSelected(previous => { + const updated = new Set(previous) + + if (event.target.checked) { + updated.add(getKey(item)) + } else { + updated.delete(getKey(item)) + } + + return updated + }) + }} + /> + + +interface TableCellProps { + item: T + column: ListTableColumn +} + +const TableCell = ({ item, column }: TableCellProps) => { + const className = `${column.id}-column column-${column.id}` + + return column.isHeading + ? {column.render(item)} + : {column.render(item)} +} + +export interface TableItemsProps + extends Pick, 'items' | 'getKey' | 'columns' | 'noItems' | 'rowClassName'> { + setSelected: Dispatch>> +} + +export const TableItems = ({ items, getKey, columns, noItems, setSelected, rowClassName }: TableItemsProps) => + 0 < items.length + ? items.map(item => + + + + {columns.map(column => + )} + + ) + : + {noItems} + diff --git a/src/js/components/common/ListTable/TableNav.tsx b/src/js/components/common/ListTable/TableNav.tsx new file mode 100644 index 000000000..a0ac19fd0 --- /dev/null +++ b/src/js/components/common/ListTable/TableNav.tsx @@ -0,0 +1,120 @@ +import React, { useMemo, useState } from 'react' +import { __ } from '@wordpress/i18n' +import { Spinner } from '@wordpress/components' +import { handleUnknownError } from '../../../utils/errors' +import { SubmitButton } from '../SubmitButton' +import { TablePagination } from './TablePagination' +import type { TablePaginationProps } from './TablePagination' +import type { ListTableBulkAction, ListTableNavProps } from './ListTable' +import type { Key } from 'react' + +interface BulkActionSelectProps extends Required, 'which' | 'actions'>> { + setSelectedAction: (action: ListTableBulkAction | undefined) => void +} + +const BulkActionSelect = ({ which, actions, setSelectedAction }: BulkActionSelectProps) => { + const actionsMap: Map> = useMemo( + () => new Map( + actions + .flatMap(actionOrGroup => + 'actions' in actionOrGroup ? actionOrGroup.actions : [actionOrGroup]) + .map(action => [action.name, action]) + ), [actions]) + + return ( + + ) +} + +interface BulkActionsProps extends Required, 'which' | 'actions'>> { + applyAction: (action: ListTableBulkAction) => Promise +} + +const BulkActions = ({ which, actions, applyAction }: BulkActionsProps) => { + const [selectedAction, setSelectedAction] = useState>() + const [isPerformingAction, setIsPerformingAction] = useState(false) + + return ( +
+ + + + + { + event.preventDefault() + + if (selectedAction) { + setIsPerformingAction(true) + applyAction(selectedAction) + .catch(handleUnknownError) + .finally(() => { + setSelectedAction(undefined) + setIsPerformingAction(false) + }) + } + }} + /> + + {isPerformingAction ? : null} +
+ ) +} + +export interface TableNavProps extends ListTableNavProps, Omit { + which: 'top' | 'bottom' + selected: Set + totalItems: number + totalPages: number | undefined +} + +export const TableNav = ({ + which, + actions, + selected, + totalItems, + totalPages, + extraTableNav, + ...paginationProps +}: TableNavProps) => + extraTableNav || 0 < totalItems && actions + ?
+ + {0 < totalItems && actions + ? action.apply(selected)} + /> + : null} + + {extraTableNav?.(which)} + {totalPages && } + +
+
+ : null diff --git a/src/js/components/common/ListTable/TablePagination.tsx b/src/js/components/common/ListTable/TablePagination.tsx new file mode 100644 index 000000000..71b267da5 --- /dev/null +++ b/src/js/components/common/ListTable/TablePagination.tsx @@ -0,0 +1,265 @@ +import React, { useState } from 'react' +import classnames from 'classnames' +import { __, _n, _x, sprintf } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' +import { updateQueryParam } from '../../../utils/urls' +import { Button } from '../Button' +import type { ListTablePaginationProps } from './ListTable' +import type { ReactNode } from 'react' + +interface NavigationButtonProps { + icon: ReactNode + newPage: number + className?: string + helperText?: string + renderAsLinks?: boolean + setCurrentPage: (page: number) => void +} + +const NavigationButton: React.FC = ({ icon, newPage, className, helperText, renderAsLinks, setCurrentPage }) => + renderAsLinks + ? <> + { + event.preventDefault() + setCurrentPage(newPage) + }} + > + {helperText} + {icon} + {'\n'} + + : + +interface NavigationButtonsProps { + currentPage: number + renderAsLinks?: boolean + setCurrentPage: (page: number) => void +} + +const BackwardNavigationButtons: React.FC = ({ currentPage, ...buttonProps }) => + 1 === currentPage + ? <> + {'\n'} + {'\n'} + + : <> + «} + newPage={1} + className="first-page" + /* translators: Hidden accessibility text. */ + helperText={__('First page', 'code-snippets')} + {...buttonProps} + />{'\n'} + ‹} + newPage={Math.max(1, currentPage - 1)} + className="prev-page" + /* translators: Hidden accessibility text. */ + helperText={__('Previous page', 'code-snippets')} + {...buttonProps} + />{'\n'} + + +interface ForwardNavigationButtonsProps extends NavigationButtonsProps { + totalPages: number +} + +const ForwardNavigationButtons: React.FC = ({ currentPage, totalPages, ...buttonProps }) => + totalPages === currentPage + ? <> + {'\n'} + + + : <> + ›} + newPage={Math.min(totalPages, currentPage + 1)} + className="next-page" + /* translators: Hidden accessibility text. */ + helperText={__('Next page', 'code-snippets')} + {...buttonProps} + />{'\n'} + »} + newPage={totalPages} + className="last-page" + /* translators: Hidden accessibility text. */ + helperText={__('Last page', 'code-snippets')} + {...buttonProps} + />{'\n'} + + +interface PagingInputProps { + which: 'top' | 'bottom' + totalPages: number + inputValue: number + setInputValue: (value: number) => void + confirmInputValue: VoidFunction +} + +const PagingInput: React.FC = ({ which, totalPages, inputValue, setInputValue, confirmInputValue }) => + <> + + { + const value = Number(event.target.value) + + if (value) { + setInputValue(value) + } + }} + /> + + +interface CurrentPageProps extends PagingInputProps { + currentPage: number +} + +const CurrentPage: React.FC = ({ which, totalPages, currentPage, ...inputProps }) => + 'bottom' === which + ? <> + {/* translators: Hidden accessibility text. */} + {__('Current Page', 'code-snippets')} + + + {/* translators: 1: Current page. */ + sprintf(_x('%s of ', 'paging', 'code-snippets'), currentPage)} + {totalPages} + + + + : + + + {/* translators: 1: Current page. */ + _x(' of ', 'paging', 'code-snippets')} + {totalPages} + + + +interface PaginationControlsProps { + which: 'top' | 'bottom' + inputValue: number + totalPages: number + totalItems: number + currentPage: number + useQueryVars?: boolean + setInputValue: (value: number) => void + setCurrentPage: (page: number) => void +} + +const PaginationControls: React.FC = ({ + which, + totalPages, + totalItems, + inputValue, + currentPage, + useQueryVars, + setCurrentPage, + setInputValue +}) => +
{ + event.preventDefault() + setCurrentPage(inputValue) + }} + > + + {/* translators: %s: Number of items. */} + {sprintf(_n('%s item', '%s items', totalItems), totalItems)} + {'\n'} + + + + + setCurrentPage(inputValue)} + />{'\n'} + + + +
+ +export interface TablePaginationProps extends Omit, + Required> { + which: 'top' | 'bottom' + totalItems: number + currentPage: number + setCurrentPage: (page: number) => void +} + +export const TablePagination: React.FC = ({ + which, + totalItems, + currentPage, + totalPages, + useQueryVars, + setCurrentPage +}) => { + const [inputValue, setInputValue] = useState(currentPage) + + const setCurrentPageSafe = (page: number) => { + if (page) { + const validPage = Math.max(1, Math.min(page, totalPages)) + setInputValue(validPage) + setCurrentPage(validPage) + + if (useQueryVars) { + updateQueryParam('paged', 1 === validPage ? undefined : validPage) + } + } + } + + return ( + + ) +} diff --git a/src/js/components/common/ListTable/index.ts b/src/js/components/common/ListTable/index.ts new file mode 100644 index 000000000..d862c2069 --- /dev/null +++ b/src/js/components/common/ListTable/index.ts @@ -0,0 +1 @@ +export * from './ListTable' diff --git a/src/js/components/common/Notice.tsx b/src/js/components/common/Notice.tsx new file mode 100644 index 000000000..7c414ab04 --- /dev/null +++ b/src/js/components/common/Notice.tsx @@ -0,0 +1,33 @@ +import { __ } from '@wordpress/i18n' +import classnames from 'classnames' +import React from 'react' +import type { HTMLAttributes, ReactNode } from 'react' + +export type NoticeType = 'info' | 'warning' | 'error' | 'success' + +export interface NoticeProps extends Omit, 'className'> { + className?: classnames.Argument + children?: ReactNode + type?: NoticeType +} + +export const Notice: React.FC = ({ className, type, children, ...props }) => +
+ {children} +
+ +export interface DismissibleNoticeProps extends NoticeProps { + onDismiss: VoidFunction +} + +export const DismissibleNotice: React.FC = ({ className, onDismiss, children, ...noticeProps }) => + + {children} + + + diff --git a/src/js/components/common/SubmitButton.tsx b/src/js/components/common/SubmitButton.tsx index 5c6bfeac0..86ee371b9 100644 --- a/src/js/components/common/SubmitButton.tsx +++ b/src/js/components/common/SubmitButton.tsx @@ -7,6 +7,7 @@ export interface SubmitButtonProps extends Omit = ({ text, name = 'submit', primary, + secondary, small, large, wrap, @@ -34,6 +36,7 @@ export const SubmitButton: React.FC = ({ 'button', { 'button-primary': primary, + 'button-secondary': secondary, 'button-small': small, 'button-large': large }, diff --git a/src/js/components/common/Toolbar.tsx b/src/js/components/common/Toolbar.tsx new file mode 100644 index 000000000..1853a4d8c --- /dev/null +++ b/src/js/components/common/Toolbar.tsx @@ -0,0 +1,157 @@ +import { __ } from '@wordpress/i18n' +import classnames from 'classnames' +import React, { useState } from 'react' +import { addQueryArgs } from '@wordpress/url' +import { isLicensed, shouldShowUpsell } from '../../utils/screen' +import { fetchQueryParam } from '../../utils/urls' +import { CommunityIcon, LibraryIcon, SettingsIcon, SnippetsIcon, TeamsIcon } from './icons/ToolbarIcons' +import { UpsellDialog } from './UpsellDialog' +import type { ReactNode} from 'react' + +interface NavLink { + name: string + url: string | undefined + label: string + external?: boolean + icon?: ReactNode + pro?: boolean +} + +const UPPER_NAV_LINKS: NavLink[] = [ + { + name: 'docs', + url: 'https://help.codesnippets.pro/', + label: __('Docs', 'code-snippets'), + external: true + }, + { + name: 'cloud', + url: 'https://codesnippets.cloud/', + label: __('Cloud Dashboard', 'code-snippets'), + external: true + }, + { + name: 'welcome', + url: window.CODE_SNIPPETS?.urls.welcome, + label: __("What's New", 'code-snippets') + } +] + +const LOWER_NAV_LINKS: NavLink[] = [ + { + name: 'snippets', + url: window.CODE_SNIPPETS?.urls.manage, + label: __('Snippets', 'code-snippets'), + icon: + }, + { + name: 'cloud-community', + url: addQueryArgs(window.CODE_SNIPPETS?.urls.manage, { subpage: 'cloud-community' }), + label: __('Community Cloud', 'code-snippets'), + icon: + }, + { + name: 'cloud-library', + url: undefined, + label: __('My Library', 'code-snippets'), + icon: , + pro: true + }, + { + name: 'cloud-teams', + url: undefined, + label: __('My Teams', 'code-snippets'), + icon: , + pro: true + }, + { + name: 'settings', + url: window.CODE_SNIPPETS?.urls.settings, + label: __('Settings', 'code-snippets'), + icon: + } +] + +interface NavProps { + setIsUpsellDialogOpen: (isOpen: boolean) => void +} + +const UpperNav: React.FC = ({ setIsUpsellDialogOpen }) => +
+
+ {__('Code + +

{__('Code Snippets', 'code-snippets')}

+
+ + +
+ +const currentPage = fetchQueryParam('subpage') ?? fetchQueryParam('page') + +const LowerNav: React.FC = ({ setIsUpsellDialogOpen }) => + + +export const Toolbar = () => { + const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false) + + return ( +
+ + + +
+ ) +} diff --git a/src/js/components/common/UpsellBanner.tsx b/src/js/components/common/UpsellBanner.tsx index 50a58aa01..0b0c31a54 100644 --- a/src/js/components/common/UpsellBanner.tsx +++ b/src/js/components/common/UpsellBanner.tsx @@ -2,13 +2,13 @@ import { ExternalLink } from '@wordpress/components' import { createInterpolateElement } from '@wordpress/element' import { __ } from '@wordpress/i18n' import React, { useState } from 'react' -import { isLicensed } from '../../utils/screen' +import { shouldShowUpsell } from '../../utils/screen' import { Button } from './Button' export const UpsellBanner = () => { const [isDismissed, setIsDismissed] = useState(false) - return isDismissed || isLicensed() || window.CODE_SNIPPETS_EDIT?.hideUpsell + return isDismissed || shouldShowUpsell() ? null :
+ + + + +export const LibraryIcon = () => + + + + +export const SettingsIcon = () => + + + + + +export const SnippetsIcon = () => + + + + +export const TeamsIcon = () => + + + diff --git a/src/js/edit.tsx b/src/js/edit.tsx deleted file mode 100644 index 8968cc235..000000000 --- a/src/js/edit.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' -import { SnippetForm } from './components/SnippetForm' - -const container = document.getElementById('edit-snippet-form-container') - -if (container) { - const root = createRoot(container) - root.render() -} else { - console.error('Could not find snippet edit form container.') -} diff --git a/src/js/entries/edit.ts b/src/js/entries/edit.ts new file mode 100644 index 000000000..c5c4676e2 --- /dev/null +++ b/src/js/entries/edit.ts @@ -0,0 +1,4 @@ +import { EditMenu } from '../components/EditMenu' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('edit-snippet-container', EditMenu) diff --git a/src/js/editor.ts b/src/js/entries/editor.ts similarity index 92% rename from src/js/editor.ts rename to src/js/entries/editor.ts index c36e2db52..bc6774dcd 100644 --- a/src/js/editor.ts +++ b/src/js/entries/editor.ts @@ -1,5 +1,5 @@ import { defineMode, getMode, registerHelper } from 'codemirror' -import { Linter } from './utils/Linter' +import { Linter } from '../utils/Linter' import type { EditorConfiguration, ModeSpec } from 'codemirror' interface ModeSpecOptions { diff --git a/src/js/entries/import.ts b/src/js/entries/import.ts new file mode 100644 index 000000000..2ece8e305 --- /dev/null +++ b/src/js/entries/import.ts @@ -0,0 +1,4 @@ +import { ImportMenu } from '../components/ImportMenu' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('import-container', ImportMenu) diff --git a/src/js/entries/manage.ts b/src/js/entries/manage.ts new file mode 100644 index 000000000..55bf4b8d3 --- /dev/null +++ b/src/js/entries/manage.ts @@ -0,0 +1,4 @@ +import { ManageMenu } from '../components/ManageMenu' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('manage-snippets-container', ManageMenu) diff --git a/src/js/mce.ts b/src/js/entries/mce.ts similarity index 98% rename from src/js/mce.ts rename to src/js/entries/mce.ts index 27274837c..9ea0a514a 100644 --- a/src/js/mce.ts +++ b/src/js/entries/mce.ts @@ -1,7 +1,7 @@ import tinymce from 'tinymce' import type { Editor } from 'tinymce' -import type { ContentShortcodeAtts, SourceShortcodeAtts } from './types/Shortcodes' -import type { LocalisedEditor } from './types/WordPressEditor' +import type { ContentShortcodeAtts, SourceShortcodeAtts } from '../types/Shortcodes' +import type { LocalisedEditor } from '../types/WordPressEditor' const convertToValues = (array: Record) => Object.keys(array).map(key => ({ diff --git a/src/js/prism.ts b/src/js/entries/prism.ts similarity index 100% rename from src/js/prism.ts rename to src/js/entries/prism.ts diff --git a/src/js/entries/settings.ts b/src/js/entries/settings.ts new file mode 100644 index 000000000..be9ebb754 --- /dev/null +++ b/src/js/entries/settings.ts @@ -0,0 +1,9 @@ +import { Toolbar } from '../components/common/Toolbar' +import { handleEditorPreviewUpdates, handleSettingsTabs, initVersionSwitch } from '../services/settings' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('code-snippets-toolbar-container', Toolbar) + +handleSettingsTabs() +handleEditorPreviewUpdates() +initVersionSwitch() diff --git a/src/js/entries/welcome.ts b/src/js/entries/welcome.ts new file mode 100644 index 000000000..b552a1941 --- /dev/null +++ b/src/js/entries/welcome.ts @@ -0,0 +1,4 @@ +import { WelcomeMenu } from '../components/WelcomeMenu' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('code-snippets-welcome-container', WelcomeMenu) diff --git a/src/js/hooks/useCloudSearch.tsx b/src/js/hooks/useCloudSearch.tsx new file mode 100644 index 000000000..dc4ec3ed8 --- /dev/null +++ b/src/js/hooks/useCloudSearch.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useState } from 'react' +import { addQueryArgs } from '@wordpress/url' +import { createContextHook } from '../utils/bootstrap' +import { REST_CLOUD_SEARCH_BASE } from '../utils/restAPI' +import { useRestAPI } from './useRestAPI' +import type { Dispatch, PropsWithChildren, SetStateAction } from 'react' +import type { CloudSnippetSchema } from '../types/schema/CloudSnippetSchema' + +interface CloudSearchContext { + page: number + error: boolean + query: string + doSearch: VoidFunction + totalItems: number + totalPages: number + isSearching: boolean + searchResults: CloudSnippetSchema[] | undefined + setPage: Dispatch> + setQuery: Dispatch> + searchByCodevault: boolean + setSearchByCodevault: Dispatch> +} + +export const [CloudSearchContext, useCloudSearch] = createContextHook('useCloudSearch') + +export const WithCloudSearchContext: React.FC = ({ children }) => { + const { api } = useRestAPI() + const [page, setPage] = useState(1) + const [query, setQuery] = useState('') + const [searchByCodevault, setSearchByCodevault] = useState(false) + + const [totalItems, setTotalItems] = useState(0) + const [totalPages, setTotalPages] = useState(0) + + const [searchResults, setSearchResults] = useState(() => { + const results = window.localStorage.getItem('code-snippets-cloud-search') // TODO remove this. + return results ? JSON.parse(results) as CloudSnippetSchema[] : undefined + }) + const [isSearching, setIsSearching] = useState(false) + const [error, setError] = useState(false) + + const doSearch = useCallback(() => { + setIsSearching(true) + + api + .getResponse(addQueryArgs(REST_CLOUD_SEARCH_BASE, { query, searchByCodevault, page })) + .then(response => { + setTotalItems(Number(response.headers['x-wp-total'])) + setTotalPages(Number(response.headers['x-wp-totalpages'])) + setSearchResults(response.data) + setIsSearching(false) + + window.localStorage.setItem('code-snippets-cloud-search', JSON.stringify(response.data)) + }) + .catch((error: unknown) => { + console.error(error) + setIsSearching(false) + setError(true) + }) + }, [api, page, query, searchByCodevault, setError, setSearchResults, setTotalItems, setTotalPages]) + + const value: CloudSearchContext = { + page, + error, + query, + setPage, + setQuery, + doSearch, + totalItems, + totalPages, + isSearching, + searchResults, + searchByCodevault, + setSearchByCodevault + } + + return {children} +} diff --git a/src/js/hooks/useCloudSearchFilters.tsx b/src/js/hooks/useCloudSearchFilters.tsx new file mode 100644 index 000000000..8f22993c8 --- /dev/null +++ b/src/js/hooks/useCloudSearchFilters.tsx @@ -0,0 +1,40 @@ +import React, { useMemo , useState } from 'react' +import { createContextHook } from '../utils/bootstrap' +import { fetchQueryParam } from '../utils/urls' +import { useCloudSearch } from './useCloudSearch' +import type { CloudSearchFilters } from '../components/ManageMenu/CommunityCloud/SearchFilters' +import type { Dispatch, PropsWithChildren, SetStateAction} from 'react' +import type { CloudSnippetSchema } from '../types/schema/CloudSnippetSchema' + +interface CloudSearchFiltersContext { + filters: CloudSearchFilters + setFilters: Dispatch> + filteredSearchResults?: CloudSnippetSchema[] +} + +export const [CloudSearchFiltersContext, useCloudSearchFilters] = createContextHook('useCloudSearchFilters') + +export const WithCloudSearchFiltersContext: React.FC = ({ children }) => { + const { searchResults } = useCloudSearch() + + const [filters, setFilters] = useState(() => { + const tags = fetchQueryParam('tags') ?? '' + const status = fetchQueryParam('status') ?? 0 + return { tags, status: Number(status) } + }) + + const filteredSearchResults = useMemo( + () => + searchResults?.filter(snippet => + (!filters.tags || snippet.tags.includes(filters.tags)) && + (!filters.status || snippet.status.valueOf() === filters.status)), + [searchResults, filters]) + + const value: CloudSearchFiltersContext = { + filters, + setFilters, + filteredSearchResults + } + + return {children} +} diff --git a/src/js/hooks/useFileUploadAPI.ts b/src/js/hooks/useFileUploadAPI.ts index 14c6ba067..90208d07f 100644 --- a/src/js/hooks/useFileUploadAPI.ts +++ b/src/js/hooks/useFileUploadAPI.ts @@ -61,27 +61,26 @@ export const useFileUploadAPI = (): FileUploadAPI => { return useMemo((): FileUploadAPI => ({ parseFiles: (request: FileUploadRequest) => { const formData = new FormData() - - for (let i = 0; i < request.files.length; i++) { - formData.append('files[]', request.files[i]) + + for (const file of request.files) { + formData.append('files[]', file) } return axiosInstance.post( - `${ROUTE_BASE}file-upload/parse`, + `${ROUTE_BASE}file-upload/parse`, formData, { headers: { - 'Content-Type': 'multipart/form-data', + 'Content-Type': 'multipart/form-data' } } ) }, - - importSnippets: (request: SnippetImportRequest) => { - return axiosInstance.post( + + importSnippets: (request: SnippetImportRequest) => + axiosInstance.post( `${ROUTE_BASE}file-upload/import`, request ) - } }), [axiosInstance]) } diff --git a/src/js/hooks/useFilteredSnippets.tsx b/src/js/hooks/useFilteredSnippets.tsx new file mode 100644 index 000000000..f3e3c9168 --- /dev/null +++ b/src/js/hooks/useFilteredSnippets.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react' +import { createContextHook } from '../utils/bootstrap' +import { parseSnippetObject } from '../utils/snippets/objects' +import { getSnippetType } from '../utils/snippets/snippets' +import { useSnippetsList } from './useSnippetsList' +import { useSnippetsFilters } from './useSnippetsFilters' +import type { PropsWithChildren} from 'react' +import type { Snippet, SnippetStatus } from '../types/Snippet' + +const partitionSnippetsByStatus = (snippets: Snippet[]): Map => + snippets.reduce((acc, snippet) => { + if (!acc.get(undefined)?.push(snippet)) { + acc.set(undefined, [snippet]) + } + + const status = snippet.lastActive + ? 'recently_activated' + : snippet.active ? 'active' : 'inactive' + + if (!acc.get(status)?.push(snippet)) { + acc.set(status, [snippet]) + } + + return acc + }, new Map()) + +const partitionActiveSnippetsByCondition = (snippets: readonly Snippet[]): Map => + snippets.reduce((acc, snippet) => { + if (snippet.active) { + if (!acc.get(snippet.conditionId)?.push(snippet)) { + acc.set(snippet.conditionId, [snippet]) + } + } + + return acc + }, new Map()) + +export const [FilteredSnippetsContext, useFilteredSnippets] = createContextHook('useFilteredSnippets') + +export interface FilteredSnippetsContext { + snippetsByStatus: Map + activeByCondition: Map +} + +export const WithFilteredSnippetsContext: React.FC = ({ children }) => { + const { snippetsList } = useSnippetsList() + const { currentType, currentTag, searchLineNumber, searchQueryText } = useSnippetsFilters() + + const snippets = useMemo(() => + snippetsList ?? window.CODE_SNIPPETS_MANAGE?.snippetsList.map(parseSnippetObject) ?? [], + [snippetsList]) + + const visibleSnippets = useMemo(() => { + const searchFields = ['name', 'desc', 'code', 'tags'] as const + const sanitizedSearchQueryText = searchQueryText?.toLowerCase().trim() + + return snippets.filter(snippet => { + if (currentType && getSnippetType(snippet) !== currentType) { + return false + } + + if (currentTag && !snippet.tags.includes(currentTag)) { + return false + } + + if (sanitizedSearchQueryText) { + return searchLineNumber !== undefined + ? snippet.code.split('\n')[searchLineNumber]?.includes(sanitizedSearchQueryText) + : searchFields.some(field => + ('tags' === field ? snippet.tags.join(' ') : snippet[field]) + .toLowerCase().includes(sanitizedSearchQueryText)) + } + + return true + }) + }, [snippets, currentTag, currentType, searchQueryText, searchLineNumber]) + + const snippetsByStatus = useMemo(() => + partitionSnippetsByStatus(visibleSnippets), + [visibleSnippets]) + + const activeByCondition = useMemo( + () => partitionActiveSnippetsByCondition(snippets), + [snippets]) + + const value: FilteredSnippetsContext = { + snippetsByStatus, + activeByCondition + } + + return {children} +} diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index 955031833..7f703ab6a 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react' import axios from 'axios' -import { createContextHook } from '../utils/hooks' +import { createContextHook } from '../utils/bootstrap' import { REST_API_AXIOS_CONFIG } from '../utils/restAPI' import { buildSnippetsAPI } from '../utils/snippets/api' import type { SnippetsAPI } from '../utils/snippets/api' @@ -15,6 +15,7 @@ export interface RestAPIContext { export interface RestAPI { get: (url: string) => Promise + getResponse: (url: string) => Promise> post: (url: string, data?: object) => Promise put: (url: string, data?: object) => Promise del: (url: string) => Promise @@ -25,28 +26,39 @@ const debugRequest = async ( url: string, doRequest: Promise>, data?: D -): Promise => { - console.debug(`${method} ${url}`, ...data ? [data] : []) - const response = await doRequest - console.debug('Response', response) - return response.data +): Promise> => { + if (window.CODE_SNIPPETS?.debug) { + console.debug(`${method} ${url}`, ...data ? [data] : []) + const response = await doRequest + console.debug('Response', response) + return response + } else { + return await doRequest + } } const buildRestAPI = (axiosInstance: AxiosInstance): RestAPI => ({ - get: (url: string): Promise => + getResponse: (url: string): Promise> => debugRequest('GET', url, axiosInstance.get, never>(url)), + get: (url: string): Promise => + debugRequest('GET', url, axiosInstance.get, never>(url)) + .then(response => response.data), + post: (url: string, data?: object): Promise => - debugRequest('POST', url, axiosInstance.post, typeof data>(url, data), data), + debugRequest('POST', url, axiosInstance.post>(url, data), data) + .then(response => response.data), del: (url: string): Promise => - debugRequest('DELETE', url, axiosInstance.delete, never>(url)), + debugRequest('DELETE', url, axiosInstance.delete, never>(url)) + .then(response => response.data), put: (url: string, data?: object): Promise => - debugRequest('PUT', url, axiosInstance.put, typeof data>(url, data), data) + debugRequest('PUT', url, axiosInstance.put>(url, data), data) + .then(response => response.data), }) -export const [RestAPIContext, useRestAPI] = createContextHook('RestAPI') +export const [RestAPIContext, useRestAPI] = createContextHook('useRestAPI') export const WithRestAPIContext: React.FC = ({ children }) => { const axiosInstance = useMemo(() => axios.create(REST_API_AXIOS_CONFIG), []) diff --git a/src/js/hooks/useSnippetForm.tsx b/src/js/hooks/useSnippetForm.tsx index 458b9f390..62c9ec57b 100644 --- a/src/js/hooks/useSnippetForm.tsx +++ b/src/js/hooks/useSnippetForm.tsx @@ -1,6 +1,6 @@ import { isAxiosError } from 'axios' import React, { useCallback, useMemo, useState } from 'react' -import { createContextHook } from '../utils/hooks' +import { createContextHook } from '../utils/bootstrap' import { isLicensed } from '../utils/screen' import { isProSnippet } from '../utils/snippets/snippets' import type { Dispatch, PropsWithChildren, SetStateAction } from 'react' @@ -22,7 +22,7 @@ export interface SnippetFormContext { setCodeEditorInstance: Dispatch> } -export const [SnippetFormContext, useSnippetForm] = createContextHook('SnippetForm') +export const [SnippetFormContext, useSnippetForm] = createContextHook('useSnippetForm') export interface WithSnippetFormContextProps extends PropsWithChildren { initialSnippet: () => Snippet diff --git a/src/js/hooks/useSnippetsFilters.tsx b/src/js/hooks/useSnippetsFilters.tsx new file mode 100644 index 000000000..c1c9fc602 --- /dev/null +++ b/src/js/hooks/useSnippetsFilters.tsx @@ -0,0 +1,86 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { SNIPPET_STATUSES, SNIPPET_TYPES } from '../types/Snippet' +import { createContextHook } from '../utils/bootstrap' +import { fetchQueryParam, updateQueryParam } from '../utils/urls' +import type { SnippetStatus, SnippetType } from '../types/Snippet' +import type { PropsWithChildren } from 'react' + +const isSnippetType = (type: unknown): type is SnippetType => + SNIPPET_TYPES.includes(type as SnippetType) + +const isSnippetStatus = (status: unknown): status is SnippetStatus => + SNIPPET_STATUSES.includes(status as SnippetStatus) + +const parseSearchQuery = (query?: string): [string | undefined, number | undefined] => { + const lineMatch = query?.trim().match(/@line:(?\d+)/) + const lineNumber = lineMatch?.groups?.line ? parseInt(lineMatch.groups.line, 10) : undefined + + return lineMatch && lineNumber + ? [query?.replace(lineMatch[0], '').trim(), lineNumber] + : [query, undefined] +} + +export interface SnippetsFiltersContext { + currentTag: string | undefined + currentType: SnippetType | undefined + searchQuery: string | undefined + currentStatus: SnippetStatus | undefined + setCurrentTag: (tag?: string) => void + setCurrentType: (type?: SnippetType) => void + setSearchQuery: (query?: string) => void + setCurrentStatus: (status?: SnippetStatus) => void + searchLineNumber?: number + searchQueryText?: string +} + +export const [SnippetsFiltersContext, useSnippetsFilters] = createContextHook('useSnippetsFilters') + +export const WithSnippetsTableFiltersContext: React.FC = ({ children }) => { + const [currentTag, setTag] = useState(() => fetchQueryParam('tag')) + const [searchQuery, setSearch] = useState(() => fetchQueryParam('s')) + + const [currentType, setCurrentType] = useState(() => { + const type = fetchQueryParam('type') + return isSnippetType(type) ? type : undefined + }) + + const [currentStatus, setCurrentStatus] = useState(() => { + const status = fetchQueryParam('status') + return isSnippetStatus(status) ? status : undefined + }) + + const setters = { + setCurrentType: useCallback((type?: SnippetType) => { + setCurrentType(type) + updateQueryParam('type', type) + }, [setCurrentType]), + setCurrentStatus: useCallback((status?: SnippetStatus) => { + setCurrentStatus(status) + updateQueryParam('status', status) + }, [setCurrentStatus]), + setCurrentTag: useCallback((tag?: string) => { + setTag(tag) + updateQueryParam('tag', tag) + }, [setTag]), + setSearchQuery: useCallback((query?: string) => { + setSearch(query) + updateQueryParam('s', query) + }, [setSearch]) + } + + const [searchQueryText, searchLineNumber] = useMemo( + () => parseSearchQuery(searchQuery), + [searchQuery]) + + const value: SnippetsFiltersContext = { + currentTag, + currentType, + searchQuery, + currentStatus, + searchQueryText, + searchLineNumber, + ...setters + } + + return {children} +} diff --git a/src/js/hooks/useSnippetsList.tsx b/src/js/hooks/useSnippetsList.tsx index 06325cc4d..3b18b5c48 100644 --- a/src/js/hooks/useSnippetsList.tsx +++ b/src/js/hooks/useSnippetsList.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react' -import { createContextHook } from '../utils/hooks' +import { createContextHook } from '../utils/bootstrap' import { isNetworkAdmin } from '../utils/screen' import { useRestAPI } from './useRestAPI' import type { PropsWithChildren } from 'react' @@ -10,7 +10,7 @@ export interface SnippetsListContext { refreshSnippetsList: () => Promise } -const [SnippetsListContext, useSnippetsList] = createContextHook('SnippetsList') +const [SnippetsListContext, useSnippetsList] = createContextHook('useSnippetsList') export const WithSnippetsListContext: React.FC = ({ children }) => { const { snippetsAPI: { fetchAll } } = useRestAPI() diff --git a/src/js/hooks/useSubmitSnippet.ts b/src/js/hooks/useSubmitSnippet.ts index 2dc3530a2..acb47399b 100644 --- a/src/js/hooks/useSubmitSnippet.ts +++ b/src/js/hooks/useSubmitSnippet.ts @@ -8,7 +8,7 @@ import { useSnippetForm } from './useSnippetForm' import type { Snippet } from '../types/Snippet' const snippetMessages = { - addNew: __('Add New Snippet', 'code-snippets'), + addNew: __('Create New Snippet', 'code-snippets'), edit: __('Edit Snippet', 'code-snippets'), created: __('Snippet created.', 'code-snippets'), updated: __('Snippet updated.', 'code-snippets'), @@ -24,7 +24,7 @@ const conditionCreated = __('Condition created.', 'code-snippet const conditionUpdated = __('Condition updated.', 'code-snippets') const conditionMessages: typeof snippetMessages = { - addNew: __('Add New Condition', 'code-snippets'), + addNew: __('Create New Condition', 'code-snippets'), edit: __('Edit Condition', 'code-snippets'), created: conditionCreated, updated: conditionUpdated, diff --git a/src/js/import.tsx b/src/js/import.tsx deleted file mode 100644 index b1c9b8247..000000000 --- a/src/js/import.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' -import { ImportApp } from './components/Import/ImportApp' - -const importContainer = document.getElementById('import-container') - -if (importContainer) { - const root = createRoot(importContainer) - root.render() -} else { - console.error('Could not find import container.') -} diff --git a/src/js/manage.ts b/src/js/manage.ts deleted file mode 100644 index 634cad9b6..000000000 --- a/src/js/manage.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetPriorityChanges } from './services/manage' - -handleSnippetActivationSwitches() -handleSnippetPriorityChanges() -handleShowCloudPreview() diff --git a/src/js/services/manage/activation.ts b/src/js/services/manage/activation.ts deleted file mode 100644 index 039edf1c9..000000000 --- a/src/js/services/manage/activation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { __ } from '@wordpress/i18n' -import { updateSnippet } from './requests' -import type { Snippet } from '../../types/Snippet' - -/** - * Update the snippet count of a specific view - * @param element - * @param increment - */ -const updateViewCount = (element: HTMLElement | null, increment: boolean) => { - if (element?.textContent) { - let count = parseInt(element.textContent.replace(/\((?\d+)\)/, '$1'), 10) - count += increment ? 1 : -1 - element.textContent = `(${count})` - } else { - console.error('Could not update view count.', element) - } -} - -/** - * Activate an inactive snippet, or deactivate an active snippet - * @param link - * @param event - */ -export const toggleSnippetActive = (link: HTMLAnchorElement, event: Event) => { - const row = link.parentElement?.parentElement // Switch < cell < row - if (!row) { - console.error('Could not toggle snippet active status.', row) - return - } - - const match = /\b(?:in)?active-snippet\b/.exec(row.className) - if (!match) { - return - } - - event.preventDefault() - - const activating = 'inactive-snippet' === match[0] - const snippet: Partial = { active: activating } - - updateSnippet('active', row, snippet, response => { - const button: HTMLAnchorElement | null = row.querySelector('.snippet-activation-switch') - - if (response.success) { - row.className = activating - ? row.className.replace(/\binactive-snippet\b/, 'active-snippet') - : row.className.replace(/\bactive-snippet\b/, 'inactive-snippet') - - const views = document.querySelector('.subsubsub') - const activeCount = views?.querySelector('.active .count') - const inactiveCount = views?.querySelector('.inactive .count') - - if (activeCount) { - updateViewCount(activeCount, activating) - } - - if (inactiveCount) { - updateViewCount(inactiveCount, activating) - } - - if (button) { - button.title = activating ? __('Deactivate', 'code-snippets') : __('Activate', 'code-snippets') - } - } else { - row.className += ' erroneous-snippet' - - if (button) { - button.title = __('An error occurred when attempting to activate', 'code-snippets') - } - } - }) -} - -export const handleSnippetActivationSwitches = () => { - for (const link of document.getElementsByClassName('snippet-activation-switch')) { - link.addEventListener('click', event => toggleSnippetActive( link, event)) - } -} diff --git a/src/js/services/manage/cloud.ts b/src/js/services/manage/cloud.ts deleted file mode 100644 index 6aa948051..000000000 --- a/src/js/services/manage/cloud.ts +++ /dev/null @@ -1,45 +0,0 @@ -import Prism from 'prismjs' -import 'prismjs/components/prism-clike' -import 'prismjs/components/prism-javascript' -import 'prismjs/components/prism-css' -import 'prismjs/components/prism-php' -import 'prismjs/components/prism-markup' -import 'prismjs/plugins/keep-markup/prism-keep-markup' - -/** - * Handle clicks on snippet preview button. - */ -export const handleShowCloudPreview = () => { - const previewButtons = document.querySelectorAll('.cloud-snippet-preview') - - previewButtons.forEach(button => { - button.addEventListener('click', () => { - const snippetId = button.getAttribute('data-snippet') - const snippetLanguage = button.getAttribute('data-lang') - - const snippetCodeInput = document.getElementById(`cloud-snippet-code-${snippetId}`) - const snippetCodeModalTag = document.getElementById('snippet-code-thickbox') - - if (!snippetCodeModalTag || !snippetCodeInput) { - return - } - - snippetCodeModalTag.classList.remove(...snippetCodeModalTag.classList) - snippetCodeModalTag.classList.add(`language-${snippetLanguage}`) - snippetCodeModalTag.textContent = snippetCodeInput.value - - if ('markup' === snippetLanguage) { - snippetCodeModalTag.innerHTML = `${snippetCodeInput.value}` - } - - if ('php' === snippetLanguage) { - // Check if there is an opening php tag if not add it. - if (!snippetCodeInput.value.startsWith(' { - const row = element.parentElement?.parentElement - const snippet: Partial = { priority: parseFloat(element.value) } - if (row) { - updateSnippet('priority', row, snippet) - } else { - console.error('Could not update snippet information.', snippet, row) - } -} - -export const handleSnippetPriorityChanges = () => { - for (const field of > document.getElementsByClassName('snippet-priority')) { - field.addEventListener('input', () => updateSnippetPriority(field)) - field.disabled = false - } -} diff --git a/src/js/services/manage/requests.ts b/src/js/services/manage/requests.ts deleted file mode 100644 index ab5147770..000000000 --- a/src/js/services/manage/requests.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { isNetworkAdmin } from '../../utils/screen' -import type { SnippetSchema } from '../../types/schema/SnippetSchema' -import type { Snippet, SnippetScope } from '../../types/Snippet' - -export interface ResponseData { - success: boolean - data?: T -} - -export type SuccessCallback = (response: ResponseData) => void - -const sendSnippetRequest = (query: string, onSuccess?: SuccessCallback) => { - const request = new XMLHttpRequest() - request.open('POST', window.ajaxurl, true) - request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8') - - request.onload = () => { - const success = 200 - const errorStart = 400 - if (success > request.status || errorStart <= request.status) { - return - } - - console.info(request.responseText) - onSuccess?.( JSON.parse(request.responseText)) - } - - request.send(query) -} - -/** - * Update the data of a given snippet using AJAX - * @param field - * @param row - * @param snippet - * @param successCallback - */ -export const updateSnippet = (field: keyof Snippet, row: Element, snippet: Partial, successCallback?: SuccessCallback) => { - const nonce = document.getElementById('code_snippets_ajax_nonce') - const columnId = row.querySelector('.column-id') - - if (!nonce || !columnId?.textContent || !parseInt(columnId.textContent, 10)) { - return - } - - const updatedSnippet: Partial = { - id: parseInt(columnId.textContent, 10), - shared_network: null !== /\bshared-network-snippet\b/.exec(row.className), - network: snippet.shared_network ?? isNetworkAdmin(), - scope: row.getAttribute('data-snippet-scope') ?? snippet.scope, - ...snippet - } - - const queryString = `action=update_code_snippet&_ajax_nonce=${nonce.value}&field=${field}&snippet=${JSON.stringify(updatedSnippet)}` - sendSnippetRequest(queryString, successCallback) -} diff --git a/src/js/services/settings/editor-preview.ts b/src/js/services/settings/editor-preview.ts index c2424f890..7e3ac9f5b 100644 --- a/src/js/services/settings/editor-preview.ts +++ b/src/js/services/settings/editor-preview.ts @@ -1,4 +1,4 @@ -import '../../editor' +import '../../entries/editor' const parseSelect = (select: HTMLSelectElement) => select.options[select.selectedIndex].value const parseCheckbox = (checkbox: HTMLInputElement) => checkbox.checked diff --git a/src/js/services/settings/tabs.ts b/src/js/services/settings/tabs.ts index 6301beeea..a743c95d4 100644 --- a/src/js/services/settings/tabs.ts +++ b/src/js/services/settings/tabs.ts @@ -16,7 +16,7 @@ const refreshEditorPreview = (section: string) => { // Update the http referer value so that any redirections lead back to this tab. const updateHttpReferer = (section: string) => { - const httpReferer = document.querySelector('input[name=_wp_http_referer]') + const httpReferer: HTMLInputElement | null = document.querySelector('input[name=_wp_http_referer]') if (!httpReferer) { console.error('could not find http referer') return diff --git a/src/js/settings.ts b/src/js/settings.ts deleted file mode 100644 index c89d8f578..000000000 --- a/src/js/settings.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { handleEditorPreviewUpdates, handleSettingsTabs, initVersionSwitch } from './services/settings' - -handleSettingsTabs() -handleEditorPreviewUpdates() -initVersionSwitch() diff --git a/src/js/types/KeyboardShortcut.ts b/src/js/types/KeyboardShortcut.ts index 58e647fbf..f94a54c6b 100644 --- a/src/js/types/KeyboardShortcut.ts +++ b/src/js/types/KeyboardShortcut.ts @@ -1,31 +1 @@ import { _x } from '@wordpress/i18n' - -export const KEYBOARD_KEYS = { - 'Cmd': _x('Cmd', 'keyboard key', 'code-snippets'), - 'Ctrl': _x('Ctrl', 'keyboard key', 'code-snippets'), - 'Shift': _x('Shift', 'keyboard key', 'code-snippets'), - 'Option': _x('Option', 'keyboard key', 'code-snippets'), - 'Alt': _x('Alt', 'keyboard key', 'code-snippets'), - 'Tab': _x('Tab', 'keyboard key', 'code-snippets'), - 'Up': _x('Up', 'keyboard key', 'code-snippets'), - 'Down': _x('Down', 'keyboard key', 'code-snippets'), - 'A': _x('A', 'keyboard key', 'code-snippets'), - 'D': _x('D', 'keyboard key', 'code-snippets'), - 'F': _x('F', 'keyboard key', 'code-snippets'), - 'G': _x('G', 'keyboard key', 'code-snippets'), - 'R': _x('R', 'keyboard key', 'code-snippets'), - 'S': _x('S', 'keyboard key', 'code-snippets'), - 'Y': _x('Y', 'keyboard key', 'code-snippets'), - 'Z': _x('Z', 'keyboard key', 'code-snippets'), - '/': _x('/', 'keyboard key', 'code-snippets'), - '[': _x(']', 'keyboard key', 'code-snippets'), - ']': _x(']', 'keyboard key', 'code-snippets') -} - -export type KeyboardKey = keyof typeof KEYBOARD_KEYS - -export interface KeyboardShortcut { - label: string - mod: KeyboardKey | KeyboardKey[] - key: KeyboardKey -} diff --git a/src/js/types/Snippet.ts b/src/js/types/Snippet.ts index dfdec93f0..d588d85bb 100644 --- a/src/js/types/Snippet.ts +++ b/src/js/types/Snippet.ts @@ -11,11 +11,17 @@ export interface Snippet { readonly shared_network?: boolean | null readonly modified?: string readonly conditionId: number + readonly lastActive?: number readonly code_error?: readonly [string, number] | null } +export const SNIPPET_TYPES = ['php', 'html', 'css', 'js', 'cond'] +export const SNIPPET_STATUSES = ['active', 'inactive', 'recently_activated'] + +export type SnippetType = typeof SNIPPET_TYPES[number] +export type SnippetStatus = typeof SNIPPET_STATUSES[number] + export type SnippetCodeType = 'php' | 'html' | 'css' | 'js' -export type SnippetType = SnippetCodeType | 'cond' export type SnippetCodeScope = typeof SNIPPET_TYPE_SCOPES[SnippetCodeType][number] export type SnippetScope = typeof SNIPPET_TYPE_SCOPES[SnippetType][number] diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index f32f2a18b..9542c7f6f 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -1,3 +1,4 @@ +import type { ChangelogSchema, ImageLinkSchema } from './schema/WelcomeSchema' import type Prism from 'prismjs' import type tinymce from 'tinymce' import type { Snippet } from './Snippet' @@ -18,11 +19,14 @@ declare global { readonly code_snippets_editor_settings: EditorOption[] CODE_SNIPPETS_PRISM?: typeof Prism readonly CODE_SNIPPETS?: { + debug: boolean isLicensed: boolean + hideUpsell: boolean restAPI: { base: string snippets: string conditions: string + cloudSearch: string cloud: string nonce: string localToken: string @@ -32,8 +36,27 @@ declare global { manage: string addNew: string edit: string + welcome: string + settings: string connectCloud: string } + banner: { + key: string + start_datetime: { date: string, timezone_type: number, timezone: string } + end_datetime: { date: string, timezone_type: number, timezone: string } + text_free: string + action_url_free: string + action_label_free: string + text_pro: string + action_url_pro: string + action_label_pro: string + } + } + readonly CODE_SNIPPETS_MANAGE?: { + snippetsList: Snippet[] + hasNetworkCap: boolean + snippetsPerPage: number + isSafeModeActive: boolean } readonly CODE_SNIPPETS_EDIT?: { snippet: Snippet @@ -43,7 +66,6 @@ declare global { enableDownloads: boolean activateByDefault: boolean enableDescription: boolean - hideUpsell: boolean editorTheme: string tagOptions: { enabled: boolean @@ -55,5 +77,15 @@ declare global { mediaButtons: boolean } } + readonly CODE_SNIPPETS_WELCOME?: { + hero: { + name: string + follow_url: string + image_url: string + } + changelog: ChangelogSchema[] + features: ImageLinkSchema[] + partners: ImageLinkSchema[] + } } } diff --git a/src/js/types/schema/CloudSnippetSchema.ts b/src/js/types/schema/CloudSnippetSchema.ts new file mode 100644 index 000000000..afd65faa9 --- /dev/null +++ b/src/js/types/schema/CloudSnippetSchema.ts @@ -0,0 +1,29 @@ +import type { SnippetScope } from '../Snippet' + +export interface CloudSnippetSchema { + id: number + cloud_id?: string + name: string + description: string + code: string + tags: string[] + scope: SnippetScope + status: CloudStatus + codevault: string + total_votes: number + vote_count: number + wp_tested: string + created: string + updated: string + revision: number + is_owner: boolean + shared_network: boolean +} + +export enum CloudStatus { + Private = 3, + Public = 4, + Unverified = 5, + AI_Verified = 6, + Pro_Verified = 8 +} diff --git a/src/js/types/schema/SnippetSchema.ts b/src/js/types/schema/SnippetSchema.ts index a765bf8e0..41ac2a834 100644 --- a/src/js/types/schema/SnippetSchema.ts +++ b/src/js/types/schema/SnippetSchema.ts @@ -16,5 +16,6 @@ export interface WritableSnippetSchema { export interface SnippetSchema extends Readonly> { readonly id: number readonly modified: string + readonly last_active?: number readonly code_error?: readonly [string, number] | null } diff --git a/src/js/types/schema/WelcomeSchema.ts b/src/js/types/schema/WelcomeSchema.ts new file mode 100644 index 000000000..c9f5cd094 --- /dev/null +++ b/src/js/types/schema/WelcomeSchema.ts @@ -0,0 +1,18 @@ +export interface ChangelogSchema { + version: string + date: string + entries: ChangelogEntriesSchema +} + +export type ChangelogEntriesSchema = Partial> + +export const CHANGELOG_SECTIONS = ['Added', 'Changed', 'Fixed', 'Deprecated', 'Removed', 'Security', 'Other'] +export type ChangelogSectionTitle = typeof CHANGELOG_SECTIONS[number] + +export interface ImageLinkSchema { + title: string + image_url: string + follow_url: string + description?: string + category?: string +} diff --git a/src/js/utils/Prism.ts b/src/js/utils/Prism.ts new file mode 100644 index 000000000..1acf0b307 --- /dev/null +++ b/src/js/utils/Prism.ts @@ -0,0 +1,17 @@ +import Prism from 'prismjs' +import 'prismjs/components/prism-markup' +import 'prismjs/components/prism-markup-templating' +import 'prismjs/components/prism-clike' +import 'prismjs/components/prism-css' +import 'prismjs/components/prism-php' +import 'prismjs/components/prism-javascript' +import 'prismjs/plugins/line-highlight/prism-line-highlight' +import 'prismjs/plugins/line-numbers/prism-line-numbers' +import 'prismjs/plugins/toolbar/prism-toolbar' +import 'prismjs/plugins/show-language/prism-show-language' +import 'prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard' +import 'prismjs/plugins/inline-color/prism-inline-color' +import 'prismjs/plugins/previewers/prism-previewers' +import 'prismjs/plugins/autolinker/prism-autolinker' + +export { Prism } diff --git a/src/js/utils/bootstrap.tsx b/src/js/utils/bootstrap.tsx new file mode 100644 index 000000000..29744ae54 --- /dev/null +++ b/src/js/utils/bootstrap.tsx @@ -0,0 +1,33 @@ +import React, { createContext, useContext } from 'react' +import { createRoot } from 'react-dom/client' +import type { Context, FunctionComponent } from 'react' + +export const loadComponent = (containerId: string, Component: FunctionComponent): void => { + const container = document.getElementById(containerId) + + if (container) { + const root = createRoot(container) + root.render() + } else { + console.error(`Could not find element #${containerId}.`) + } +} + +export const createContextHook = (hookName: string): [ + Context, + () => T +] => { + const contextValue = createContext(undefined) + + const useContextHook = (): T => { + const value = useContext(contextValue) + + if (value === undefined) { + throw Error(`${hookName} can only be used within a corresponding context provider.`) + } + + return value + } + + return [contextValue, useContextHook] +} diff --git a/src/js/utils/hooks.ts b/src/js/utils/hooks.ts deleted file mode 100644 index 3a52d8c20..000000000 --- a/src/js/utils/hooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext } from 'react' -import type { Context } from 'react' - -export const createContextHook = (name: string): [ - Context, - () => T -] => { - const contextValue = createContext(undefined) - - const useContextHook = (): T => { - const value = useContext(contextValue) - - if (value === undefined) { - throw Error(`use${name} can only be used within a ${name} context provider.`) - } - - return value - } - - return [contextValue, useContextHook] -} diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 1a6b14e8f..84e401eda 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -1,8 +1,10 @@ import { trimTrailingChar } from './text' import type { AxiosRequestConfig } from 'axios' +export const REST_API_NAMESPACE = 'code-snippets' export const REST_BASE = trimTrailingChar(window.CODE_SNIPPETS?.restAPI.base ?? '', '/') export const REST_SNIPPETS_BASE = trimTrailingChar(window.CODE_SNIPPETS?.restAPI.snippets ?? '', '/') +export const REST_CLOUD_SEARCH_BASE = trimTrailingChar(window.CODE_SNIPPETS?.restAPI.cloudSearch ?? '', '/') export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { headers: { diff --git a/src/js/utils/screen.ts b/src/js/utils/screen.ts index c073673ba..333fc393d 100644 --- a/src/js/utils/screen.ts +++ b/src/js/utils/screen.ts @@ -6,3 +6,6 @@ export const isMacOS = (): boolean => export const isLicensed = (): boolean => !!window.CODE_SNIPPETS?.isLicensed + +export const shouldShowUpsell = () => + !isLicensed() && !window.CODE_SNIPPETS?.hideUpsell diff --git a/src/js/utils/snippets/objects.ts b/src/js/utils/snippets/objects.ts index f542ab642..1bae32a25 100644 --- a/src/js/utils/snippets/objects.ts +++ b/src/js/utils/snippets/objects.ts @@ -46,6 +46,7 @@ export const parseSnippetObject = (fields: unknown): Snippet => { ...'network' in fields && 'boolean' === typeof fields.network && { network: fields.network }, ...'shared_network' in fields && 'boolean' === typeof fields.shared_network && { shared_network: fields.shared_network }, ...'priority' in fields && 'number' === typeof fields.priority && { priority: fields.priority }, - ...'condition_id' in fields && isAbsInt(fields.condition_id) && { conditionId: fields.condition_id } + ...'condition_id' in fields && isAbsInt(fields.condition_id) && { conditionId: fields.condition_id }, + ...'last_active' in fields && { lastActive: Number(fields.last_active) } } } diff --git a/src/js/utils/snippets/snippets.ts b/src/js/utils/snippets/snippets.ts index 46f1a893c..f3003da32 100644 --- a/src/js/utils/snippets/snippets.ts +++ b/src/js/utils/snippets/snippets.ts @@ -1,7 +1,16 @@ -import { __ } from '@wordpress/i18n' +import { __, sprintf } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' import { parseSnippetObject } from './objects' import type { Snippet, SnippetType } from '../../types/Snippet' +export const SNIPPET_TYPE_LABELS: Record = { + php: __('Functions', 'code-snippets'), + html: __('Content', 'code-snippets'), + css: __('Styles', 'code-snippets'), + js: __('Scripts', 'code-snippets'), + cond: __('Conditions', 'code-snippets') +} + const PRO_TYPES = new Set(['css', 'js', 'cond']) export const createSnippetObject = (fields: unknown): Snippet => @@ -26,6 +35,17 @@ export const getSnippetType = ({ scope }: Pick): SnippetType = } } +export const getSnippetEditUrl = (snippet?: Pick): string | undefined => + snippet?.id + ? addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: snippet.id }) + : window.CODE_SNIPPETS?.urls.addNew + +export const getSnippetDisplayName = (snippet: Pick): string => + '' === snippet.name.trim() + // translators: %s: snippet identifier. + ? sprintf(isCondition(snippet) ? __('Condition #%d', 'code-snippets') : __('Snippet #%d', 'code-snippets'), snippet.id) + : snippet.name + export const validateSnippet = (snippet: Snippet): undefined | string => { const missingTitle = '' === snippet.name.trim() const missingCode = '' === snippet.code.trim() diff --git a/src/js/utils/urls.ts b/src/js/utils/urls.ts new file mode 100644 index 000000000..5dcbd6322 --- /dev/null +++ b/src/js/utils/urls.ts @@ -0,0 +1,20 @@ +export const fetchQueryParam = (name: string): string | undefined => { + const urlParams = new URLSearchParams(window.location.search) + return urlParams.get(name) ?? undefined +} + +export const updateQueryParam = (name: string, value?: string | number) => { + if ('URLSearchParams' in window) { + const searchParams = new URLSearchParams(window.location.search) + + if (value) { + searchParams.set(name, String(value)) + } else { + searchParams.delete(name) + } + + const newUrl = window.location.toString().replace(window.location.search, `?${searchParams.toString()}`) + console.log(window.location.search, searchParams.toString(), newUrl) + window.history.replaceState({}, document.title, newUrl) + } +} diff --git a/src/php/class-admin.php b/src/php/Admin/Bootstrap_Admin.php similarity index 71% rename from src/php/class-admin.php rename to src/php/Admin/Bootstrap_Admin.php index dfdf093dd..f1c75784d 100644 --- a/src/php/class-admin.php +++ b/src/php/Admin/Bootstrap_Admin.php @@ -1,24 +1,35 @@ + * @var Admin_Menu[] */ - public array $menus = array(); + public array $menus = []; /** * Welcome_API class instance. @@ -31,10 +42,20 @@ class Admin { * Class constructor */ public function __construct() { - if ( is_admin() ) { - $this->welcome_api = new Welcome_API(); - $this->run(); + if ( ! is_admin() ) { + return; } + + $this->welcome_api = new Welcome_API(); + + add_action( 'init', array( $this, 'load_classes' ), 11 ); + + add_filter( 'mu_menu_items', array( $this, 'mu_menu_items' ) ); + add_filter( 'manage_sites_action_links', array( $this, 'add_sites_row_action' ), 10, 2 ); + add_filter( 'plugin_action_links_' . plugin_basename( PLUGIN_FILE ), array( $this, 'plugin_action_links' ), 10, 2 ); + add_filter( 'plugin_row_meta', array( $this, 'plugin_row_meta' ), 10, 2 ); + add_filter( 'debug_information', array( $this, 'debug_information' ) ); + add_action( 'code_snippets/admin/manage', array( $this, 'print_notices' ) ); } /** @@ -45,29 +66,11 @@ public function load_classes() { $this->menus['edit'] = new Edit_Menu(); $this->menus['import'] = new Import_Menu(); - if ( is_network_admin() === Settings\are_settings_unified() ) { + if ( is_network_admin() === are_settings_unified() ) { $this->menus['settings'] = new Settings_Menu(); } $this->menus['welcome'] = new Welcome_Menu( $this->welcome_api ); - - foreach ( $this->menus as $menu ) { - $menu->run(); - } - } - - /** - * Register action and filter hooks - */ - public function run() { - add_action( 'init', array( $this, 'load_classes' ), 11 ); - - add_filter( 'mu_menu_items', array( $this, 'mu_menu_items' ) ); - add_filter( 'manage_sites_action_links', array( $this, 'add_sites_row_action' ), 10, 2 ); - add_filter( 'plugin_action_links_' . plugin_basename( PLUGIN_FILE ), array( $this, 'plugin_action_links' ), 10, 2 ); - add_filter( 'plugin_row_meta', array( $this, 'plugin_row_meta' ), 10, 2 ); - add_filter( 'debug_information', array( $this, 'debug_information' ) ); - add_action( 'code_snippets/admin/manage', array( $this, 'print_notices' ) ); } /** @@ -263,6 +266,7 @@ public function debug_information( array $info ): array { * Print any admin notices that have not been dismissed. * * @return void + * @noinspection PhpRedundantOptionalArgumentInspection */ public function print_notices() { global $current_user; @@ -306,7 +310,7 @@ public function print_notices() { } printf( - '

', + '

'; } - - /** - * Render a badge for a snippet type in the nav tabs. - * - * @param string $type_name Identifier of the snippet type. - */ - private static function render_snippet_tab_badge( string $type_name ) { - if ( 'all' !== $type_name ) { - printf( '', esc_attr( $type_name ) ); - - switch ( $type_name ) { - case 'cloud': - echo ''; - break; - case 'cloud_search': - echo ''; - break; - case 'bundles': - echo ''; - break; - case 'ai': - echo '', esc_html__( 'AI', 'code-snippets' ), ''; - break; - case 'cond': - echo ''; - break; - default: - echo esc_html( $type_name ); - break; - } - - echo ''; - } - } - - /** - * Render a nav tab for a snippet type. - * - * @param array{string, string} $type_labels Associative array of snippet type identifiers and their labels. - * @param string $current_type Identifier of currently-selected type. - * - * @return void - */ - public static function render_snippet_type_tabs( array $type_labels, string $current_type = '' ) { - $is_licensed = code_snippets()->licensing->is_licensed(); - $pro_types = [ 'css', 'js', 'cond', 'cloud', 'bundles' ]; - $cloud_tabs = [ 'cloud', 'bundles' ]; - - foreach ( $type_labels as $type_name => $label ) { - if ( ! $is_licensed && in_array( $type_name, $pro_types, true ) ) { - continue; - } - - if ( $type_name === $current_type ) { - printf( '', esc_attr( $type_name ) ); - } else { - $current_url = remove_query_arg( [ 'cloud_select', 'cloud_search' ] ); - $nav_tab_inactive = in_array( $type_name, $cloud_tabs, true ) && ! code_snippets()->cloud_api->is_cloud_key_verified(); - - printf( - '', - $nav_tab_inactive ? 'nav-tab nav-tab-inactive' : 'nav-tab', - esc_attr( $type_name ), - esc_url( add_query_arg( 'type', $type_name, $current_url ) ) - ); - } - - printf( - '%s', - esc_attr( 'all' === $type_name ? 'all-snippets-label' : 'snippet-label' ), - esc_html( $label ) - ); - - self::render_snippet_tab_badge( $type_name ); - echo ''; - } - - foreach ( $type_labels as $type_name => $label ) { - if ( $is_licensed || ! in_array( $type_name, $pro_types, true ) ) { - continue; - } - - printf( - '%s', - esc_attr( $type_name ), - esc_url( 'https://codesnippets.pro/pricing/' ), - esc_attr__( 'Find more about Pro (opens in external tab)', 'code-snippets' ), - esc_html( $label ) - ); - - self::render_snippet_tab_badge( $type_name ); - echo ''; - } - } } diff --git a/src/php/class-contextual-help.php b/src/php/Admin/Contextual_Help.php similarity index 95% rename from src/php/class-contextual-help.php rename to src/php/Admin/Contextual_Help.php index 267d55ff3..deba2c6f9 100644 --- a/src/php/class-contextual-help.php +++ b/src/php/Admin/Contextual_Help.php @@ -1,8 +1,9 @@ __( 'Plugin Website', 'code-snippets' ), ]; - $kses = [ - 'p' => [], + $allowed_html = [ + 'p' => [], 'strong' => [], - 'a' => [ 'href' => [] ], + 'a' => [ 'href' => [] ], ]; $contents = sprintf( "

%s

\n", esc_html__( 'For more information:', 'code-snippets' ) ); @@ -84,7 +85,7 @@ private function load_help_sidebar() { $contents .= "\n" . sprintf( '

%s

', esc_url( $url ), esc_html( $label ) ); } - $this->screen->set_help_sidebar( wp_kses( $contents, $kses ) ); + $this->screen->set_help_sidebar( wp_kses( $contents, $allowed_html ) ); } /** @@ -143,7 +144,7 @@ private function load_manage_help() { __( 'Be sure to check your snippets for errors before you activate them, as a faulty snippet could bring your whole blog down. If your site starts doing strange things, deactivate all your snippets and activate them one at a time.', 'code-snippets' ), __( "If something goes wrong with a snippet, and you can't use WordPress, you can cause all snippets to stop executing by turning on safe mode.", 'code-snippets' ), /* translators: %s: URL to Code Snippets Pro Docs */ - sprintf( __( 'You can find out how to enable safe mode in the Code Snippets Pro Docs.', 'code-snippets' ), 'https://help.codesnippets.pro/article/12-safe-mode' ) + sprintf( __( 'You can find out how to enable safe mode in the Code Snippets Pro Docs.', 'code-snippets' ), 'https://help.codesnippets.pro/article/12-safe-mode' ), ] ); } diff --git a/src/php/admin-menus/class-admin-menu.php b/src/php/Admin/Menus/Admin_Menu.php similarity index 54% rename from src/php/admin-menus/class-admin-menu.php rename to src/php/Admin/Menus/Admin_Menu.php index 2e731eb0f..4d6ae4c1b 100644 --- a/src/php/admin-menus/class-admin-menu.php +++ b/src/php/Admin/Menus/Admin_Menu.php @@ -1,6 +1,8 @@ base_slug = code_snippets()->get_menu_slug(); $this->slug = code_snippets()->get_menu_slug( $name ); - } - /** - * Register action and filter hooks. - * - * @return void - */ - public function run() { if ( ! code_snippets()->is_compact_menu() ) { add_action( 'admin_menu', array( $this, 'register' ) ); add_action( 'network_admin_menu', array( $this, 'register' ) ); @@ -100,27 +119,16 @@ public function register() { } /** - * Render the content of a vew template - * - * @param string $name Name of view template to render. + * Render the navigation bar at the top of the admin page. */ - protected function render_view( string $name ) { - include dirname( PLUGIN_FILE ) . '/php/views/' . $name . '.php'; + protected function render_navigation() { + echo '
'; } /** * Render the menu */ - public function render() { - $this->render_view( $this->name ); - } - - /** - * Print the status and error messages - */ - protected function print_messages() { - // None required by default. - } + abstract public function render(); /** * Executed when the admin page is loaded @@ -146,62 +154,4 @@ public function load() { * Enqueue scripts and stylesheets for the admin page, if necessary */ abstract public function enqueue_assets(); - - /** - * Generate a list of page title links for passing to React. - * - * @param array $actions List of actions to convert into links, as array values. - * - * @return array Link labels keyed to link URLs. - */ - public function page_title_action_links( array $actions ): array { - $plugin = code_snippets(); - $links = []; - - foreach ( $actions as $action ) { - if ( 'settings' === $action && ! isset( $plugin->admin->menus['settings'] ) ) { - continue; - } - - $url = $plugin->get_menu_url( $action ); - - if ( isset( $_GET['type'] ) && in_array( $_GET['type'], Snippet::get_types(), true ) ) { - $url = add_query_arg( 'type', sanitize_key( wp_unslash( $_GET['type'] ) ), $url ); - } - - switch ( $action ) { - case 'manage': - $label = _x( 'Manage', 'snippets', 'code-snippets' ); - break; - case 'add': - $label = _x( 'Add New', 'snippet', 'code-snippets' ); - break; - case 'import': - $label = _x( 'Import', 'snippets', 'code-snippets' ); - break; - case 'settings': - $label = _x( 'Settings', 'snippets', 'code-snippets' ); - break; - default: - $label = ''; - } - - if ( $label && $url ) { - $links[ $label ] = $url; - } - } - - return $links; - } - - /** - * Render a list of links to other pages in the page title - * - * @param array $actions List of actions to render as links, as array values. - */ - public function render_page_title_actions( array $actions ) { - foreach ( $this->page_title_action_links( $actions ) as $label => $url ) { - printf( '%s', esc_url( $url ), esc_html( $label ) ); - } - } } diff --git a/src/php/admin-menus/class-edit-menu.php b/src/php/Admin/Menus/Edit_Menu.php similarity index 82% rename from src/php/admin-menus/class-edit-menu.php rename to src/php/Admin/Menus/Edit_Menu.php index 59962ccab..9ef35f3a3 100644 --- a/src/php/admin-menus/class-edit-menu.php +++ b/src/php/Admin/Menus/Edit_Menu.php @@ -1,8 +1,17 @@ remove_debug_bar_codemirror(); } @@ -63,7 +64,7 @@ public function register() { remove_submenu_page( $this->base_slug, $this->slug ); } - // Add New Snippet menu. + // Create New Snippet menu. $this->add_menu( code_snippets()->get_menu_slug( 'add' ), _x( 'Add New', 'menu label', 'code-snippets' ), @@ -97,10 +98,10 @@ protected function ensure_correct_page() { $edit_hook .= $screen->in_admin( 'network' ) ? '-network' : ''; // Disallow visiting the edit snippet page without a valid ID. - if ( - $screen->base === $edit_hook - && ( empty( $_REQUEST['id'] ) || 0 === $this->snippet->id || null === $this->snippet->id ) - && ! isset( $_REQUEST['preview'] ) + if ( + $screen->base === $edit_hook + && ( empty( $_REQUEST['id'] ) || 0 === $this->snippet->id || null === $this->snippet->id ) + && ! isset( $_REQUEST['preview'] ) ) { wp_safe_redirect( code_snippets()->get_menu_url( 'add' ) ); exit; @@ -113,10 +114,7 @@ protected function ensure_correct_page() { * @return void */ public function render() { - printf( - '
%s
', - esc_html__( 'Loading edit page…', 'code-snippets' ) - ); + echo '
'; } /** @@ -152,9 +150,8 @@ public function load_snippet_data() { * @return void */ public function enqueue_assets() { - $plugin = code_snippets(); - $settings = Settings\get_settings_values(); + $settings = get_settings_values(); $tags_enabled = $settings['general']['enable_tags']; $desc_enabled = $settings['general']['enable_description']; @@ -162,28 +159,20 @@ public function enqueue_assets() { wp_enqueue_style( self::CSS_HANDLE, - plugins_url( 'dist/edit.css', $plugin->file ), + plugins_url( 'dist/edit.css', PLUGIN_FILE ), [ 'code-editor', 'wp-components', ], - $plugin->version + PLUGIN_VERSION ); wp_enqueue_script( self::JS_HANDLE, - plugins_url( 'dist/edit.js', $plugin->file ), - [ - 'code-snippets-code-editor', - 'react', - 'react-dom', - 'wp-url', - 'wp-i18n', - 'wp-element', - 'wp-components', - ], - $plugin->version, - true + plugins_url( 'dist/edit.js', PLUGIN_FILE ), + [ 'code-snippets-code-editor' ] + self::$script_deps, + PLUGIN_VERSION, + [ 'in_footer' => true ] ); wp_set_script_translations( self::JS_HANDLE, 'code-snippets' ); @@ -193,20 +182,18 @@ public function enqueue_assets() { wp_enqueue_editor(); } - $plugin->localize_script( self::JS_HANDLE ); + code_snippets()->localize_script( self::JS_HANDLE ); wp_localize_script( self::JS_HANDLE, 'CODE_SNIPPETS_EDIT', [ 'snippet' => $this->snippet->get_fields(), - 'pageTitleActions' => $plugin->is_compact_menu() ? $this->page_title_action_links( [ 'manage', 'import', 'settings' ] ) : [], 'isPreview' => isset( $_REQUEST['preview'] ), 'activateByDefault' => get_setting( 'general', 'activate_by_default' ), 'editorTheme' => get_setting( 'editor', 'theme' ), 'enableDownloads' => apply_filters( 'code_snippets/enable_downloads', true ), 'enableDescription' => $desc_enabled, - 'hideUpsell' => get_setting( 'general', 'hide_upgrade_menu' ), 'tagOptions' => apply_filters( 'code_snippets/tag_editor_options', [ diff --git a/src/php/admin-menus/class-import-menu.php b/src/php/Admin/Menus/Import_Menu.php similarity index 64% rename from src/php/admin-menus/class-import-menu.php rename to src/php/Admin/Menus/Import_Menu.php index 0f6674ffb..d26988398 100644 --- a/src/php/admin-menus/class-import-menu.php +++ b/src/php/Admin/Menus/Import_Menu.php @@ -1,6 +1,11 @@ file ), + plugins_url( 'dist/import.js', PLUGIN_FILE ), [ 'react', 'react-dom', 'wp-i18n', 'wp-components', ], - $plugin->version, + PLUGIN_VERSION, true ); - $plugin->localize_script( 'code-snippets-import' ); + code_snippets()->localize_script( 'code-snippets-import' ); + } + + /** + * Render Import menu UI. + * + * @return void + */ + public function render() { + echo '
', '

'; + esc_html_e( 'Import Snippets', 'code-snippets' ); + + echo '

', '
', '
'; } } diff --git a/src/php/admin-menus/class-manage-menu.php b/src/php/Admin/Menus/Manage_Menu.php similarity index 51% rename from src/php/admin-menus/class-manage-menu.php rename to src/php/Admin/Menus/Manage_Menu.php index d342dde93..c3eb22664 100644 --- a/src/php/admin-menus/class-manage-menu.php +++ b/src/php/Admin/Menus/Manage_Menu.php @@ -1,9 +1,15 @@ is_compact_menu() ) { add_action( 'admin_menu', array( $this, 'register_compact_menu' ), 2 ); @@ -162,19 +157,22 @@ public function register_compact_menu() { } /** - * Executed when the admin page is loaded + * Executed when the admin page is loaded. */ public function load() { parent::load(); - $contextual_help = new Contextual_Help( 'manage' ); + $contextual_help = new Contextual_Help( 'edit' ); $contextual_help->load(); - $this->cloud_search_list_table = new Cloud_Search_List_Table(); - $this->cloud_search_list_table->prepare_items(); - - $this->list_table = new List_Table(); - $this->list_table->prepare_items(); + add_screen_option( + 'per_page', + array( + 'label' => __( 'Snippets per page', 'code-snippets' ), + 'default' => 999, + 'option' => 'snippets_per_page', + ) + ); } /** @@ -184,45 +182,64 @@ public function enqueue_assets() { $plugin = code_snippets(); wp_enqueue_style( - 'code-snippets-manage', - plugins_url( 'dist/manage.css', $plugin->file ), - [], - $plugin->version + self::CSS_HANDLE, + plugins_url( 'dist/manage.css', PLUGIN_FILE ), + self::$style_deps, + PLUGIN_VERSION ); wp_enqueue_script( - 'code-snippets-manage-js', - plugins_url( 'dist/manage.js', $plugin->file ), - [ 'wp-i18n' ], - $plugin->version, - true + self::JS_HANDLE, + plugins_url( 'dist/manage.js', PLUGIN_FILE ), + self::$script_deps, + PLUGIN_VERSION, + [ 'in_footer' => true ] ); - wp_set_script_translations( 'code-snippets-manage-js', 'code-snippets' ); - - if ( 'cloud' === $this->get_current_type() || 'cloud_search' === $this->get_current_type() ) { - Front_End::enqueue_all_prism_themes(); - } + Code_Highlighter::enqueue_all_prism_themes(); + + wp_set_script_translations( self::JS_HANDLE, 'code-snippets' ); + $plugin->localize_script( self::JS_HANDLE ); + + wp_localize_script( + self::JS_HANDLE, + 'CODE_SNIPPETS_MANAGE', + [ + 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ), + 'snippetsPerPage' => $this->get_snippets_per_page(), + 'isSafeModeActive' => code_snippets()->evaluate_functions->is_safe_mode_active(), + 'snippetsList' => array_map( + function ( $snippet ) { + return $snippet->get_fields(); + }, + get_snippets() + ), + ] + ); } /** - * Get the currently displayed snippet type. + * Get the number of snippets to show per page. * - * @return string + * @return int */ - protected function get_current_type(): string { - $types = Plugin::get_types(); - $current_type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all'; - return isset( $types[ $current_type ] ) ? $current_type : 'all'; + protected function get_snippets_per_page(): int { + $per_page = (int) get_user_option( 'snippets_per_page' ); + + if ( empty( $per_page ) || $per_page < 1 ) { + $per_page = 999; + } + + return (int) apply_filters( 'snippets_per_page', $per_page ); } /** - * Print the status and error messages + * Render the snippets table interface. * * @return void */ - protected function print_messages() { - $this->render_view( 'partials/list-table-notices' ); + public function render() { + echo '
'; } /** @@ -237,101 +254,4 @@ protected function print_messages() { public function save_screen_option( $status, string $option, $value ) { return 'snippets_per_page' === $option ? $value : $status; } - - /** - * Update the priority value for a snippet. - * - * @param Snippet $snippet Snippet to update. - * - * @return void - */ - private function update_snippet_priority( Snippet $snippet ) { - global $wpdb; - $table = code_snippets()->db->get_table_name( $snippet->network ); - - $wpdb->update( - $table, - array( 'priority' => $snippet->priority ), - array( 'id' => $snippet->id ), - array( '%d' ), - array( '%d' ) - ); - - clean_snippets_cache( $table ); - } - - /** - * Handle AJAX requests - */ - public function ajax_callback() { - check_ajax_referer( 'code_snippets_manage_ajax' ); - - if ( ! isset( $_POST['field'], $_POST['snippet'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'incomplete request', - ) - ); - } - - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $snippet_data = array_map( 'sanitize_text_field', json_decode( wp_unslash( $_POST['snippet'] ), true ) ); - - $snippet = new Snippet( $snippet_data ); - $field = sanitize_key( $_POST['field'] ); - - if ( 'priority' === $field ) { - - if ( ! isset( $snippet_data['priority'] ) || ! is_numeric( $snippet_data['priority'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'missing snippet priority data', - ) - ); - } - - $this->update_snippet_priority( $snippet ); - - } elseif ( 'active' === $field ) { - - if ( ! isset( $snippet_data['active'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'missing snippet active data', - ) - ); - } - - if ( $snippet->shared_network ) { - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - - if ( in_array( $snippet->id, $active_shared_snippets, true ) !== $snippet->active ) { - - $active_shared_snippets = $snippet->active ? - array_merge( $active_shared_snippets, array( $snippet->id ) ) : - array_diff( $active_shared_snippets, array( $snippet->id ) ); - - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - } elseif ( $snippet->active ) { - $result = activate_snippet( $snippet->id, $snippet->network ); - if ( is_string( $result ) ) { - wp_send_json_error( - array( - 'type' => 'action_error', - 'message' => $result, - ) - ); - } - } else { - deactivate_snippet( $snippet->id, $snippet->network ); - } - } - - wp_send_json_success(); - } } diff --git a/src/php/admin-menus/class-settings-menu.php b/src/php/Admin/Menus/Settings_Menu.php similarity index 63% rename from src/php/admin-menus/class-settings-menu.php rename to src/php/Admin/Menus/Settings_Menu.php index a5c72d377..57811a4f7 100644 --- a/src/php/admin-menus/class-settings-menu.php +++ b/src/php/Admin/Menus/Settings_Menu.php @@ -1,10 +1,17 @@ update_network_options(); } else { wp_safe_redirect( code_snippets()->get_menu_url( 'settings', 'admin' ) ); @@ -51,16 +57,95 @@ public function load() { * Enqueue the stylesheet for the settings menu */ public function enqueue_assets() { - $plugin = code_snippets(); - - Settings\enqueue_editor_preview_assets(); + $this->enqueue_codemirror(); + $handle = 'code-snippets-settings'; wp_enqueue_style( - 'code-snippets-settings', - plugins_url( 'dist/settings.css', $plugin->file ), - [ 'code-editor' ], - $plugin->version + $handle, + plugins_url( 'dist/settings.css', PLUGIN_FILE ), + self::$style_deps + [ 'code-editor' ], + PLUGIN_VERSION ); + + wp_enqueue_script( + $handle, + plugins_url( 'dist/settings.js', PLUGIN_FILE ), + self::$script_deps + [ 'code-snippets-code-editor' ], + PLUGIN_VERSION, + true + ); + + wp_set_script_translations( $handle, 'code-snippets' ); + code_snippets()->localize_script( $handle ); + + $this->add_codemirror_settings_script( $handle ); + } + + /** + * Enqueue the CodeMirror scripts and styles, including all themes. + * + * @return void + */ + protected function enqueue_codemirror() { + enqueue_code_editor( 'php' ); + $themes = get_editor_themes(); + + foreach ( $themes as $theme ) { + wp_enqueue_style( + 'code-snippets-editor-theme-' . $theme, + plugins_url( "dist/editor-themes/$theme.css", PLUGIN_FILE ), + [ 'code-editor' ], + PLUGIN_VERSION + ); + } + } + + /** + * Load the CodeMirror settings as an inline script variable. + * + * @param string $handle The handle of the script to which the settings will be added. + * + * @return void + */ + protected function add_codemirror_settings_script( string $handle ) { + $field_definitions = Settings_Fields::get_field_definitions(); + $editor_fields = []; + + foreach ( $field_definitions['editor'] as $name => $field ) { + if ( empty( $field['codemirror'] ) ) { + continue; + } + + $editor_fields[] = [ + 'name' => $name, + 'type' => $field['type'], + 'codemirror' => addslashes( $field['codemirror'] ), + ]; + } + + // Pass the saved options to the external JavaScript file. + $inline_script = 'var code_snippets_editor_settings = ' . wp_json_encode( $editor_fields ) . ';'; + + wp_add_inline_script( $handle, $inline_script, 'before' ); + + // Provide configuration and simple i18n for the version switch JS module. + $version_switch = [ + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'nonce_switch' => wp_create_nonce( 'code_snippets_version_switch' ), + 'nonce_refresh' => wp_create_nonce( 'code_snippets_refresh_versions' ), + ]; + + $strings = [ + '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' ); } /** @@ -100,6 +185,8 @@ public function get_current_section( string $default_section = 'general' ): stri * Render the admin screen */ public function render() { + $this->render_navigation(); + $update_url = is_network_admin() ? add_query_arg( 'update_site_option', true ) : admin_url( 'options.php' ); $current_section = $this->get_current_section(); @@ -227,13 +314,4 @@ public function update_network_options() { wp_safe_redirect( esc_url_raw( $redirect ) ); exit; } - - /** - * Empty implementation for print_messages. - * - * @return void - */ - protected function print_messages() { - // none required. - } } diff --git a/src/php/Admin/Menus/Welcome_Menu.php b/src/php/Admin/Menus/Welcome_Menu.php new file mode 100644 index 000000000..bbe539142 --- /dev/null +++ b/src/php/Admin/Menus/Welcome_Menu.php @@ -0,0 +1,86 @@ +api = $api; + } + + /** + * Load the welcome menu. + * + * @return void + */ + public function render() { + echo '
'; + } + + /** + * Enqueue assets necessary for the welcome menu. + * + * @return void + */ + public function enqueue_assets() { + $handle = 'code-snippets-welcome'; + + wp_enqueue_style( + $handle, + plugins_url( 'dist/welcome.css', PLUGIN_FILE ), + self::$style_deps, + PLUGIN_VERSION + ); + + wp_enqueue_script( + $handle, + plugins_url( 'dist/welcome.js', PLUGIN_FILE ), + self::$script_deps, + PLUGIN_VERSION, + true + ); + + code_snippets()->localize_script( $handle ); + + wp_localize_script( + $handle, + 'CODE_SNIPPETS_WELCOME', + [ + 'banner' => $this->api->get_banner(), + 'hero' => $this->api->get_hero_item(), + 'changelog' => $this->api->get_changelog(), + 'features' => $this->api->get_features(), + 'partners' => $this->api->get_partners(), + ] + ); + } +} diff --git a/src/php/cloud/class-cloud-api.php b/src/php/Client/Cloud_API.php similarity index 71% rename from src/php/cloud/class-cloud-api.php rename to src/php/Client/Cloud_API.php index 788ce8cae..c6698aa77 100644 --- a/src/php/cloud/class-cloud-api.php +++ b/src/php/Client/Cloud_API.php @@ -1,9 +1,12 @@ Static Function * - * @param string $search_method Search by name of codevault or keyword(s). - * @param string $search Search query. - * @param integer $page Search result page to retrieve. Defaults to '0'. + * @param string $search_method Search by name of codevault or keyword(s). + * @param string $search Search query. + * @param int $page Search result page to retrieve. Defaults to '0'. * * @return Cloud_Snippets Result of search query. */ @@ -257,24 +226,24 @@ public function add_cloud_link( Cloud_Link $link ) { * @return void */ public function delete_snippet_from_transient_data( int $snippet_id ) { - if ( ! $this->cached_cloud_links ) { - $this->get_cloud_links(); - } + $cloud_links = $this->get_cloud_links(); - foreach ( $this->cached_cloud_links as $link ) { + foreach ( $cloud_links as $link ) { if ( $link->local_id === $snippet_id ) { // Remove the link from the local_to_cloud_map. - $index = array_search( $link, $this->cached_cloud_links, true ); - unset( $this->cached_cloud_links[ $index ] ); - - // Update the transient data. - set_transient( - self::CLOUD_MAP_TRANSIENT_KEY, - $this->cached_cloud_links, - DAY_IN_SECONDS * self::DAYS_TO_STORE_CS - ); + $index = array_search( $link, $cloud_links, true ); + unset( $cloud_links[ $index ] ); } } + + // Update the transient data. + set_transient( + self::CLOUD_MAP_TRANSIENT_KEY, + $cloud_links, + DAY_IN_SECONDS * self::DAYS_TO_STORE_CS + ); + + $this->cached_cloud_links = $cloud_links; } /** @@ -403,117 +372,6 @@ public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): ar ]; } - /** - * Find the cloud link for a given cloud snippet identifier. - * - * @param int $cloud_id Cloud ID. - * - * @return Cloud_Link|null - */ - public function get_link_for_cloud_id( int $cloud_id ): ?Cloud_Link { - $cloud_links = $this->get_cloud_links(); - - if ( $cloud_links ) { - foreach ( $cloud_links as $cloud_link ) { - if ( $cloud_link->cloud_id === $cloud_id ) { - return $cloud_link; - } - } - } - - return null; - } - - - /** - * Find the cloud link for a given cloud snippet. - * - * @param Cloud_Snippet $cloud_snippet Cloud snippet. - * - * @return Cloud_Link|null - */ - public function get_link_for_cloud_snippet( Cloud_Snippet $cloud_snippet ): ?Cloud_Link { - return $this->get_link_for_cloud_id( $cloud_snippet->id ); - } - - /** - * Translate a snippet scope to a type. - * - * @param string $scope The scope of the snippet. - * - * @return string The type of the snippet. - */ - public static function get_type_from_scope( string $scope ): string { - switch ( $scope ) { - case 'global': - return 'php'; - case 'site-css': - return 'css'; - case 'site-footer-js': - return 'js'; - case 'content': - return 'html'; - default: - return ''; - } - } - - /** - * Get the label for a given cloud status. - * - * @param int $status Cloud status code. - * - * @return string The label for the status. - */ - public static function get_status_label( int $status ): string { - $labels = [ - self::STATUS_PRIVATE => __( 'Private', 'code-snippets' ), - self::STATUS_PUBLIC => __( 'Public', 'code-snippets' ), - self::STATUS_UNVERIFIED => __( 'Unverified', 'code-snippets' ), - self::STATUS_AI_VERIFIED => __( 'AI Verified', 'code-snippets' ), - self::STATUS_PRO_VERIFIED => __( 'Pro Verified', 'code-snippets' ), - ]; - - return $labels[ $status ] ?? __( 'Unknown', 'code-snippets' ); - } - - /** - * Get the badge class for a given cloud status. - * - * @param int $status Cloud status code. - * - * @return string - */ - public static function get_status_badge( int $status ): string { - $badge_names = [ - self::STATUS_PRIVATE => 'private', - self::STATUS_PUBLIC => 'public', - self::STATUS_UNVERIFIED => 'failure', - self::STATUS_AI_VERIFIED => 'success', - self::STATUS_PRO_VERIFIED => 'info', - ]; - - return $badge_names[ $status ] ?? 'neutral'; - } - - /** - * Renders the html for the preview thickbox popup. - * - * @return void - */ - public static function render_cloud_snippet_thickbox() { - add_thickbox(); - ?> - - exists( $changelog_dir . $changelog_filename ) ) { @@ -251,30 +253,52 @@ protected function build_changelog_data() { continue; } - $header_parts = explode( '(', $sections[0], 2 ); - $version = trim( trim( $header_parts[0] ), '[]' ); - - $changelog[ $version ] = []; + $entries = array_fill_keys( $section_titles, [] ); foreach ( array_slice( $sections, 1 ) as $section_contents ) { $lines = array_filter( array_map( 'trim', explode( "\n", $section_contents ) ) ); $section_type = $lines[0]; + if ( ! isset( $entries[ $section_type ] ) ) { + $section_type = 'Other'; + } + foreach ( array_slice( $lines, 1 ) as $line ) { $entry = trim( str_replace( '(PRO)', '', str_replace( '*', '', $line ) ) ); $core_or_pro = false === strpos( $line, '(PRO)' ) ? 'core' : 'pro'; - if ( ! isset( $changelog[ $version ][ $section_type ] ) ) { - $changelog[ $version ][ $section_type ] = [ - $core_or_pro => [ $entry ], - ]; - } elseif ( ! isset( $changelog[ $version ][ $section_type ][ $core_or_pro ] ) ) { - $changelog[ $version ][ $section_type ][ $core_or_pro ] = [ $entry ]; + $entry = str_replace( '`', '', $entry ); + $entry = preg_replace( '/\[(.+?)]\(.+?\)/', '$1', $entry ); + + if ( ! isset( $entries[ $section_type ][ $core_or_pro ] ) ) { + $entries[ $section_type ][ $core_or_pro ] = [ $entry ]; } else { - $changelog[ $version ][ $section_type ][ $core_or_pro ][] = $entry; + $entries[ $section_type ][ $core_or_pro ][] = $entry; } } } + + $header_parts = explode( '(', $sections[0], 2 ); + $version = trim( trim( $header_parts[0] ), '[]' ); + $date = trim( trim( $header_parts[1] ?? '' ), '()' ); + + try { + $datetime = new DateTimeImmutable( $date ); + $parsed_date = $datetime->format( get_option( 'date_format' ) ); + } catch ( Exception $e ) { + $parsed_date = $date; + } + + $changelog[] = [ + 'version' => $version, + 'date' => $parsed_date, + 'entries' => array_filter( + $entries, + function ( $section ) { + return ! empty( $section ); + } + ), + ]; } $this->welcome_data['changelog'] = $changelog; diff --git a/src/php/class-db.php b/src/php/Core/DB.php similarity index 93% rename from src/php/class-db.php rename to src/php/Core/DB.php index 4254dc516..0875d17b8 100644 --- a/src/php/class-db.php +++ b/src/php/Core/DB.php @@ -1,6 +1,8 @@ ms_table ) ) { - $this->create_table( $this->ms_table ); - } - - // Create the table if it doesn't exist. - if ( ! self::table_exists( $this->table ) ) { - $this->create_table( $this->table ); - } - } - /** * Create the snippet tables, or upgrade them if they already exist */ @@ -328,7 +314,7 @@ static function ( $a, $b ) use ( $comparisons ) { * * @param string $table_name Name of table to fetch snippets from. * @param array $scopes List of scopes to include in query. - * @param boolean $active_only Whether to only fetch active snippets from the table. + * @param bool $active_only Whether to only fetch active snippets from the table. * * @return array>|false List of active snippets, if any could be retrieved. * diff --git a/src/php/class-licensing.php b/src/php/Core/Licensing.php similarity index 90% rename from src/php/class-licensing.php rename to src/php/Core/Licensing.php index 97cf5d67f..80d71f00d 100644 --- a/src/php/class-licensing.php +++ b/src/php/Core/Licensing.php @@ -1,6 +1,6 @@ is_complete_uninstall_enabled() ) { + return; + } + + if ( is_multisite() ) { + $this->uninstall_multisite(); + } else { + $this->uninstall_current_site(); + } + + $this->delete_flat_files_directory(); + } + + /** + * Determine whether the option for allowing a complete uninstallation is enabled. + * + * @return bool + */ + public function is_complete_uninstall_enabled(): bool { + $unified = false; + + if ( is_multisite() ) { + $menu_perms = get_site_option( 'menu_items', array() ); + $unified = empty( $menu_perms['snippets_settings'] ); + } + + $settings = $unified ? get_site_option( 'code_snippets_settings' ) : get_option( 'code_snippets_settings' ); + + return isset( $settings['general']['complete_uninstall'] ) && $settings['general']['complete_uninstall']; + } + + /** + * Clean up data created by this plugin for a single site + * + * phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange + */ + private function uninstall_current_site() { + global $wpdb; + + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}snippets" ); + + delete_option( 'code_snippets_version' ); + delete_option( 'recently_activated_snippets' ); + delete_option( 'code_snippets_settings' ); + + delete_option( 'code_snippets_cloud_settings' ); + delete_transient( 'cs_codevault_snippets' ); + delete_transient( 'cs_local_to_cloud_map' ); + } + + /** + * Clean up data created by this plugin on multisite. + * + * phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange + */ + private function uninstall_multisite() { + global $wpdb; + + // Loop through sites. + $blog_ids = get_sites( [ 'fields' => 'ids' ] ); + + foreach ( $blog_ids as $site_id ) { + switch_to_blog( $site_id ); + $this->uninstall_current_site(); + } + + restore_current_blog(); + + // Remove network snippets table. + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}ms_snippets" ); + + // Remove saved options. + delete_site_option( 'code_snippets_version' ); + delete_site_option( 'recently_activated_snippets' ); + } + + /** + * Clean up directory used to store snippet flat files. + * + * @return void + */ + private 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 ); + } + } +} diff --git a/src/php/class-upgrade.php b/src/php/Core/Upgrader.php similarity index 94% rename from src/php/class-upgrade.php rename to src/php/Core/Upgrader.php index 53c494654..9dc294544 100644 --- a/src/php/class-upgrade.php +++ b/src/php/Core/Upgrader.php @@ -1,13 +1,18 @@ db = $db; $this->current_version = $version; + + add_action( 'plugins_loaded', [ $this, 'run' ], 0 ); } /** @@ -182,7 +189,7 @@ private function migrate_scope_data( string $table_name ) { /** * Build a collection of sample snippets for new users to try out. * - * @return array List of Snippet objects. + * @return Snippet[] List of Snippet objects. */ private function get_sample_content(): array { $tag = "\n\n" . esc_html__( 'This is a sample snippet. Feel free to use it, edit it, or remove it.', 'code-snippets' ); diff --git a/src/php/deactivation-notice.php b/src/php/Core/deactivation-notice.php similarity index 100% rename from src/php/deactivation-notice.php rename to src/php/Core/deactivation-notice.php diff --git a/src/php/load.php b/src/php/Core/load.php similarity index 92% rename from src/php/load.php rename to src/php/Core/load.php index de46e383c..24145d246 100644 --- a/src/php/load.php +++ b/src/php/Core/load.php @@ -44,7 +44,8 @@ const REST_API_NAMESPACE = 'code-snippets/v'; // Load dependencies with Composer. -$code_snippets_autoloader = require dirname( __DIR__ ) . '/vendor/autoload.php'; + +$code_snippets_autoloader = require dirname( __DIR__, 2 ) . '/vendor/autoload.php'; // Remove all original (non-prefixed) vendor namespace mappings to prevent collisions with other plugins. // Since Imposter rewrites namespaces to Code_Snippets\Vendor\*, we need to remove the original PSR-4 @@ -52,9 +53,9 @@ if ( $code_snippets_autoloader instanceof \Composer\Autoload\ClassLoader ) { $prefixes = $code_snippets_autoloader->getPrefixesPsr4(); $our_prefix = 'Code_Snippets\\Vendor\\'; - + foreach ( $prefixes as $namespace => $paths ) { - // Remove any non-Code_Snippets namespace that has a corresponding prefixed version + // Remove any non-Code_Snippets namespace that has a corresponding prefixed version. if ( strpos( $namespace, $our_prefix ) === false ) { $prefixed_namespace = $our_prefix . $namespace; if ( isset( $prefixes[ $prefixed_namespace ] ) ) { @@ -74,7 +75,7 @@ function code_snippets(): Plugin { static $plugin; if ( is_null( $plugin ) ) { - $plugin = new Plugin( PLUGIN_VERSION, PLUGIN_FILE ); + $plugin = new Plugin(); } return $plugin; diff --git a/src/php/Flat_Files/Flat_File_Config_Repository.php b/src/php/Flat_Files/Flat_File_Config_Repository.php new file mode 100644 index 000000000..9387ffa91 --- /dev/null +++ b/src/php/Flat_Files/Flat_File_Config_Repository.php @@ -0,0 +1,103 @@ +fs = $fs; + } + + /** + * Load configuration from a directory. + * + * @param string $base_dir Full filesystem path to directory. + * + * @return array Loaded configuration. + */ + 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 []; + } + + /** + * Store configuration. + * + * @param string $base_dir Full filesystem path to configuration directory. + * @param array $active_snippets List of active snippets. + * + * @return void + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export + */ + 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 = sprintf( + "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 ); + } + } + } + + /** + * Update stored configuration for a snippet. + * + * @param string $base_dir Full filesystem path to configuration directory. + * @param Snippet $snippet Snippet to update. + * @param bool|null $remove Whether to remove the snippet from the configuration. + * + * @return void + */ + 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/Handler_Registry.php b/src/php/Flat_Files/Handler_Registry.php new file mode 100644 index 000000000..8f25b75cf --- /dev/null +++ b/src/php/Flat_Files/Handler_Registry.php @@ -0,0 +1,54 @@ + $handler ) { + $this->register_handler( $type, $handler ); + } + } + + /** + * Registers a handler for a snippet type. + * + * @param string $type Handler key. + * @param Snippet_Type_Handler $handler Handler class. + * + * @return void + */ + public function register_handler( string $type, Snippet_Type_Handler $handler ): void { + $this->handlers[ $type ] = $handler; + } + + /** + * Gets the handler for a snippet type. + * + * @param string $type Handler key. + * + * @return Snippet_Type_Handler|null + */ + public function get_handler( string $type ): ?Snippet_Type_Handler { + return $this->handlers[ $type ] ?? null; + } +} diff --git a/src/php/Flat_Files/Handlers/Content_Snippet_Handler.php b/src/php/Flat_Files/Handlers/Content_Snippet_Handler.php new file mode 100644 index 000000000..fcd1dd16c --- /dev/null +++ b/src/php/Flat_Files/Handlers/Content_Snippet_Handler.php @@ -0,0 +1,40 @@ +\n\n" . $code; + } +} diff --git a/src/php/Flat_Files/Handlers/Functions_Snippet_Handler.php b/src/php/Flat_Files/Handlers/Functions_Snippet_Handler.php new file mode 100644 index 000000000..79c1674e4 --- /dev/null +++ b/src/php/Flat_Files/Handlers/Functions_Snippet_Handler.php @@ -0,0 +1,40 @@ +handler_registry = $handler_registry; $this->fs = $fs; @@ -60,12 +70,11 @@ public function __construct( * @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 ); + return file_exists( self::get_flag_file_path() ); } /** - * Get the full path to the flat-file enabled flag. + * Retrieve the full filesystem path to the flag file, used for determining if flat files are enabled. * * @return string */ @@ -97,6 +106,7 @@ private function handle_enabled_file_flag( bool $enabled ): void { * Register WordPress hooks used by file-based execution. * * @return void + * @noinspection PhpRedundantOptionalArgumentInspection */ public function register_hooks(): void { if ( ! $this->fs->is_writable( WP_CONTENT_DIR ) ) { @@ -121,14 +131,14 @@ public function register_hooks(): void { } /** - * Activate multiple snippets and regenerate their flat files. + * Set a number of snippets to active status. * * @param Snippet[] $valid_snippets Snippets to activate. - * @param string $table Table name. + * @param string $table Database table the snippets belong to. * * @return void */ - public function activate_snippets( $valid_snippets, $table ): void { + public function activate_snippets( array $valid_snippets, string $table ): void { foreach ( $valid_snippets as $snippet ) { $snippet->active = true; $this->handle_snippet( $snippet, $table ); @@ -138,24 +148,15 @@ public function activate_snippets( $valid_snippets, $table ): void { /** * Write a snippet file and update its config index entry. * - * @param Snippet $snippet Snippet object. - * @param string $table Table name. + * @param Snippet $snippet Snippet to write. + * @param string $table Snippet database table name. + * @param Snippet_Type_Handler $handler Snippet type handler. * * @return void */ - 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() ); + private function write_snippet( Snippet $snippet, string $table, Snippet_Type_Handler $handler ): void { + $hashed_table = self::get_hashed_table_name( $table ); + $base_dir = self::get_base_dir( $hashed_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() ); @@ -167,11 +168,31 @@ public function handle_snippet( Snippet $snippet, string $table ): void { $this->config_repo->update( $base_dir, $snippet ); } + /** + * Synchronise a snippet with the filesystem storage. + * + * @param Snippet $snippet Snippet to synchronise. + * @param string $table Database table snippet belongs to. + * + * @return void + */ + public function handle_snippet( Snippet $snippet, string $table ): void { + if ( 0 === $snippet->id ) { + return; + } + + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( $handler ) { + $this->write_snippet( $snippet, $table, $handler ); + } + } + /** * Delete a snippet file and remove it from the config index. * - * @param Snippet $snippet Snippet object. - * @param bool $network Whether the snippet is network-wide. + * @param Snippet $snippet Snippet to delete. + * @param bool $network Whether this is a network-level snippet. * * @return void */ @@ -202,22 +223,10 @@ 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; + if ( $handler ) { + $table = code_snippets()->db->get_table_name( $snippet->network ); + $this->write_snippet( $snippet, $table, $handler ); } - - $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 ); } /** @@ -243,12 +252,12 @@ public function deactivate_snippet( int $snippet_id, bool $network ): void { } /** - * Get the base directory for flat files. + * Determine the base directory for storing a snippet given its database table and type. * - * @param string $table Optional hashed table name. - * @param string $snippet_type Optional snippet type directory. + * @param string $table Database table name (can be empty). + * @param string $snippet_type Snippet type (can be empty). * - * @return string + * @return string Full filesystem path to base directory. */ public static function get_base_dir( string $table = '', string $snippet_type = '' ): string { $base_dir = WP_CONTENT_DIR . '/code-snippets'; @@ -287,7 +296,7 @@ public static function get_base_url( string $table = '', string $snippet_type = } /** - * Create a directory if it does not exist. + * Create a new directory if it does not already exist. * * @param string $dir Directory path. * @@ -304,11 +313,11 @@ private function maybe_create_directory( string $dir ): void { } /** - * Build the file path for a snippet's code file. + * Determine the file path for a snippet. * - * @param string $base_dir Base directory path. - * @param int $snippet_id Snippet ID. - * @param string $ext File extension. + * @param string $base_dir Base filesystem directory. + * @param int $snippet_id Snippet identifier. + * @param string $ext File extension, without the period. * * @return string */ @@ -317,9 +326,9 @@ private function get_snippet_file_path( string $base_dir, int $snippet_id, strin } /** - * Delete a file if it exists. + * Delete a file from the filesystem if it exists. * - * @param string $file_path File path. + * @param string $file_path Path of file to delete. * * @return void */ @@ -337,29 +346,26 @@ private function delete_file( string $file_path ): void { * @param mixed $value New value. * * @return void + * @noinspection PhpUnusedParameterInspection */ - public function sync_active_shared_network_snippets( $option, $old_value, $value ): void { - if ( 'active_shared_network_snippets' !== $option ) { - return; + public function sync_active_shared_network_snippets( string $option, $old_value, $value ): void { + if ( 'active_shared_network_snippets' === $option ) { + $this->create_active_shared_network_snippets_file( $value ); } - - $this->create_active_shared_network_snippets_file( $value ); } /** - * Sync the active shared network snippets list to a config file when first added. + * Handler for 'add_option' to ensure that the stored active network snippet statuses match that in the database. * - * @param string $option Option name. - * @param mixed $value Option value. + * @param string|mixed $option Name of option being added. + * @param mixed $value Initial value of option. * * @return void */ 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 ); } - - $this->create_active_shared_network_snippets_file( $value ); } /** @@ -368,26 +374,29 @@ public function sync_active_shared_network_snippets_add( $option, $value ): void * @param mixed $value Option value. * * @return void + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export */ 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'; - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- var_export is required for writing PHP config files. - $file_content = "fs->put_contents( $file_path, $file_content, FS_CHMOD_FILE ); } /** - * Hash a table name for file system usage. + * Hash a table name. * - * @param string $table Table name. + * @param string $table Table name to hash. * - * @return string + * @return string Hashed table name. */ public static function get_hashed_table_name( string $table ): string { return wp_hash( $table ); @@ -396,14 +405,14 @@ public static function get_hashed_table_name( string $table ): string { /** * Get a list of active snippets from flat file config. * - * @param array $scopes Scopes to include. + * @param array $scopes Scopes to include. * @param string $snippet_type Snippet type directory. * * @return array> */ public static function get_active_snippets_from_flat_files( array $scopes = [], - $snippet_type = 'php' + string $snippet_type = 'php' ): array { $active_snippets = []; $db = code_snippets()->db; @@ -477,14 +486,14 @@ public static function get_active_snippets_from_flat_files( return $active_snippets; } - /** - * Sort active snippet entries for execution order. - * - * @param array> $active_snippets Active snippets list. - * @param DB $db Database instance. - * - * @return void - */ + /** + * Sort list of active snippets for evaluation. + * + * @param array $active_snippets List of active snippet data. + * @param DB $db Database instance. + * + * @return void + */ private static function sort_active_snippets( array &$active_snippets, DB $db ): void { $comparisons = [ function ( array $a, array $b ) { @@ -563,9 +572,11 @@ private static function load_active_snippets_from_file( function ( $snippet ) use ( $scopes, $shared_ids ) { $active_value = isset( $snippet['active'] ) ? intval( $snippet['active'] ) : 0; + $is_active = DB::is_network_snippet_enabled( $active_value, intval( $snippet['id'] ), $shared_ids ); - return ( $is_active || 'condition' === $snippet['scope'] ) && in_array( $snippet['scope'], $scopes, true ); + return ( $is_active || 'condition' === $snippet['scope'] ) && + in_array( $snippet['scope'], $scopes, true ); } ); @@ -579,24 +590,27 @@ function ( $snippet ) use ( $scopes, $shared_ids ) { * * @param array $fields Settings fields. * - * @return array + * @return array Settings fields with flat file setting added. */ public function add_settings_fields( array $fields ): array { + + $learn_more_link = sprintf( + ' %s', + esc_url( 'https://codesnippets.pro/doc/file-based-execution/' ), + __( 'Learn more.', 'code-snippets' ) + ); + $fields['general']['enable_flat_files'] = [ - 'name' => __( 'Enable file-based execution', 'code-snippets' ), + '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' ) - ), + 'label' => __( 'Snippets will be executed directly from files instead of the database.', 'code-snippets' ) . $learn_more_link, ]; return $fields; } /** - * Recreate all flat files when file-based execution settings are updated. + * Create necessary flat files, if the option is enabled. * * @param array $settings Settings data. * @@ -635,8 +649,6 @@ private function create_snippet_flat_files(): void { } 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 ); @@ -663,9 +675,8 @@ private function create_snippet_flat_files(): void { */ 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; + $sites = get_sites( [ 'fields' => 'ids' ] ); foreach ( $sites as $site_id ) { switch_to_blog( $site_id ); @@ -682,7 +693,7 @@ private function create_active_shared_network_snippets_config_file(): void { $db->set_table_vars(); } else { $active_shared_network_snippets = get_option( 'active_shared_network_snippets' ); - if ( false !== $active_shared_network_snippets ) { + if ( f≈alse !== $active_shared_network_snippets ) { $this->create_active_shared_network_snippets_file( $active_shared_network_snippets ); } } diff --git a/src/php/Flat_Files/WP_Filesystem_Adapter.php b/src/php/Flat_Files/WP_Filesystem_Adapter.php new file mode 100644 index 000000000..837148236 --- /dev/null +++ b/src/php/Flat_Files/WP_Filesystem_Adapter.php @@ -0,0 +1,134 @@ +fs = $wp_filesystem; + } + + /** + * Writes a string to a file. + * + * @param string $path Remote path to the file where to write the data. + * @param string $contents The data to write. + * @param int|false $chmod Optional. The file permissions as octal number, usually 0644. Default false. + * + * @return bool True on success, false on failure. + */ + public function put_contents( string $path, string $contents, $chmod ): bool { + return $this->fs->put_contents( $path, $contents, $chmod ); + } + + /** + * Checks if a file or directory exists. + * + * @param string $path Path to file or directory. + * + * @return bool Whether $path exists or not. + */ + public function exists( string $path ): bool { + return $this->fs->exists( $path ); + } + + /** + * Deletes a file or directory. + * + * @param string $file Path to the file or directory. + * @param bool $recursive Optional. If set to true, deletes files and folders recursively. + * Default false. + * @param string|false $type Type of resource. 'f' for file, 'd' for directory. + * Default false. + * + * @return bool True on success, false on failure. + */ + public function delete( string $file, bool $recursive = false, $type = false ): bool { + return $this->fs->delete( $file, $recursive, $type ); + } + + /** + * Checks if resource is a directory. + * * + * + * @param string $path Directory path. + * + * @return bool Whether $path is a directory. + */ + public function is_dir( string $path ): bool { + return $this->fs->is_dir( $path ); + } + + /** + * Creates a directory. + * + * @param string $path Path for new directory. + * @param int|false $chmod Optional. The permissions as octal number (or false to skip chmod). + * Default false. + * + * @return bool True on success, false on failure. + */ + public function mkdir( string $path, $chmod ): bool { + return $this->fs->mkdir( $path, $chmod ); + } + + /** + * Deletes a directory. + * + * @param string $path Path to directory. + * @param bool $recursive Optional. Whether to recursively remove files/directories. + * Default false. + * + * @return bool True on success, false on failure. + */ + public function rmdir( string $path, bool $recursive = false ): bool { + return $this->fs->rmdir( $path, $recursive ); + } + + /** + * Changes filesystem permissions. + * + * @param string $path Path to the file. + * @param int|false $chmod Optional. The permissions as octal number, usually 0644 for files. + * + * @return bool True on success, false on failure. + */ + public function chmod( string $path, $chmod ): bool { + return $this->fs->chmod( $path, $chmod ); + } + + /** + * Checks if a file or directory is writable. + * + * @param string $path Path to file or directory. + * + * @return bool Whether $path is writable. + */ + public function is_writable( string $path ): bool { + return $this->fs->is_writable( $path ); + } +} diff --git a/src/php/Integration/Classic_Editor/MCE_Plugin.php b/src/php/Integration/Classic_Editor/MCE_Plugin.php new file mode 100644 index 000000000..e81b87dfe --- /dev/null +++ b/src/php/Integration/Classic_Editor/MCE_Plugin.php @@ -0,0 +1,57 @@ +current_user_can() ) { + return; + } + + /* Register the TinyMCE plugin */ + add_filter( + 'mce_external_plugins', + function ( $plugins ) { + $plugins['code_snippets'] = plugins_url( 'dist/mce.js', PLUGIN_FILE ); + return $plugins; + } + ); + + /* Add the button to the editor toolbar */ + add_filter( + 'mce_buttons', + function ( $buttons ) { + $buttons[] = 'code_snippets'; + return $buttons; + } + ); + + /* Add the translation strings to the TinyMCE editor */ + add_filter( + 'mce_external_languages', + function ( $languages ) { + $languages['code_snippets'] = __DIR__ . '/mce-strings.php'; + return $languages; + } + ); + } +} diff --git a/src/php/front-end/mce-strings.php b/src/php/Integration/Classic_Editor/mce-strings.php similarity index 93% rename from src/php/front-end/mce-strings.php rename to src/php/Integration/Classic_Editor/mce-strings.php index 58ceef03c..0eb50c379 100644 --- a/src/php/front-end/mce-strings.php +++ b/src/php/Integration/Classic_Editor/mce-strings.php @@ -5,9 +5,11 @@ * @package Code_Snippets */ -namespace Code_Snippets; +namespace Code_Snippets\Integration\Classic_Editor; use _WP_Editors; +use Code_Snippets\Model\Snippet; +use function Code_Snippets\get_snippets; /** * Variable types. diff --git a/src/php/evaluation/class-evaluate-content.php b/src/php/Integration/Evaluate_Content.php similarity index 95% rename from src/php/evaluation/class-evaluate-content.php rename to src/php/Integration/Evaluate_Content.php index da7a0779f..124c2869e 100644 --- a/src/php/evaluation/class-evaluate-content.php +++ b/src/php/Integration/Evaluate_Content.php @@ -1,11 +1,10 @@ db = $db; add_action( 'plugins_loaded', [ $this, 'evaluate_early' ], 1 ); + add_filter( 'code_snippets/execute_snippets', [ $this, 'disable_snippet_execution' ], 5 ); + + if ( isset( $_REQUEST['snippets-safe-mode'] ) ) { + add_filter( 'home_url', [ $this, 'add_safe_mode_query_var' ] ); + add_filter( 'admin_url', [ $this, 'add_safe_mode_query_var' ] ); + } + } + + /** + * Inject the safe mode query var into URLs + * + * @param string $url Original URL. + * + * @return string Modified URL. + */ + public function add_safe_mode_query_var( string $url ): string { + return isset( $_REQUEST['snippets-safe-mode'] ) ? + add_query_arg( 'snippets-safe-mode', (bool) $_REQUEST['snippets-safe-mode'], $url ) : + $url; } /** @@ -78,6 +96,17 @@ public function is_safe_mode_active(): bool { ! apply_filters( 'code_snippets/execute_snippets', true ); } + /** + * Disable snippet execution if the necessary query var is set. + * + * @param bool $execute_snippets Current filter value. + * + * @return bool New filter value. + */ + public function disable_snippet_execution( bool $execute_snippets ): bool { + return ! empty( $_REQUEST['snippets-safe-mode'] ) && code_snippets()->current_user_can() ? false : $execute_snippets; + } + /** * Quickly deactivate a snippet with minimal overhead. * diff --git a/src/php/promotions/elementor-pro.php b/src/php/Integration/Promotions/Elementor_Pro.php similarity index 88% rename from src/php/promotions/elementor-pro.php rename to src/php/Integration/Promotions/Elementor_Pro.php index 8a01396be..711456d06 100644 --- a/src/php/promotions/elementor-pro.php +++ b/src/php/Integration/Promotions/Elementor_Pro.php @@ -1,9 +1,6 @@

- + - +

@@ -91,11 +91,11 @@ public function add_promotion_to_custom_css_section( $element ) { $element->add_control( 'code_snippets_promotion_notice', [ - 'type' => \Elementor\Controls_Manager::NOTICE, + 'type' => \Elementor\Controls_Manager::NOTICE, 'notice_type' => 'info', 'dismissible' => true, - 'heading' => esc_html__( 'Manage your custom styles', 'code-snippets' ), - 'content' => $this->get_promotion_content(), + 'heading' => esc_html__( 'Manage your custom styles', 'code-snippets' ), + 'content' => $this->get_promotion_content(), ] ); } diff --git a/src/php/front-end/class-front-end.php b/src/php/Integration/Shortcodes.php similarity index 76% rename from src/php/front-end/class-front-end.php rename to src/php/Integration/Shortcodes.php index cc33a3b44..828254777 100644 --- a/src/php/front-end/class-front-end.php +++ b/src/php/Integration/Shortcodes.php @@ -1,17 +1,22 @@ WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_snippets_info' ], - 'permission_callback' => function () { - return current_user_can( 'edit_posts' ); - }, - ) - ); - } - - /** - * Fetch snippets data in response to a request. - * - * @return WP_REST_Response - */ - public function get_snippets_info(): WP_REST_Response { - $snippets = get_snippets(); - $data = []; - - foreach ( $snippets as $snippet ) { - $data[] = [ - 'id' => $snippet->id, - 'name' => $snippet->name, - 'type' => $snippet->type, - 'active' => $snippet->active, - ]; - } - - return new WP_REST_Response( $data, 200 ); - } - - /** - * Perform the necessary actions to add a button to the TinyMCE editor - */ - public function setup_mce_plugin() { - if ( ! code_snippets()->current_user_can() ) { - return; - } - - /* Register the TinyMCE plugin */ - add_filter( - 'mce_external_plugins', - function ( $plugins ) { - $plugins['code_snippets'] = plugins_url( 'dist/mce.js', PLUGIN_FILE ); - return $plugins; - } - ); - - /* Add the button to the editor toolbar */ - add_filter( - 'mce_buttons', - function ( $buttons ) { - $buttons[] = 'code_snippets'; - return $buttons; - } - ); - - /* Add the translation strings to the TinyMCE editor */ - add_filter( - 'mce_external_languages', - function ( $languages ) { - $languages['code_snippets'] = __DIR__ . '/mce-strings.php'; - return $languages; - } - ); - } - /** * Enqueue the syntax highlighting assets if they are required for the current posts * @@ -125,9 +48,7 @@ function ( $languages ) { * @return array|null|false Unchanged list of posts. */ public function enqueue_highlighting( $posts ) { - - // Exit early if there are no posts to check or if the highlighter has been disabled. - if ( empty( $posts ) || Settings\get_setting( 'general', 'disable_prism' ) ) { + if ( empty( $posts ) || get_setting( 'general', 'disable_prism' ) ) { return $posts; } @@ -144,13 +65,13 @@ public function enqueue_highlighting( $posts ) { // Load assets on the appropriate hook if a matching shortcode was found. if ( null !== $found_shortcode_content ) { - $this->register_prism_assets(); + Code_Highlighter::register_prism_assets(); add_action( 'wp_enqueue_scripts', function () { - wp_enqueue_style( self::PRISM_HANDLE ); - wp_enqueue_script( self::PRISM_HANDLE ); + wp_enqueue_style( Code_Highlighter::PRISM_HANDLE ); + wp_enqueue_script( Code_Highlighter::PRISM_HANDLE ); }, 100 ); @@ -159,46 +80,10 @@ function () { return $posts; } - /** - * Enqueue the styles and scripts for the Prism syntax highlighter. - * - * @return void - */ - public static function register_prism_assets() { - $plugin = code_snippets(); - - wp_register_script( - self::PRISM_HANDLE, - plugins_url( 'dist/prism.js', $plugin->file ), - array(), - $plugin->version, - true - ); - - wp_register_style( - self::PRISM_HANDLE, - plugins_url( 'dist/prism.css', $plugin->file ), - array(), - $plugin->version - ); - } - - /** - * Enqueue all available Prism themes. - * - * @return void - */ - public static function enqueue_all_prism_themes() { - self::register_prism_assets(); - - wp_enqueue_style( self::PRISM_HANDLE ); - wp_enqueue_script( self::PRISM_HANDLE ); - } - /** * Print a message to the user if the snippet ID attribute is invalid. * - * @param integer $id Snippet ID. + * @param int $id Snippet ID. * * @return string Warning message. */ @@ -230,8 +115,8 @@ protected function convert_boolean_attribute_flags( array $atts, array $boolean_ /** * Build the file path for a snippet's flat file. * - * @param string $table_name Table name for the snippet. - * @param Snippet $snippet Snippet object. + * @param string $table_name Table name for the snippet. + * @param Snippet $snippet Snippet object. * * @return string Full file path for the snippet. */ @@ -267,6 +152,16 @@ protected function evaluate_shortcode_content( Snippet $snippet, array $atts ): : $this->evaluate_shortcode_from_db( $snippet, $atts ); } + /** + * Evaluate a snippet by evaluating its code as a string. + * + * @param Snippet $snippet Snippet to execute. + * @param array $atts Shortcode attributes. + * + * @return string Snippet output. + * + * phpcs:disable Squiz.PHP.Eval.Discouraged + */ 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 @@ -282,10 +177,18 @@ private function evaluate_shortcode_from_db( Snippet $snippet, array $atts ): st return ob_get_clean(); } - private function evaluate_shortcode_from_flat_file( $filepath, array $atts ): string { + /** + * Evaluate a snippet by loading its code from the filesystem. + * + * @param string $filepath Path to file to evaluate. + * @param array $atts Shortcode attributes. + * + * @return string Snippet output. + */ + private function evaluate_shortcode_from_flat_file( string $filepath, array $atts ): string { ob_start(); - ( function( $atts ) use ( $filepath ) { + ( 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. @@ -299,20 +202,28 @@ private function evaluate_shortcode_from_flat_file( $filepath, array $atts ): st return ob_get_clean(); } - private function get_snippet( int $id, bool $network, string $snippet_type ): Snippet { + /** + * Retrieve a content snippet from the filesystem or the database. + * + * @param int $id Snippet identifier. + * @param bool $network Whether the snippet is network-wide. + * + * @return Snippet + */ + private function get_content_snippet( int $id, bool $network ): 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 ); + $handler = code_snippets()->snippet_handler_registry->get_handler( 'html' ); $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 ); @@ -352,7 +263,7 @@ public function render_content_shortcode( array $atts ): string { return $this->invalid_id_warning( $id ); } - $snippet = $this->get_snippet( $id, (bool) $atts['network'], 'html' ); + $snippet = $this->get_content_snippet( $id, (bool) $atts['network'] ); // Render the source code if this is not a shortcode snippet. if ( 'content' !== $snippet->scope ) { @@ -494,8 +405,7 @@ public function render_source_shortcode( array $atts ): string { return $this->invalid_id_warning( $id ); } - $snippet = $this->get_snippet( $id, (bool) $atts['network'], 'html' ); - + $snippet = $this->get_content_snippet( $id, (bool) $atts['network'] ); return $this->render_snippet_source( $snippet, $atts ); } } diff --git a/src/php/cloud/class-cloud-link.php b/src/php/Model/Cloud_Link.php similarity index 61% rename from src/php/cloud/class-cloud-link.php rename to src/php/Model/Cloud_Link.php index 21671325c..521061f3a 100644 --- a/src/php/cloud/class-cloud-link.php +++ b/src/php/Model/Cloud_Link.php @@ -1,21 +1,19 @@ $initial_data Initial data. + * @param Cloud_Snippet $initial_data Initial data. */ public function __construct( $initial_data = null ) { $initial_data = $this->normalize_cloud_api( $initial_data ); @@ -70,6 +68,7 @@ protected function prepare_field( $value, string $field ) { * @param mixed $snippets The field as provided. * * @return Cloud_Snippets[] The field in the correct format. + * @noinspection PhpUnused */ protected function prepare_snippets( $snippets ): array { $result = []; diff --git a/src/php/class-data-item.php b/src/php/Model/Model.php similarity index 78% rename from src/php/class-data-item.php rename to src/php/Model/Model.php index 85dc46b6f..88139d28c 100644 --- a/src/php/class-data-item.php +++ b/src/php/Model/Model.php @@ -1,6 +1,6 @@ $default_values List of valid fields mapped to their default values. - * @param array|Data_Item $initial_data Optional initial data to populate fields. - * @param array $field_aliases Optional list of field name aliases to map when resolving a field name. + * @param array $default_values List of valid fields mapped to their default values. + * @param array|Model $initial_data Optional initial data to populate fields. + * @param array $field_aliases Optional list of field name aliases to map when resolving a field name. */ public function __construct( array $default_values, $initial_data = null, array $field_aliases = [] ) { $this->fields = $default_values; @@ -159,6 +157,20 @@ public function __get( string $field ) { return $this->fields[ $field ]; } + /** + * Set the value of a field without any validation. + * + * @param string $resolved_field The resolved field name. + * @param mixed $value The field value. + */ + private function set_value_internal( string $resolved_field, $value ) { + $value = method_exists( $this, 'prepare_' . $resolved_field ) ? + call_user_func( array( $this, 'prepare_' . $resolved_field ), $value ) : + $this->prepare_field( $value, $resolved_field ); + + $this->fields[ $resolved_field ] = $value; + } + /** * Set the value of a field. * @@ -168,23 +180,15 @@ public function __get( string $field ) { * @throws WP_Exception If the field name is not allowed. */ public function __set( string $field, $value ) { - $field = $this->resolve_field_name( $field ); - - if ( ! $this->is_allowed_field( $field ) ) { - if ( function_exists( 'wp_trigger_error' ) ) { - // translators: 1: class name, 2: field name. - $message = sprintf( 'Trying to set invalid property on "%s" class: %s', get_class( $this ), $field ); - wp_trigger_error( __FUNCTION__, $message, E_USER_ERROR ); - } - - return; + $resolved_field = $this->resolve_field_name( $field ); + + if ( $this->is_allowed_field( $resolved_field ) ) { + $this->set_value_internal( $resolved_field, $value ); + } elseif ( function_exists( 'wp_trigger_error' ) ) { + // translators: 1: class name, 2: field name. + $message = sprintf( 'Trying to set invalid property on "%s" class: %s', get_class( $this ), $field ); + wp_trigger_error( __FUNCTION__, $message, E_USER_ERROR ); } - - $value = method_exists( $this, 'prepare_' . $field ) ? - call_user_func( array( $this, 'prepare_' . $field ), $value ) : - $this->prepare_field( $value, $field ); - - $this->fields[ $field ] = $value; } /** @@ -226,21 +230,15 @@ public function is_allowed_field( string $field ): bool { * @param mixed $value The field value. * * @return bool true if the field was set successfully, false if the field name is invalid. - * - * @noinspection PhpDocMissingThrowsInspection */ public function set_field( string $field, $value ): bool { - if ( ! $this->is_allowed_field( $field ) ) { + $resolved_field = $this->resolve_field_name( $field ); + + if ( ! $this->is_allowed_field( $resolved_field ) ) { return false; } - /** - * Above is_allowed_field check should bypass exception. - * - * @noinspection PhpUnhandledExceptionInspection - */ - $this->__set( $field, $value ); - + $this->set_value_internal( $resolved_field, $value ); return true; } } diff --git a/src/php/class-snippet.php b/src/php/Model/Snippet.php similarity index 91% rename from src/php/class-snippet.php rename to src/php/Model/Snippet.php index dc3c6a952..2fbba7143 100644 --- a/src/php/class-snippet.php +++ b/src/php/Model/Snippet.php @@ -1,10 +1,12 @@ raw_active_value = $initial_data->active; } - $default_values = array( + $default_values = [ 'id' => 0, 'name' => '', 'desc' => '', 'code' => '', - 'tags' => array(), + 'tags' => [], 'scope' => 'global', 'condition_id' => 0, 'active' => false, @@ -85,13 +88,13 @@ public function __construct( $initial_data = null ) { 'code_error' => null, 'revision' => 1, 'cloud_id' => '', - ); + ]; - $field_aliases = array( + $field_aliases = [ 'description' => 'desc', 'language' => 'lang', 'conditionId' => 'condition_id', - ); + ]; parent::__construct( $default_values, $initial_data, $field_aliases ); } @@ -142,7 +145,7 @@ protected function prepare_field( $value, string $field ) { return code_snippets_build_tags_array( $value ); case 'active': - return ( is_bool( $value ) ? $value : (bool) $value ) && ! $this->is_condition() && (int) $value != -1; + return ( is_bool( $value ) ? $value : (bool) $value ) && ! $this->is_condition() && -1 !== (int) $value; default: return $value; @@ -155,6 +158,7 @@ protected function prepare_field( $value, string $field ) { * @param int|string $scope The field as provided. * * @return string The field in the correct format. + * @noinspection PhpUnused */ protected function prepare_scope( $scope ) { $scopes = self::get_all_scopes(); @@ -176,6 +180,7 @@ protected function prepare_scope( $scope ) { * @param bool $network The field as provided. * * @return bool The field in the correct format. + * @noinspection PhpUnused */ protected function prepare_network( bool $network ): bool { if ( null === $network && function_exists( 'is_network_admin' ) ) { @@ -224,10 +229,22 @@ public static function get_types(): array { return [ 'php', 'html', 'css', 'js', 'cond' ]; } + /** + * Retrieve the timestamp of when the snippet was last active, if available. + * + * @return string + * @noinspection PhpUnused + */ + protected function get_last_active(): string { + $recently_active = get_self_option( $this->network, 'recently_activated_snippets', [] ); + return $recently_active[ (string) $this->id ] ?? 0; + } + /** * Determine the language that the snippet code is written in, based on the scope * * @return string The name of a language filename extension. + * @noinspection PhpUnused */ protected function get_lang(): string { return 'cond' === $this->type ? 'json' : $this->type; @@ -239,6 +256,7 @@ protected function get_lang(): string { * @param DateTime|string $modified Snippet modification date. * * @return string + * @noinspection PhpUnused */ protected function prepare_modified( $modified ): ?string { @@ -274,6 +292,7 @@ public function update_modified() { * Retrieve the snippet title if set or a placeholder title if not. * * @return string + * @noinspection PhpUnused */ protected function get_display_name(): string { // translators: %s: snippet identifier. @@ -284,6 +303,7 @@ protected function get_display_name(): string { * Retrieve the tags in list format * * @return string The tags separated by a comma and a space. + * @noinspection PhpUnused */ protected function get_tags_list(): string { return implode( ', ', $this->tags ); @@ -332,6 +352,7 @@ public static function get_scope_icons(): array { * Retrieve the string representation of the scope * * @return string The name of the scope. + * @noinspection PhpUnused */ protected function get_scope_name(): string { switch ( $this->scope ) { @@ -366,6 +387,7 @@ protected function get_scope_name(): string { * Retrieve the icon used for the current scope * * @return string A dashicon name. + * @noinspection PhpUnused */ protected function get_scope_icon(): string { $icons = self::get_scope_icons(); @@ -377,6 +399,7 @@ protected function get_scope_icon(): string { * Determine if the snippet is a shared network snippet * * @return bool Whether the snippet is a shared network snippet. + * @noinspection PhpUnused */ protected function get_shared_network(): bool { if ( isset( $this->fields['shared_network'] ) ) { @@ -396,7 +419,8 @@ protected function get_shared_network(): bool { /** * Retrieve the snippet modification date as a timestamp. * - * @return integer Timestamp value. + * @return int Timestamp value. + * @noinspection PhpUnused */ protected function get_modified_timestamp(): int { $datetime = DateTime::createFromFormat( self::DATE_FORMAT, $this->modified, new DateTimeZone( 'UTC' ) ); @@ -408,6 +432,7 @@ protected function get_modified_timestamp(): int { * Retrieve the modification time in the local timezone. * * @return DateTime + * @noinspection PhpUnused */ protected function get_modified_local(): DateTime { $datetime = DateTime::createFromFormat( self::DATE_FORMAT, $this->modified, new DateTimeZone( 'UTC' ) ); @@ -441,7 +466,7 @@ protected function get_modified_local(): DateTime { /** * Retrieve the last modified time, nicely formatted for readability. * - * @param boolean $include_html Whether to include HTML in the output. + * @param bool $include_html Whether to include HTML in the output. * * @return string */ @@ -474,6 +499,8 @@ public function format_modified( bool $include_html = true ): string { /** * Determine whether the current snippet type is pro-only. + * + * @noinspection PhpUnused */ private function get_is_pro(): bool { return 'css' === $this->type || 'js' === $this->type || 'cond' === $this->type; diff --git a/src/php/class-plugin.php b/src/php/Plugin.php similarity index 54% rename from src/php/class-plugin.php rename to src/php/Plugin.php index faa3ddc3b..00cb4aad3 100644 --- a/src/php/class-plugin.php +++ b/src/php/Plugin.php @@ -2,10 +2,21 @@ namespace Code_Snippets; -use Code_Snippets\Cloud\Cloud_API; +use Code_Snippets\Admin\Bootstrap_Admin; + +use Code_Snippets\Client\Cloud_API; +use Code_Snippets\Core\DB; +use Code_Snippets\Core\Licensing; +use Code_Snippets\Core\Upgrader; +use Code_Snippets\Integration\Classic_Editor\MCE_Plugin; +use Code_Snippets\Integration\Evaluate_Content; +use Code_Snippets\Integration\Evaluate_Functions; +use Code_Snippets\Integration\Shortcodes; +use Code_Snippets\Migration\Importers\Files\Files_Import_Manager; +use Code_Snippets\Migration\Importers\Plugins\Plugins_Import_Manager; +use Code_Snippets\REST_API\Cloud_Snippets_REST_Controller; +use Code_Snippets\REST_API\REST_Endpoints; use Code_Snippets\REST_API\Snippets_REST_Controller; -use Evaluation\Evaluate_Content; -use Evaluation\Evaluate_Functions; /** * The main plugin class @@ -17,16 +28,20 @@ class Plugin { /** * Current plugin version number * + * @deprecated Use the PLUGIN_VERSION constant instead. + * * @var string */ - public string $version; + public string $version = PLUGIN_VERSION; /** * Filesystem path to the main plugin file * + * @deprecated Use the PLUGIN_FILE constant instead. + * * @var string */ - public string $file; + public string $file = PLUGIN_FILE; /** * Database class @@ -52,16 +67,9 @@ class Plugin { /** * Administration area class * - * @var Admin + * @var Bootstrap_Admin */ - public Admin $admin; - - /** - * Front-end functionality class - * - * @var Front_End - */ - public Front_End $front_end; + public Bootstrap_Admin $admin; /** * Class for managing cloud API actions. @@ -80,110 +88,80 @@ class Plugin { /** * Handles snippet handler registration. * - * @var Snippet_Handler_Registry + * @var Flat_Files\Handler_Registry */ - public Snippet_Handler_Registry $snippet_handler_registry; + public Flat_Files\Handler_Registry $snippet_handler_registry; /** * Class constructor - * - * @param string $version Current plugin version. - * @param string $file Path to main plugin file. */ - public function __construct( string $version, string $file ) { - $this->version = $version; - $this->file = $file; - + public function __construct() { wp_cache_add_global_groups( CACHE_GROUP ); - - add_filter( 'code_snippets/execute_snippets', array( $this, 'disable_snippet_execution' ), 5 ); - - if ( isset( $_REQUEST['snippets-safe-mode'] ) ) { - add_filter( 'home_url', array( $this, 'add_safe_mode_query_var' ) ); - add_filter( 'admin_url', array( $this, 'add_safe_mode_query_var' ) ); - } - - add_action( 'rest_api_init', [ $this, 'init_rest_api' ] ); add_action( 'allowed_redirect_hosts', [ $this, 'allow_code_snippets_redirect' ] ); } /** - * Initialise classes and include files + * Load the plugin utilities. + */ + private function load_utilities() { + require_once __DIR__ . '/snippet-ops.php'; + require_once __DIR__ . '/Utils/editor.php'; + require_once __DIR__ . '/Utils/options.php'; + require_once __DIR__ . '/Settings/settings.php'; + } + + /** + * Initialise the plugin and load all necessary classes. */ public function load_plugin() { - $includes_path = __DIR__; + $this->load_utilities(); - // Database operation functions. $this->db = new DB(); - - // Snippet operation functions. - require_once $includes_path . '/snippet-ops.php'; + $this->cloud_api = new Cloud_API(); + $this->licensing = new Licensing(); $this->evaluate_content = new Evaluate_Content( $this->db ); $this->evaluate_functions = new Evaluate_Functions( $this->db ); - // CodeMirror editor functions. - require_once $includes_path . '/editor.php'; - - // General Administration functions. if ( is_admin() ) { - $this->admin = new Admin(); + $this->admin = new Bootstrap_Admin(); } - // 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'; + new REST_Endpoints(); + new Snippets_REST_Controller(); + new Cloud_Snippets_REST_Controller(); - // Snippet files. - $this->snippet_handler_registry = new Snippet_Handler_Registry( [ - 'php' => new Php_Snippet_Handler(), - 'html' => new Html_Snippet_Handler(), - ] ); + new Shortcodes(); + new MCE_Plugin(); + new Upgrader( PLUGIN_VERSION, $this->db ); - $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(); - - $upgrade = new Upgrade( $this->version, $this->db ); - add_action( 'plugins_loaded', array( $upgrade, 'run' ), 0 ); - $this->licensing = new Licensing(); + $this->init_snippet_files(); // Importers. new Plugins_Import_Manager(); new Files_Import_Manager(); - + // Initialize promotions. - new Promotions\Elementor_Pro(); + new Integration\Promotions\Elementor_Pro(); } /** - * Register custom REST API controllers. + * Initialises the snippet files component. * * @return void */ - public function init_rest_api() { - $snippets_controller = new Snippets_REST_Controller(); - $snippets_controller->register_routes(); - } + private function init_snippet_files() { + $this->snippet_handler_registry = new Flat_Files\Handler_Registry( + [ + 'php' => new Flat_Files\Handlers\Functions_Snippet_Handler(), + 'html' => new Flat_Files\Handlers\Content_Snippet_Handler(), + ] + ); - /** - * Disable snippet execution if the necessary query var is set. - * - * @param bool $execute_snippets Current filter value. - * - * @return bool New filter value. - */ - public function disable_snippet_execution( bool $execute_snippets ): bool { - return ! empty( $_REQUEST['snippets-safe-mode'] ) && $this->current_user_can() ? false : $execute_snippets; + $fs = new Flat_Files\WP_Filesystem_Adapter(); + $config_repo = new Flat_Files\Flat_File_Config_Repository( $fs ); + + $snippet_files = new Flat_Files\Snippet_Files( $this->snippet_handler_registry, $fs, $config_repo ); + $snippet_files->register_hooks(); } /** @@ -203,12 +181,12 @@ public function is_compact_menu(): bool { * @return string The menu's slug. */ public function get_menu_slug( string $menu = '' ): string { - $add = array( 'single', 'add', 'add-new', 'add-snippet', 'new-snippet', 'add-new-snippet' ); - $edit = array( 'edit', 'edit-snippet' ); - $import = array( 'import', 'import-snippets', 'import-code-snippets' ); - $settings = array( 'settings', 'snippets-settings' ); - $cloud = array( 'cloud', 'cloud-snippets' ); - $welcome = array( 'welcome', 'getting-started', 'code-snippets' ); + $add = [ 'single', 'add', 'add-new', 'add-snippet', 'new-snippet', 'add-new-snippet' ]; + $edit = [ 'edit', 'edit-snippet' ]; + $import = [ 'import', 'import-snippets', 'import-code-snippets' ]; + $settings = [ 'settings', 'snippets-settings' ]; + $cloud = [ 'cloud', 'cloud-snippets' ]; + $welcome = [ 'welcome', 'getting-started', 'code-snippets' ]; if ( in_array( $menu, $edit, true ) ) { return 'edit-snippet'; @@ -219,7 +197,7 @@ public function get_menu_slug( string $menu = '' ): string { } elseif ( in_array( $menu, $settings, true ) ) { return 'snippets-settings'; } elseif ( in_array( $menu, $cloud, true ) ) { - return 'snippets&type=cloud'; + return 'snippets&subpage=cloud-community'; } elseif ( in_array( $menu, $welcome, true ) ) { return 'code-snippets-welcome'; } else { @@ -258,22 +236,6 @@ public function get_menu_url( string $menu = '', string $context = 'self' ): str } } - /** - * Fetch the admin menu slug for a snippets admin menu. - * - * @param integer $snippet_id Snippet ID. - * @param string $context URL scheme to use. - * - * @return string The URL to the edit snippet page for that snippet. - */ - public function get_snippet_edit_url( int $snippet_id, string $context = 'self' ): string { - return add_query_arg( - 'id', - absint( $snippet_id ), - $this->get_menu_url( 'edit', $context ) - ); - } - /** * Allow redirecting to the Code Snippets site. * @@ -290,7 +252,7 @@ public function allow_code_snippets_redirect( array $hosts ): array { /** * Determine whether the current user can perform actions on snippets. * - * @return boolean Whether the current user has the required capability. + * @return bool Whether the current user has the required capability. * * @since 2.8.6 */ @@ -371,39 +333,6 @@ public function get_cap(): string { return $this->get_cap_name(); } - /** - * Inject the safe mode query var into URLs - * - * @param string $url Original URL. - * - * @return string Modified URL. - */ - public function add_safe_mode_query_var( string $url ): string { - return isset( $_REQUEST['snippets-safe-mode'] ) ? - add_query_arg( 'snippets-safe-mode', (bool) $_REQUEST['snippets-safe-mode'], $url ) : - $url; - } - - /** - * Retrieve a list of available snippet types and their labels. - * - * @return array Snippet types. - */ - public static function get_types(): array { - return apply_filters( - 'code_snippets_types', - array( - 'php' => __( 'Functions', 'code-snippets' ), - 'html' => __( 'Content', 'code-snippets' ), - 'css' => __( 'Styles', 'code-snippets' ), - 'js' => __( 'Scripts', 'code-snippets' ), - 'cloud' => __( 'Codevault', 'code-snippets' ), - 'cloud_search' => __( 'Cloud Search', 'code-snippets' ), - 'bundles' => __( 'Bundles', 'code-snippets' ), - ) - ); - } - /** * Localise a plugin script to provide the CODE_SNIPPETS object. * @@ -416,19 +345,24 @@ public function localize_script( string $handle ) { $handle, 'CODE_SNIPPETS', [ + 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'isLicensed' => $this->licensing->is_licensed(), 'isCloudConnected' => Cloud_API::is_cloud_connection_available(), + 'hideUpsell' => Settings\get_setting( 'general', 'hide_upgrade_menu' ), 'restAPI' => [ - 'base' => esc_url_raw( rest_url() ), - 'snippets' => esc_url_raw( rest_url( Snippets_REST_Controller::get_base_route() ) ), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'localToken' => $this->cloud_api->get_local_token(), + 'base' => esc_url_raw( rest_url() ), + 'snippets' => esc_url_raw( rest_url( Snippets_REST_Controller::get_base_route() ) ), + 'cloudSearch' => esc_url_raw( rest_url( Cloud_Snippets_REST_Controller::get_base_route() ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'localToken' => $this->cloud_api->get_local_token(), ], 'urls' => [ - 'plugin' => esc_url_raw( plugins_url( '', PLUGIN_FILE ) ), - 'manage' => esc_url_raw( $this->get_menu_url() ), - 'edit' => esc_url_raw( $this->get_menu_url( 'edit' ) ), - 'addNew' => esc_url_raw( $this->get_menu_url( 'add' ) ), + 'plugin' => esc_url_raw( plugins_url( '', PLUGIN_FILE ) ), + 'manage' => esc_url_raw( $this->get_menu_url() ), + 'edit' => esc_url_raw( $this->get_menu_url( 'edit' ) ), + 'addNew' => esc_url_raw( $this->get_menu_url( 'add' ) ), + 'welcome' => esc_url_raw( $this->get_menu_url( 'welcome' ) ), + 'settings' => esc_url_raw( $this->get_menu_url( 'settings' ) ), ], ] ); diff --git a/src/php/REST_API/Cloud_Snippets_REST_Controller.php b/src/php/REST_API/Cloud_Snippets_REST_Controller.php new file mode 100644 index 000000000..ee0f9ceea --- /dev/null +++ b/src/php/REST_API/Cloud_Snippets_REST_Controller.php @@ -0,0 +1,135 @@ +rest_base; + + register_rest_route( + $this->namespace, + $route, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + 'args' => [ + 'query' => [ + 'description' => esc_html__( 'Search query.', 'code-snippets' ), + 'type' => 'string', + 'required' => true, + ], + 'searchByCodevault' => [ + 'description' => esc_html__( 'Treat the search query as the name of a CodeVault instead of a search term.', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], + 'page' => [ + 'description' => esc_html__( 'Page number.', 'code-snippets' ), + 'type' => 'integer', + 'default' => 1, + ], + ], + ], + 'schema' => [ $this, 'get_item_schema' ], + ] + ); + } + + /** + * Check if a given request has permission to view cloud snippets. + * + * @param WP_REST_Request $request The request object. + * + * @return bool Whether the request has permission to view cloud snippets. + */ + public function get_items_permissions_check( $request ): bool { + return code_snippets()->current_user_can(); + } + + /** + * Retrieve cloud snippets using a search query. + * + * @param WP_REST_Request $request The request object containing the search parameters. + * + * @return WP_REST_Response + */ + public function get_items( $request ): WP_REST_Response { + $method = $request->get_param( 'searchByCodevault' ) ? 'codevault' : 'term'; + $query = $request->get_param( 'query' ); + $page = $request->get_param( 'page' ); + + $cloud_snippets = Cloud_API::fetch_search_results( $method, $query, $page ); + + $results = []; + + foreach ( $cloud_snippets->snippets as $snippet ) { + $results[] = $snippet->get_fields(); + } + + $response = rest_ensure_response( $results ); + + $response->header( 'X-WP-Total', $cloud_snippets->total_snippets ); + $response->header( 'X-WP-TotalPages', $cloud_snippets->total_pages ); + + return $response; + } +} diff --git a/src/php/REST_API/REST_Endpoints.php b/src/php/REST_API/REST_Endpoints.php new file mode 100644 index 000000000..cc6de334a --- /dev/null +++ b/src/php/REST_API/REST_Endpoints.php @@ -0,0 +1,118 @@ +namespace, + 'recently-active', + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_recent_list_callback' ], + 'permission_callback' => [ code_snippets(), 'current_user_can' ], + 'args' => [ + 'network' => [ + 'description' => esc_html__( 'Fetch the recent list for network-wide snippets instead of site-wide.', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], + ], + ], + ] + ); + + register_rest_route( + $this->namespace, + 'recently-active', + [ + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'clear_recent_list_callback' ], + 'permission_callback' => [ code_snippets(), 'current_user_can' ], + 'args' => [ + 'network' => [ + 'description' => esc_html__( 'Clear the recent list for network-wide snippets instead of site-wide.', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], + ], + ], + ] + ); + } + + /** + * Callback for retrieving the recently activated snippets list. + * + * This will return the list of recently activated snippets, either site-wide or network-wide, + * depending on the 'network' parameter. + * + * @param WP_REST_Request $request The REST request object. + * + * @return WP_REST_Response The recently activated snippets list. + */ + public function get_recent_list_callback( WP_REST_Request $request ): WP_REST_Response { + return rest_ensure_response( + $request->get_param( 'network' ) + ? get_site_option( 'recently_activated_snippets', [] ) + : get_option( 'recently_activated_snippets', [] ) + ); + } + + /** + * Callback for clearing the recently activated snippets list. + * + * This will clear the list of recently activated snippets, either site-wide or network-wide, + * depending on the 'network' parameter. + * + * @param WP_REST_Request $request The REST request object. + * + * @return WP_REST_Response The recently activated snippets list prior to clearing it. + */ + public function clear_recent_list_callback( WP_REST_Request $request ): WP_REST_Response { + $network = $request->get_param( 'network' ); + + $current = get_self_option( $network, 'recently_activated_snippets', [] ); + delete_self_option( $network, 'recently_activated_snippets' ); + + return rest_ensure_response( $current ); + } +} diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/REST_API/Snippets_REST_Controller.php similarity index 92% rename from src/php/rest-api/class-snippets-rest-controller.php rename to src/php/REST_API/Snippets_REST_Controller.php index bf37e60bf..072100cfa 100644 --- a/src/php/rest-api/class-snippets-rest-controller.php +++ b/src/php/REST_API/Snippets_REST_Controller.php @@ -2,8 +2,10 @@ namespace Code_Snippets\REST_API; -use Code_Snippets\Export; -use Code_Snippets\Snippet; +use Code_Snippets\Export\Export; +use Code_Snippets\Migration\Export\Export_Code; +use Code_Snippets\Migration\Export\Export_JSON; +use Code_Snippets\Model\Snippet; use WP_Error; use WP_REST_Controller; use WP_REST_Request; @@ -12,10 +14,10 @@ use function Code_Snippets\activate_snippet; use function Code_Snippets\code_snippets; use function Code_Snippets\deactivate_snippet; -use function Code_Snippets\trash_snippet; use function Code_Snippets\get_snippet; use function Code_Snippets\get_snippets; use function Code_Snippets\save_snippet; +use function Code_Snippets\trash_snippet; use const Code_Snippets\REST_API_NAMESPACE; /** @@ -68,6 +70,13 @@ public static function get_prefixed_base_route(): string { return '/' . rtrim( rest_get_url_prefix(), '/\\' ) . '/' . self::get_base_route(); } + /** + * Class constrictor. + */ + public function __construct() { + add_action( 'rest_api_init', [ $this, 'register_routes' ] ); + } + /** * Register REST routes. */ @@ -205,7 +214,7 @@ public function get_items( $request ): WP_REST_Response { if ( isset( $query_params['per_page'] ) || isset( $query_params['page'] ) ) { $collection_params = $this->get_collection_params(); - $per_page = isset( $query_params['per_page'] ) + $per_page = isset( $query_params['per_page'] ) ? max( 1, (int) $query_params['per_page'] ) : (int) $collection_params['per_page']['default']; $page_request = (int) $request->get_param( 'page' ); @@ -403,13 +412,14 @@ public function deactivate_item( WP_REST_Request $request ) { /** * Prepare an instance of the Export class from a request. * - * @param WP_REST_Request $request Full data about the request. + * @param Export $export Instance of Export class to use for generating response. * - * @return Export + * @return WP_REST_Response */ - protected function build_export( WP_REST_Request $request ): Export { - $item = $this->prepare_item_for_database( $request ); - return new Export( [ $item->id ], $item->network ); + protected function build_export_response( Export $export ): WP_REST_Response { + $response = rest_ensure_response( $export->generate_export() ); + $response->header( 'X-Suggested-Filename', $export->build_filename() ); + return $response; } /** @@ -417,12 +427,13 @@ protected function build_export( WP_REST_Request $request ): Export { * * @param WP_REST_Request $request Full data about the request. * - * @return WP_Error|WP_REST_Response + * @return WP_REST_Response */ - public function export_item( WP_REST_Request $request ) { - $export = $this->build_export( $request ); - $result = $export->create_export_object(); - return rest_ensure_response( $result ); + public function export_item( WP_REST_Request $request ): WP_REST_Response { + $item = $this->prepare_item_for_database( $request ); + $export = new Export_JSON( [ $item->id ], $item->network ); + + return $this->build_export_response( $export ); } /** @@ -430,13 +441,13 @@ public function export_item( WP_REST_Request $request ) { * * @param WP_REST_Request $request Full data about the request. * - * @return WP_Error|WP_REST_Response + * @return WP_REST_Response */ - public function export_item_code( WP_REST_Request $request ) { - $export = $this->build_export( $request ); - $result = $export->export_snippets_code(); + public function export_item_code( WP_REST_Request $request ): WP_REST_Response { + $item = $this->prepare_item_for_database( $request ); + $export = new Export_Code( [ $item->id ], $item->network ); - return rest_ensure_response( $result ); + return $this->build_export_response( $export ); } /** @@ -485,7 +496,7 @@ public function prepare_item_for_response( $item, $request ) { * * @param WP_REST_Request $request Full data about the request. * - * @return boolean + * @return bool */ public function get_items_permissions_check( $request ): bool { return code_snippets()->current_user_can(); @@ -496,7 +507,7 @@ public function get_items_permissions_check( $request ): bool { * * @param WP_REST_Request $request Full data about the request. * - * @return boolean + * @return bool */ public function get_item_permissions_check( $request ): bool { return $this->get_items_permissions_check( $request ); @@ -507,7 +518,7 @@ public function get_item_permissions_check( $request ): bool { * * @param WP_REST_Request $request Full data about the request. * - * @return boolean + * @return bool */ public function create_item_permissions_check( $request ): bool { return code_snippets()->current_user_can(); @@ -518,7 +529,7 @@ public function create_item_permissions_check( $request ): bool { * * @param WP_REST_Request $request Full data about the request. * - * @return boolean + * @return bool */ public function update_item_permissions_check( $request ): bool { return $this->create_item_permissions_check( $request ); @@ -529,7 +540,7 @@ public function update_item_permissions_check( $request ): bool { * * @param WP_REST_Request $request Full data about the request. * - * @return boolean + * @return bool */ public function delete_item_permissions_check( $request ): bool { return $this->create_item_permissions_check( $request ); @@ -605,6 +616,11 @@ public function get_item_schema(): array { 'format' => 'date-time', 'readonly' => true, ], + 'last_active' => [ + 'description' => esc_html__( 'Timestamp of when the snippet was last active, if available.', 'code-snippets' ), + 'type' => 'integer', + 'readonly' => true, + ], 'code_error' => [ 'description' => esc_html__( 'Error message if the snippet code could not be parsed.', 'code-snippets' ), 'type' => 'string', diff --git a/src/php/Settings/Editor_Preview.php b/src/php/Settings/Editor_Preview.php new file mode 100644 index 000000000..69ec550de --- /dev/null +++ b/src/php/Settings/Editor_Preview.php @@ -0,0 +1,64 @@ + List of editor themes. + */ + public static function get_editor_theme_list(): array { + $themes = [ + 'default' => __( 'Default', 'code-snippets' ), + ]; + + foreach ( get_editor_themes() as $theme ) { + if ( '-mobile' === substr( $theme, -7 ) ) { + continue; + } + + $themes[ $theme ] = ucwords( str_replace( '-', ' ', $theme ) ); + } + + return $themes; + } + + /** + * Render the editor preview setting. + */ + public function render() { + $settings = get_settings_values(); + $settings = $settings['editor']; + + $indent_unit = absint( $settings['indent_unit'] ); + $tab_size = absint( $settings['tab_size'] ); + + $n_tabs = $settings['indent_with_tabs'] ? floor( $indent_unit / $tab_size ) : 0; + $n_spaces = $settings['indent_with_tabs'] ? $indent_unit % $tab_size : $indent_unit; + + $indent = str_repeat( "\t", $n_tabs ) . str_repeat( ' ', $n_spaces ); + + $code = "add_filter( 'admin_footer_text', function ( \$text ) {\n\n" . + $indent . "\$site_name = get_bloginfo( 'name' );\n\n" . + $indent . '$text = "Thank you for visiting $site_name.";' . "\n" . + $indent . 'return $text;' . "\n" . + "} );\n"; + + echo ''; + } +} diff --git a/src/php/settings/class-setting-field.php b/src/php/Settings/Setting_Field.php similarity index 81% rename from src/php/settings/class-setting-field.php rename to src/php/Settings/Setting_Field.php index e9887456d..119c3d6ed 100644 --- a/src/php/settings/class-setting-field.php +++ b/src/php/Settings/Setting_Field.php @@ -1,16 +1,9 @@ type . '_field'; + switch ( $this->type ) { + case 'callback': + if ( is_callable( $this->render_callback ) ) { + call_user_func( $this->render_callback, $this->args ); + } + break; + + case 'checkbox': + $this->render_checkbox( $this->input_name, $this->label, $this->get_saved_value() ?? false ); + break; + + case 'checkboxes': + $this->render_checkboxes_field(); + break; + + case 'text': + $this->render_text_field(); + break; + + case 'number': + $this->render_number_field(); + break; + + case 'select': + $this->render_select_field(); + break; + + case 'action': + $this->render_action_field(); + break; + + default: + // Error message, not necessary to translate. + printf( 'Cannot render a %s field.', esc_html( $this->type ) ); + return; - if ( method_exists( $this, $method_name ) ) { - call_user_func( array( $this, $method_name ) ); - } else { - // Error message, not necessary to translate. - printf( 'Cannot render a %s field.', esc_html( $this->type ) ); - return; } if ( $this->desc ) { @@ -113,33 +134,21 @@ public function render() { } } - /** - * Render a callback field. - */ - public function render_callback_field() { - if ( ! is_callable( $this->render_callback ) ) { - return; - } - - call_user_func( $this->render_callback, $this->args ); - } - /** * Render a single checkbox field. * - * @param string $input_name Input name. - * @param string $label Input label. - * @param boolean $checked Whether the checkbox should be checked. + * @param string $input_name Input name. + * @param string $label Input label. + * @param bool $checked Whether the checkbox should be checked. */ private static function render_checkbox( string $input_name, string $label, bool $checked ) { - $checkbox = sprintf( - '', + '', esc_attr( $input_name ), checked( $checked, true, false ) ); - $kses = [ + $allowed_html = [ 'input' => [ 'type' => [], 'name' => [], @@ -150,24 +159,14 @@ private static function render_checkbox( string $input_name, string $label, bool if ( $label ) { printf( '', - wp_kses( $checkbox, $kses ), + wp_kses( $checkbox, $allowed_html ), wp_kses_post( $label ) ); } else { - echo wp_kses( $checkbox, $kses ); + echo wp_kses( $checkbox, $allowed_html ); } } - /** - * Render a checkbox field for a setting - * - * @return void - * @since 2.0.0 - */ - public function render_checkbox_field() { - $this->render_checkbox( $this->input_name, $this->label, $this->get_saved_value() ?? false ); - } - /** * Render a checkbox field for a setting * @@ -246,7 +245,7 @@ private function render_select_field() { foreach ( $this->options as $option => $option_label ) { printf( - '', + '', esc_attr( $option ), selected( $option, $saved_value, false ), esc_html( $option_label ) diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php new file mode 100644 index 000000000..bb14499c0 --- /dev/null +++ b/src/php/Settings/Settings_Fields.php @@ -0,0 +1,312 @@ +> + */ + private array $fields; + + /** + * The default settings values. + * + * @var array> + */ + private array $defaults; + + /** + * Constructor. + * + * Initializes the settings fields and default values. + */ + public function __construct() { + $this->init_fields(); + $this->init_defaults(); + } + + /** + * Retrieve the instance of this class. + * + * @return Settings_Fields + */ + private static function get_instance(): Settings_Fields { + if ( ! isset( self::$instance ) ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Retrieve the default setting values + * + * @return array> + */ + public static function get_default_values(): array { + return self::get_instance()->defaults; + } + + /** + * Retrieve the settings fields. + * + * @return array> + */ + public static function get_field_definitions(): array { + return self::get_instance()->fields; + } + + /** + * Initialise default settings values. + * + * @return void + */ + private function init_defaults() { + $this->defaults = [ + 'general' => [ + 'activate_by_default' => true, + 'enable_tags' => true, + 'enable_description' => true, + 'visual_editor_rows' => 5, + 'list_order' => 'priority-asc', + 'disable_prism' => false, + 'hide_upgrade_menu' => false, + 'complete_uninstall' => false, + ], + 'editor' => [ + 'indent_with_tabs' => true, + 'tab_size' => 4, + 'indent_unit' => 4, + 'font_size' => 14, + 'wrap_lines' => true, + 'code_folding' => true, + 'line_numbers' => true, + 'auto_close_brackets' => true, + 'highlight_selection_matches' => true, + 'highlight_active_line' => true, + 'keymap' => 'default', + 'theme' => 'default', + ], + 'version-switch' => [ + 'selected_version' => '', + ], + 'debug' => [ + 'enable_version_change' => false, + ], + ]; + + $this->defaults = apply_filters( 'code_snippets_settings_defaults', $this->defaults ); + } + + /** + * Initialise the settings fields values. + * + * @return void + */ + private function init_fields() { + $this->fields = []; + + $this->fields['debug'] = [ + 'database_update' => [ + 'name' => __( 'Database Table Upgrade', 'code-snippets' ), + 'type' => 'action', + 'label' => __( 'Upgrade Database Table', 'code-snippets' ), + 'desc' => __( 'Use this button to manually upgrade the Code Snippets database table. This action will only affect the snippets table and should be used only when necessary.', 'code-snippets' ), + ], + 'reset_caches' => [ + 'name' => __( 'Reset Caches', 'code-snippets' ), + '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' ), + ], + ]; + + $this->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' ], + ], + ]; + + $this->fields['general'] = [ + 'activate_by_default' => [ + 'name' => __( 'Activate by Default', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( "Make the 'Save and Activate' button the default action when saving a snippet.", 'code-snippets' ), + ], + 'enable_tags' => [ + 'name' => __( 'Enable Snippet Tags', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show snippet tags on admin pages.', 'code-snippets' ), + ], + 'enable_description' => [ + 'name' => __( 'Enable Snippet Descriptions', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show snippet descriptions on admin pages.', 'code-snippets' ), + ], + 'visual_editor_rows' => [ + 'name' => __( 'Description Editor Height', 'code-snippets' ), + 'type' => 'number', + 'label' => _x( 'rows', 'unit', 'code-snippets' ), + 'min' => 0, + ], + 'list_order' => [ + 'name' => __( 'Snippets List Order', 'code-snippets' ), + 'type' => 'select', + 'desc' => __( 'Default way to order snippets on the All Snippets admin menu.', 'code-snippets' ), + 'options' => [ + 'priority-asc' => __( 'Priority', 'code-snippets' ), + 'name-asc' => __( 'Name (A-Z)', 'code-snippets' ), + 'name-desc' => __( 'Name (Z-A)', 'code-snippets' ), + 'modified-desc' => __( 'Modified (latest first)', 'code-snippets' ), + 'modified-asc' => __( 'Modified (oldest first)', 'code-snippets' ), + ], + ], + 'disable_prism' => [ + 'name' => __( 'Disable Syntax Highlighter', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Disable syntax highlighting when displaying snippet code on the front-end.', 'code-snippets' ), + ], + ]; + + if ( ! code_snippets()->licensing->is_licensed() ) { + $this->fields['general']['hide_upgrade_menu'] = [ + 'name' => __( 'Hide Upgrade Notices', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Hide notices inviting you to upgrade to Code Snippets Pro.', 'code-snippets' ), + ]; + } + + if ( ! is_multisite() || is_main_site() ) { + $this->fields['general']['complete_uninstall'] = [ + 'name' => __( 'Complete Uninstall', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'When the plugin is deleted from the Plugins menu, also delete all snippets and plugin settings.', 'code-snippets' ), + ]; + } + + $this->fields['editor'] = [ + 'indent_with_tabs' => [ + 'name' => __( 'Indent With Tabs', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Use hard tabs instead of spaces for indentation.', 'code-snippets' ), + 'codemirror' => 'indentWithTabs', + ], + 'tab_size' => [ + 'name' => __( 'Tab Size', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'The width of a tab character.', 'code-snippets' ), + 'label' => _x( 'spaces', 'unit', 'code-snippets' ), + 'codemirror' => 'tabSize', + 'min' => 0, + ], + 'indent_unit' => [ + 'name' => __( 'Indent Unit', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'The number of spaces to indent a block.', 'code-snippets' ), + 'label' => _x( 'spaces', 'unit', 'code-snippets' ), + 'codemirror' => 'indentUnit', + 'min' => 0, + ], + 'font_size' => [ + 'name' => __( 'Font Size', 'code-snippets' ), + 'type' => 'number', + 'label' => _x( 'px', 'unit', 'code-snippets' ), + 'codemirror' => 'fontSize', + 'min' => 8, + 'max' => 28, + ], + 'wrap_lines' => [ + 'name' => __( 'Wrap Lines', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Soft-wrap long lines of code instead of horizontally scrolling.', 'code-snippets' ), + 'codemirror' => 'lineWrapping', + ], + 'code_folding' => [ + 'name' => __( 'Code Folding', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Allow folding functions or other blocks into a single line.', 'code-snippets' ), + 'codemirror' => 'foldGutter', + ], + 'line_numbers' => [ + 'name' => __( 'Line Numbers', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show line numbers to the left of the editor.', 'code-snippets' ), + 'codemirror' => 'lineNumbers', + ], + 'auto_close_brackets' => [ + 'name' => __( 'Auto Close Brackets', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Auto-close brackets and quotes when typed.', 'code-snippets' ), + 'codemirror' => 'autoCloseBrackets', + ], + 'highlight_selection_matches' => [ + 'name' => __( 'Highlight Selection Matches', 'code-snippets' ), + 'label' => __( 'Highlight all instances of a currently selected word.', 'code-snippets' ), + 'type' => 'checkbox', + 'codemirror' => 'highlightSelectionMatches', + ], + 'highlight_active_line' => [ + 'name' => __( 'Highlight Active Line', 'code-snippets' ), + 'label' => __( 'Highlight the line that is currently being edited.', 'code-snippets' ), + 'type' => 'checkbox', + 'codemirror' => 'styleActiveLine', + ], + 'keymap' => [ + 'name' => __( 'Keymap', 'code-snippets' ), + 'type' => 'select', + 'desc' => __( 'The set of keyboard shortcuts to use in the code editor.', 'code-snippets' ), + 'options' => [ + 'default' => __( 'Default', 'code-snippets' ), + 'vim' => __( 'Vim', 'code-snippets' ), + 'emacs' => __( 'Emacs', 'code-snippets' ), + 'sublime' => __( 'Sublime Text', 'code-snippets' ), + ], + 'codemirror' => 'keyMap', + ], + 'theme' => [ + 'name' => __( 'Theme', 'code-snippets' ), + 'type' => 'select', + 'options' => Editor_Preview::get_editor_theme_list(), + 'codemirror' => 'theme', + ], + ]; + + $this->fields = apply_filters( 'code_snippets_settings_fields', $this->fields ); + } +} diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php new file mode 100644 index 000000000..d5f8adb8e --- /dev/null +++ b/src/php/Settings/Version_Switch.php @@ -0,0 +1,496 @@ + $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( self::CACHE_KEY, $versions, self::VERSION_CACHE_DURATION ); + } + + return $versions; + } + + /** + * Retrieve the current plugin version. + * + * @return string + */ + public static function get_current_version(): string { + return defined( 'CODE_SNIPPETS_VERSION' ) ? CODE_SNIPPETS_VERSION : '0.0.0'; + } + + /** + * Determine if a version switch is currently taking place. + * + * @return bool + */ + public static function is_version_switch_in_progress(): bool { + return get_transient( self::PROGRESS_KEY ) !== false; + } + + /** + * Purge transient data associated with this class. + * + * @return void + */ + public static function clear_version_caches(): void { + delete_transient( self::CACHE_KEY ); + delete_transient( self::PROGRESS_KEY ); + } + + /** + * Validate that a target version is valid. + * + * @param string $target_version Target version for switching. + * @param array $available_versions List of available versions. + * + * @return array + */ + 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' => '', + ]; + } + + /** + * Create a response indicating an error occurred. + * + * @param string $message Error message. + * @param string $technical_details Additional details. + * + * @return array + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log + */ + 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, + ]; + } + + /** + * Install a plugin version from a URL. + * + * @param string $download_url Download URL. + * + * @return array|bool|WP_Error + */ + 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, + ] + ); + } + + /** + * Extract error message from an upgrade handler. + * + * @param WP_Upgrader_Skin|null $update_handler Update handler. + * @param Plugin_Upgrader|null $upgrader Plugin upgrader. + * + * @return string + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r + */ + 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" . $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 ); + } + + /** + * Report the failure of a version switch attempt. + * + * @param string $target_version Version number of attempted upgrade. + * @param mixed $result Result of upgrade. + * @param string $details Additional details. + * + * @return void + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions + */ + private 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 ) ); + } + } + + /** + * Handle the failure to install a new version. + * + * @param string $target_version Version used for attempted installation. + * @param string $download_url URL used for downloading new version. + * @param mixed $install_result Result of installation attempt. + * + * @return array + */ + private 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, + ]; + } + + /** + * Handle switching to a different plugin version. + * + * @param string $target_version Target version to switch to. + * + * @return array Result data. + */ + 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( self::PROGRESS_KEY, $target_version, self::PROGRESS_TIMEOUT ); + + $install_result = self::perform_version_install( $validation['download_url'] ); + + delete_transient( self::PROGRESS_KEY ); + + if ( is_wp_error( $install_result ) ) { + return self::create_error_response( $install_result->get_error_message() ); + } + + if ( $install_result ) { + delete_transient( self::CACHE_KEY ); + + // translators: %s: new version number. + $message = esc_html__( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ); + + return [ + 'success' => true, + 'message' => sprintf( $message, $target_version ), + ]; + } else { + return self::handle_installation_failure( $target_version, $validation['download_url'], $install_result ); + } + } + + /** + * Render settings page field for the version switcher. + * + * @return void + */ + public static function render_version_switch_field(): 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( wp_unslash( $_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 ); + } + } + + /** + * Render settings page field for the refresh version button. + * + * @return void + */ + public static function render_refresh_versions_field(): void { + printf( + '', + esc_html__( 'Refresh Available Versions', 'code-snippets' ) + ); + + printf( + '

%s

', + esc_html__( 'Check for the latest available plugin versions from WordPress.org.', 'code-snippets' ) + ); + } + + /** + * AJAX handler for refreshing the onstalled version. + * + * @return void + */ + public static function ajax_refresh_versions(): void { + check_ajax_referer( 'code_snippets_refresh_versions', sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ) ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'You do not have permission to manage options.', 'code-snippets' ) ] ); + } + + delete_transient( self::CACHE_KEY ); + self::get_available_versions(); + + wp_send_json_success( [ 'message' => __( 'Available versions updated successfully.', 'code-snippets' ) ] ); + } + + /** + * Render warning notice. + * + * @return void + */ + public static function render_version_switch_warning(): void { + ?> + + > @@ -85,12 +50,13 @@ function get_settings_values(): array { return $settings; } - $settings = get_default_settings(); - $saved = get_self_option( are_settings_unified(), OPTION_NAME, array() ); + $settings = Settings_Fields::get_default_values(); + $saved = get_self_option( are_settings_unified(), OPTION_NAME, [] ); - foreach ( $settings as $section => $fields ) { + // Deep merge the saved settings with the default values. + foreach ( $settings as $section => $section_fields ) { if ( isset( $saved[ $section ] ) ) { - $settings[ $section ] = array_replace( $fields, $saved[ $section ] ); + $settings[ $section ] = array_replace( $section_fields, $saved[ $section ] ); } } @@ -137,9 +103,9 @@ 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. @@ -155,12 +121,8 @@ function get_settings_sections(): array { * Register settings sections, fields, etc */ function register_plugin_settings() { - if ( are_settings_unified() ) { - if ( ! get_site_option( OPTION_NAME ) ) { - add_site_option( OPTION_NAME, get_default_settings() ); - } - } elseif ( ! get_option( OPTION_NAME ) ) { - add_option( OPTION_NAME, get_default_settings() ); + if ( ! get_self_option( are_settings_unified(), OPTION_NAME ) ) { + add_self_option( are_settings_unified(), OPTION_NAME, Settings_Fields::get_default_values() ); } // Register the setting. @@ -177,7 +139,7 @@ function register_plugin_settings() { // 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 ) { + foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) { if ( ! isset( $registered_sections[ $section_id ] ) ) { continue; } @@ -188,14 +150,18 @@ function register_plugin_settings() { } } + $editor_preview = new Editor_Preview(); + // Add editor preview as a field. add_settings_field( 'editor_preview', __( 'Editor Preview', 'code-snippets' ), - __NAMESPACE__ . '\\render_editor_preview', + [ $editor_preview, 'render' ], 'code-snippets', 'editor' ); + + Version_Switch::init(); } add_action( 'admin_init', __NAMESPACE__ . '\\register_plugin_settings' ); @@ -318,17 +284,17 @@ function sanitize_settings( array $input ): array { $updated = false; // Don't directly loop through $input as it does not include as deselected checkboxes. - foreach ( get_settings_fields() as $section_id => $fields ) { + foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) { foreach ( $fields as $field_id => $field ) { // Fetch the corresponding input value from the posted data. $input_value = $input[ $section_id ][ $field_id ] ?? null; + $stored_value = $settings[ $section_id ][ $field_id ] ?? null; // Attempt to sanitize the setting value. $sanitized_value = sanitize_setting_value( $field, $input_value ); - $current_value = $settings[ $section_id ][ $field_id ] ?? null; - if ( ! is_null( $sanitized_value ) && $current_value !== $sanitized_value ) { + if ( ! is_null( $sanitized_value ) && $stored_value !== $sanitized_value ) { $settings[ $section_id ][ $field_id ] = $sanitized_value; $updated = true; } diff --git a/src/php/Utils/Code_Highlighter.php b/src/php/Utils/Code_Highlighter.php new file mode 100644 index 000000000..cbebadb54 --- /dev/null +++ b/src/php/Utils/Code_Highlighter.php @@ -0,0 +1,54 @@ + true ] + ); + + wp_register_style( + self::PRISM_HANDLE, + plugins_url( 'dist/prism.css', PLUGIN_FILE ), + [], + PLUGIN_VERSION + ); + } + + + /** + * Enqueue all available Prism themes. + * + * @return void + */ + public static function enqueue_all_prism_themes() { + self::register_prism_assets(); + + wp_enqueue_style( self::PRISM_HANDLE ); + wp_enqueue_script( self::PRISM_HANDLE ); + } +} diff --git a/src/php/class-validator.php b/src/php/Utils/Validator.php similarity index 99% rename from src/php/class-validator.php rename to src/php/Utils/Validator.php index 27effb432..0014a21a8 100644 --- a/src/php/class-validator.php +++ b/src/php/Utils/Validator.php @@ -1,6 +1,6 @@ $extra_atts Pass a list of attributes to override the saved ones. */ function enqueue_code_editor( string $type, array $extra_atts = [] ) { - $plugin = code_snippets(); - $modes = [ 'css' => 'text/css', 'php' => 'php-snippet', @@ -49,10 +52,10 @@ function enqueue_code_editor( string $type, array $extra_atts = [] ) { ]; // Add relevant saved setting values to the default attributes. - $plugin_settings = Settings\get_settings_values(); - $setting_fields = Settings\get_settings_fields(); + $plugin_settings = get_settings_values(); + $field_definitions = Settings_Fields::get_field_definitions(); - foreach ( $setting_fields['editor'] as $field_id => $field ) { + foreach ( $field_definitions['editor'] as $field_id => $field ) { // The 'codemirror' setting field specifies the name of the attribute. $default_atts[ $field['codemirror'] ] = $plugin_settings['editor'][ $field_id ]; } @@ -86,10 +89,10 @@ function enqueue_code_editor( string $type, array $extra_atts = [] ) { wp_enqueue_script( 'code-snippets-code-editor', - plugins_url( 'dist/editor.js', $plugin->file ), + plugins_url( 'dist/editor.js', PLUGIN_FILE ), [ 'code-editor' ], - $plugin->version, - true + PLUGIN_VERSION, + [ 'in_footer' => true ] ); // CodeMirror Theme. @@ -98,9 +101,9 @@ function enqueue_code_editor( string $type, array $extra_atts = [] ) { if ( 'default' !== $theme ) { wp_enqueue_style( 'code-snippets-editor-theme-' . $theme, - plugins_url( "dist/editor-themes/$theme.css", $plugin->file ), + plugins_url( "dist/editor-themes/$theme.css", PLUGIN_FILE ), [ 'code-editor' ], - $plugin->version + PLUGIN_VERSION ); } } diff --git a/src/php/strings.php b/src/php/Utils/i18n.php similarity index 100% rename from src/php/strings.php rename to src/php/Utils/i18n.php diff --git a/src/php/Utils/options.php b/src/php/Utils/options.php new file mode 100644 index 000000000..47ba8e1cb --- /dev/null +++ b/src/php/Utils/options.php @@ -0,0 +1,67 @@ +api = $api; - } - - /** - * Enqueue assets necessary for the welcome menu. - * - * @return void - */ - public function enqueue_assets() { - wp_enqueue_style( - 'code-snippets-welcome', - plugins_url( 'dist/welcome.css', PLUGIN_FILE ), - [], - PLUGIN_VERSION - ); - } - - /** - * Retrieve a list of links to display in the page header. - * - * @return array - */ - protected function get_header_links(): array { - $links = [ - 'cloud' => [ - 'url' => 'https://codesnippets.cloud', - 'icon' => 'cloud', - 'label' => __( 'Cloud', 'code-snippets' ), - ], - 'resources' => [ - 'url' => 'https://help.codesnippets.pro/', - 'icon' => 'sos', - 'label' => __( 'Support', 'code-snippets' ), - ], - 'facebook' => [ - 'url' => 'https://www.facebook.com/groups/282962095661875/', - 'icon' => 'facebook', - 'label' => __( 'Community', 'code-snippets' ), - ], - 'discord' => [ - 'url' => 'https://snipco.de/discord', - 'icon' => 'discord', - 'label' => __( 'Discord', 'code-snippets' ), - ], - ]; - - if ( ! code_snippets()->licensing->is_licensed() ) { - $links['pro'] = [ - 'url' => 'https://codesnippets.pro/pricing/', - 'icon' => 'cart', - 'label' => __( 'Upgrade to Pro', 'code-snippets' ), - ]; - } - - return $links; - } -} diff --git a/src/php/class-list-table.php b/src/php/class-list-table.php deleted file mode 100644 index ad9a13944..000000000 --- a/src/php/class-list-table.php +++ /dev/null @@ -1,1519 +0,0 @@ - - */ - public array $statuses = [ 'all', 'active', 'inactive', 'recently_activated', 'shared_network', 'trashed' ]; - - /** - * Column name to use when ordering the snippets list. - * - * @var string - */ - protected string $order_by; - - /** - * Direction to use when ordering the snippets list. Either 'asc' or 'desc'. - * - * @var string - */ - protected string $order_dir; - - /** - * List of active snippets indexed by attached condition ID. - * - * @var array - */ - protected array $active_by_condition = []; - - /** - * The constructor function for our class. - * Registers hooks, initializes variables, setups class. - * - * @phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited - */ - public function __construct() { - global $status, $page; - $this->is_network = is_network_admin(); - - // Determine the status. - $status = apply_filters( 'code_snippets/list_table/default_view', 'all' ); - if ( isset( $_REQUEST['status'] ) && in_array( sanitize_key( $_REQUEST['status'] ), $this->statuses, true ) ) { - $status = sanitize_key( $_REQUEST['status'] ); - } - - // Add the search query to the URL. - if ( isset( $_REQUEST['s'] ) ) { - $_SERVER['REQUEST_URI'] = add_query_arg( 's', sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) ); - } - - // Add a snippets per page screen option. - $page = $this->get_pagenum(); - - add_screen_option( - 'per_page', - array( - 'label' => __( 'Snippets per page', 'code-snippets' ), - 'default' => 999, - 'option' => 'snippets_per_page', - ) - ); - - add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ) ); - - // Strip the result query arg from the URL. - $_SERVER['REQUEST_URI'] = remove_query_arg( 'result' ); - - // Add filters to format the snippet description in the same way the post content is formatted. - $filters = [ 'wptexturize', 'convert_smilies', 'convert_chars', 'wpautop', 'shortcode_unautop', 'capital_P_dangit', [ $this, 'wp_kses_desc' ] ]; - foreach ( $filters as $filter ) { - add_filter( 'code_snippets/list_table/column_description', $filter ); - } - - // Set up the class. - parent::__construct( - array( - 'ajax' => true, - 'plural' => 'snippets', - 'singular' => 'snippet', - ) - ); - } - - /** - * Determine if a condition is considered 'active' by checking if it is attached to any active snippets. - * - * @param Snippet $condition Condition snippet to check. - * - * @return bool - */ - protected function is_condition_active( Snippet $condition ): bool { - return $condition->is_condition() - && isset( $this->active_by_condition[ $condition->id ] ) - && count( $this->active_by_condition[ $condition->id ] ) > 0; - } - - /** - * Apply a more permissive version of wp_kses_post() to the snippet description. - * - * @param string $data Description content to filter. - * - * @return string Filtered description content with allowed HTML tags and attributes intact. - */ - public function wp_kses_desc( string $data ): string { - $safe_style_filter = function ( $styles ) { - $styles[] = 'display'; - return $styles; - }; - - add_filter( 'safe_style_css', $safe_style_filter ); - $data = wp_kses_post( $data ); - remove_filter( 'safe_style_css', $safe_style_filter ); - - return $data; - } - - /** - * Set the 'id' column as hidden by default. - * - * @param array $hidden List of hidden columns. - * - * @return array Modified list of hidden columns. - */ - public function default_hidden_columns( array $hidden ): array { - array_push( $hidden, 'id', 'code', 'cloud_id', 'revision' ); - return $hidden; - } - - /** - * Set the 'name' column as the primary column. - * - * @return string - */ - protected function get_default_primary_column_name(): string { - return 'name'; - } - - /** - * Define the output of all columns that have no callback function - * - * @param Snippet $item The snippet used for the current row. - * @param string $column_name The name of the column being printed. - * - * @return string The content of the column to output. - */ - protected function column_default( $item, $column_name ): string { - switch ( $column_name ) { - case 'id': - return $item->id; - - case 'description': - return apply_filters( 'code_snippets/list_table/column_description', $item->desc ); - - case 'type': - $type = $item->type; - $url = add_query_arg( 'type', $type ); - - return sprintf( - '%s', - esc_attr( $type ), - esc_url( $url ), - 'cond' === $type ? '' : esc_html( $type ) - ); - - case 'date': - return $item->modified ? $item->format_modified() : '—'; - - default: - return apply_filters( "code_snippets/list_table/column_$column_name", '—', $item ); - } - } - - /** - * Retrieve a URL to perform an action on a snippet - * - * @param string $action Name of action to produce a link for. - * @param Snippet $snippet Snippet object to produce link for. - * - * @return string URL to perform action. - */ - public function get_action_link( string $action, Snippet $snippet ): string { - - // Redirect actions to the network dashboard for shared network snippets. - $local_actions = array( 'activate', 'activate-shared', 'run-once', 'run-once-shared' ); - $network_redirect = $snippet->shared_network && ! $this->is_network && ! in_array( $action, $local_actions, true ); - - // Edit links go to a different menu. - if ( 'edit' === $action ) { - return code_snippets()->get_snippet_edit_url( $snippet->id, $network_redirect ? 'network' : 'self' ); - } - - $query_args = array( - 'action' => $action, - 'id' => $snippet->id, - 'scope' => $snippet->scope, - ); - - $url = $network_redirect ? - add_query_arg( $query_args, code_snippets()->get_menu_url( 'manage', 'network' ) ) : - add_query_arg( $query_args ); - - // Add a nonce to the URL for security purposes. - return wp_nonce_url( $url, 'code_snippets_manage_snippet_' . $snippet->id ); - } - - /** - * Build a list of action links for individual snippets - * - * @param Snippet $snippet The current snippet. - * - * @return array The action links HTML. - */ - private function get_snippet_action_links( Snippet $snippet ): array { - $actions = array(); - - if ( $snippet->shared_network && ! $this->is_network ) { - $actions['network_shared'] = sprintf( - '%s', - esc_html__( 'Network Snippet', 'code-snippets' ) - ); - - if ( is_multisite() && is_super_admin() ) { - $actions['edit'] = sprintf( - '%s', - esc_url( $this->get_action_link( 'edit', $snippet ) ), - esc_html__( 'Edit', 'code-snippets' ) - ); - } - - return apply_filters( 'code_snippets/list_table/row_actions', $actions, $snippet ); - } - - if ( $snippet->is_trashed() ) { - $actions['restore'] = sprintf( - '%s', - esc_url( $this->get_action_link( 'restore', $snippet ) ), - esc_html__( 'Restore', 'code-snippets' ) - ); - - $actions['delete_permanently'] = sprintf( - '%1$s', - esc_html__( 'Delete Permanently', 'code-snippets' ), - esc_url( $this->get_action_link( 'delete_permanently', $snippet ) ), - esc_js( - sprintf( - 'return confirm("%s");', - esc_html__( 'You are about to permanently delete the selected item.', 'code-snippets' ) . "\n" . - esc_html__( "'Cancel' to stop, 'OK' to delete.", 'code-snippets' ) - ) - ) - ); - } elseif ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) { - // Display special links if on a subsite and dealing with a network-active snippet. - if ( $snippet->active ) { - $actions['network_active'] = esc_html__( 'Network Active', 'code-snippets' ); - } else { - $actions['network_only'] = esc_html__( 'Network Only', 'code-snippets' ); - } - } elseif ( ! $snippet->shared_network || current_user_can( code_snippets()->get_network_cap_name() ) ) { - - // If the snippet is a shared network snippet, only display extra actions if the user has network permissions. - $simple_actions = array( - 'edit' => esc_html__( 'Edit', 'code-snippets' ), - 'clone' => esc_html__( 'Clone', 'code-snippets' ), - 'export' => esc_html__( 'Export', 'code-snippets' ), - ); - - foreach ( $simple_actions as $action => $label ) { - $actions[ $action ] = sprintf( '%s', esc_url( $this->get_action_link( $action, $snippet ) ), $label ); - } - - $actions['delete'] = sprintf( - '%1$s', - esc_html__( 'Trash', 'code-snippets' ), - esc_url( $this->get_action_link( 'delete', $snippet ) ) - ); - } - - return apply_filters( 'code_snippets/list_table/row_actions', $actions, $snippet ); - } - - /** - * Retrieve the code for a snippet activation switch - * - * @param Snippet $snippet Snippet object. - * - * @return string Output for activation switch. - */ - protected function column_activate( Snippet $snippet ): string { - if ( $snippet->is_trashed() ) { - return ''; - } - - // Show icon for shared network snippets on network admin. - if ( $snippet->shared_network && $this->is_network ) { - return ''; - } - - if ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) { - return ''; - } - - switch ( $snippet->scope ) { - case 'single-use': - $class = 'snippet-execution-button'; - $action = 'run-once'; - $label = esc_html__( 'Run Once', 'code-snippets' ); - break; - - case 'condition': - $edit_url = code_snippets()->get_snippet_edit_url( $snippet->id, $snippet->network ? 'network' : 'admin' ); - - return sprintf( - '%s', - esc_url( $edit_url ), - isset( $this->active_by_condition[ $snippet->id ] ) - ? esc_html( count( $this->active_by_condition[ $snippet->id ] ) ) - : 0 - ); - - default: - $class = 'snippet-activation-switch'; - $action = $snippet->active ? 'deactivate' : 'activate'; - $label = $snippet->network && ! $snippet->shared_network ? - ( $snippet->active ? __( 'Network Deactivate', 'code-snippets' ) : __( 'Network Activate', 'code-snippets' ) ) : - ( $snippet->active ? __( 'Deactivate', 'code-snippets' ) : __( 'Activate', 'code-snippets' ) ); - break; - } - - if ( $snippet->shared_network ) { - $action .= '-shared'; - } - - return $action && $label - ? sprintf( - '  ', - esc_attr( $class ), - esc_url( $this->get_action_link( $action, $snippet ) ), - esc_attr( $label ) - ) - : ''; - } - - /** - * Build the content of the snippet name column - * - * @param Snippet $snippet The snippet being used for the current row. - * - * @return string The content of the column to output. - */ - protected function column_name( Snippet $snippet ): string { - - $row_actions = $this->row_actions( - $this->get_snippet_action_links( $snippet ), - apply_filters( 'code_snippets/list_table/row_actions_always_visible', true ) - ); - - $out = esc_html( $snippet->display_name ); - $user_can_manage_network = current_user_can( code_snippets()->get_network_cap_name() ); - - // Add a link to the snippet if it isn't an unreadable network-only snippet and isn't trashed. - if ( ! $snippet->is_trashed() && ( $this->is_network || ! $snippet->network || $user_can_manage_network ) ) { - $out = sprintf( - '%s', - esc_attr( code_snippets()->get_snippet_edit_url( $snippet->id, $snippet->network ? 'network' : 'admin' ) ), - $out - ); - } else { - $out = sprintf( '%s', $out ); - } - - $out = apply_filters( 'code_snippets/list_table/column_name', $out, $snippet ); - return $out . $row_actions; - } - - /** - * Handles the checkbox column output. - * - * @param Snippet $item The snippet being used for the current row. - * - * @return string The column content to be printed. - */ - protected function column_cb( $item ): string { - $out = sprintf( - '', - $item->shared_network ? 'shared_ids' : 'ids', - $item->id - ); - - return apply_filters( 'code_snippets/list_table/column_cb', $out, $item ); - } - - /** - * Handles the tags column output. - * - * @param Snippet $snippet The snippet being used for the current row. - * - * @return string The column output. - */ - protected function column_tags( Snippet $snippet ): string { - - // Return now if there are no tags. - if ( empty( $snippet->tags ) ) { - return ''; - } - - $out = array(); - - // Loop through the tags and create a link for each one. - foreach ( $snippet->tags as $tag ) { - $out[] = sprintf( - '%s', - esc_url( add_query_arg( 'tag', esc_attr( $tag ) ) ), - esc_html( $tag ) - ); - } - - return join( ', ', $out ); - } - - /** - * Handles the priority column output. - * - * @param Snippet $snippet The snippet being used for the current row. - * - * @return string The column output. - */ - protected function column_priority( Snippet $snippet ): string { - return sprintf( '', $snippet->priority ); - } - - /** - * Define the column headers for the table - * - * @return array The column headers, ID paired with label - */ - public function get_columns(): array { - $columns = array( - 'cb' => '', - 'activate' => '', - 'name' => __( 'Name', 'code-snippets' ), - 'type' => __( 'Type', 'code-snippets' ), - 'description' => __( 'Description', 'code-snippets' ), - 'tags' => __( 'Tags', 'code-snippets' ), - 'date' => __( 'Modified', 'code-snippets' ), - 'priority' => __( 'Priority', 'code-snippets' ), - 'id' => __( 'ID', 'code-snippets' ), - ); - - if ( ! get_setting( 'general', 'enable_description' ) ) { - unset( $columns['description'] ); - } - - if ( ! get_setting( 'general', 'enable_tags' ) ) { - unset( $columns['tags'] ); - } - - return apply_filters( 'code_snippets/list_table/columns', $columns ); - } - - /** - * Define the columns that can be sorted. The format is: - * 'internal-name' => 'orderby' - * or - * 'internal-name' => array( 'orderby', true ) - * - * The second format will make the initial sorting order be descending. - * - * @return array> The IDs of the columns that can be sorted - */ - public function get_sortable_columns(): array { - $sortable_columns = [ - 'id' => [ 'id', true ], - 'name' => 'name', - 'type' => [ 'type', true ], - 'date' => [ 'modified', true ], - 'priority' => [ 'priority', true ], - ]; - - return apply_filters( 'code_snippets/list_table/sortable_columns', $sortable_columns ); - } - - /** - * Define the bulk actions to include in the drop-down menus - * - * @return array An array of menu items with the ID paired to the label - */ - public function get_bulk_actions(): array { - global $status; - - if ( 'trashed' === $status ) { - $actions = [ - 'restore-selected' => __( 'Restore', 'code-snippets' ), - 'delete-permanently-selected' => __( 'Delete Permanently', 'code-snippets' ), - ]; - } else { - $actions = [ - 'activate-selected' => $this->is_network ? __( 'Network Activate', 'code-snippets' ) : __( 'Activate', 'code-snippets' ), - 'deactivate-selected' => $this->is_network ? __( 'Network Deactivate', 'code-snippets' ) : __( 'Deactivate', 'code-snippets' ), - 'clone-selected' => __( 'Clone', 'code-snippets' ), - 'download-selected' => __( 'Export Code', 'code-snippets' ), - 'export-selected' => __( 'Export', 'code-snippets' ), - 'delete-selected' => __( 'Move to Trash', 'code-snippets' ), - ]; - } - - return apply_filters( 'code_snippets/list_table/bulk_actions', $actions ); - } - - /** - * Retrieve the classes for the table - * - * We override this in order to add 'snippets' as a class for custom styling - * - * @return array The classes to include on the table element - */ - public function get_table_classes(): array { - $classes = array( 'widefat', $this->_args['plural'] ); - - return apply_filters( 'code_snippets/list_table/table_classes', $classes ); - } - - /** - * Retrieve the 'views' of the table - * - * Example: active, inactive, recently active - * - * @return array A list of the view labels linked to the view - */ - public function get_views(): array { - global $totals, $status; - $status_links = parent::get_views(); - - // Loop through the view counts. - foreach ( $totals as $type => $count ) { - if ( ! $count ) { - continue; - } - - switch ( $type ) { - case 'all': - // translators: %s: total number of snippets. - $template = _n( - 'All (%s)', - 'All (%s)', - $count, - 'code-snippets' - ); - break; - - case 'active': - // translators: %s: total number of active snippets. - $template = _n( - 'Active (%s)', - 'Active (%s)', - $count, - 'code-snippets' - ); - break; - - case 'inactive': - // translators: %s: total number of inactive snippets. - $template = _n( - 'Inactive (%s)', - 'Inactive (%s)', - $count, - 'code-snippets' - ); - break; - - case 'recently_activated': - // translators: %s: total number of recently activated snippets. - $template = _n( - 'Recently Active (%s)', - 'Recently Active (%s)', - $count, - 'code-snippets' - ); - break; - - case 'shared_network': - if ( ! is_multisite() ) { - continue 2; - } - - // translators: %s: Websites count. - $shared_label_template = $this->is_network - ? _n_noop( - 'Shared with Subsites (%s)', - 'Shared with Subsites (%s)', - 'code-snippets' - ) - : _n_noop( - 'Network Snippets (%s)', - 'Network Snippets (%s)', - 'code-snippets' - ); - - $template = translate_nooped_plural( $shared_label_template, $count, 'code-snippets' ); - break; - - case 'trashed': - // translators: %s: total number of trashed snippets. - $template = _n( - 'Trashed (%s)', - 'Trashed (%s)', - $count, - 'code-snippets' - ); - break; - - default: - continue 2; - } - - $url = esc_url( add_query_arg( 'status', $type ) ); - $class = $type === $status ? ' class="current"' : ''; - $text = sprintf( $template, number_format_i18n( $count ) ); - - $status_links[ $type ] = sprintf( '%s', $url, $class, $text ); - } - - return apply_filters( 'code_snippets/list_table/views', $status_links ); - } - - /** - * Gets the tags of the snippets currently being viewed in the table - * - * @since 2.0 - */ - public function get_current_tags() { - global $snippets, $status; - - // If we're not viewing a snippets table, get all used tags instead. - if ( ! isset( $snippets, $status ) ) { - $tags = get_all_snippet_tags(); - } else { - $tags = array(); - - // Merge all tags into a single array. - foreach ( $snippets[ $status ] as $snippet ) { - $tags = array_merge( $snippet->tags, $tags ); - } - - // Remove duplicate tags. - $tags = array_unique( $tags ); - } - - sort( $tags ); - - return $tags; - } - - /** - * Add filters and extra actions above and below the table - * - * @param string $which Whether the actions are displayed on the before (true) or after (false) the table. - */ - public function extra_tablenav( $which ) { - /** - * Status global. - * - * @var string $status - */ - global $status; - - if ( 'top' === $which ) { - - // Tags dropdown filter. - $tags = $this->get_current_tags(); - - if ( count( $tags ) ) { - $query = isset( $_GET['tag'] ) ? sanitize_text_field( wp_unslash( $_GET['tag'] ) ) : ''; - - echo '
'; - echo ''; - - submit_button( __( 'Filter', 'code-snippets' ), 'button', 'filter_action', false ); - echo '
'; - } - } - - echo '
'; - - if ( 'recently_activated' === $status ) { - submit_button( __( 'Clear List', 'code-snippets' ), 'secondary', 'clear-recent-list', false ); - } - - do_action( 'code_snippets/list_table/actions', $which ); - - echo '
'; - } - - /** - * Output form fields needed to preserve important - * query vars over form submissions - * - * @param string $context The context in which the fields are being outputted. - */ - public static function required_form_fields( string $context = 'main' ) { - $vars = apply_filters( - 'code_snippets/list_table/required_form_fields', - array( 'page', 's', 'status', 'paged', 'tag' ), - $context - ); - - if ( 'search_box' === $context ) { - // Remove the 's' var if we're doing this for the search box. - $vars = array_diff( $vars, array( 's' ) ); - } - - foreach ( $vars as $var ) { - if ( ! empty( $_REQUEST[ $var ] ) ) { - $value = sanitize_text_field( wp_unslash( $_REQUEST[ $var ] ) ); - printf( '', esc_attr( $var ), esc_attr( $value ) ); - echo "\n"; - } - } - - do_action( 'code_snippets/list_table/print_required_form_fields', $context ); - } - - /** - * Perform an action on a single snippet. - * - * @param int $id Snippet ID. - * @param string $action Action to perform. - * - * @return bool|string Result of performing action - */ - private function perform_action( int $id, string $action ) { - switch ( $action ) { - - case 'activate': - activate_snippet( $id, $this->is_network ); - return 'activated'; - - case 'deactivate': - deactivate_snippet( $id, $this->is_network ); - return 'deactivated'; - - case 'run-once': - $this->perform_action( $id, 'activate' ); - return 'executed'; - - case 'run-once-shared': - $this->perform_action( $id, 'activate-shared' ); - return 'executed'; - - case 'activate-shared': - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - - if ( ! in_array( $id, $active_shared_snippets, true ) ) { - $active_shared_snippets[] = $id; - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - - return 'activated'; - - case 'deactivate-shared': - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - update_option( 'active_shared_network_snippets', array_diff( $active_shared_snippets, array( $id ) ) ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - return 'deactivated'; - - case 'clone': - $this->clone_snippets( [ $id ] ); - return 'cloned'; - - case 'delete': - trash_snippet( $id, $this->is_network ); - return 'deleted'; - - case 'restore': - restore_snippet( $id, $this->is_network ); - return 'restored'; - - case 'delete_permanently': - delete_snippet( $id, $this->is_network ); - return 'deleted_permanently'; - - case 'export': - $export = new Export_Attachment( [ $id ], $this->is_network ); - $export->download_snippets_json(); - break; - - case 'download': - $export = new Export_Attachment( [ $id ], $this->is_network ); - $export->download_snippets_code(); - break; - } - - return false; - } - - /** - * Processes actions requested by the user. - * - * @return void - */ - public function process_requested_actions() { - - // Clear the recent snippets list if requested to do so. - if ( isset( $_POST['clear-recent-list'] ) ) { - check_admin_referer( 'bulk-' . $this->_args['plural'] ); - - if ( $this->is_network ) { - update_site_option( 'recently_activated_snippets', array() ); - } else { - update_option( 'recently_activated_snippets', array() ); - } - } - - // Check if there are any single snippet actions to perform. - if ( isset( $_GET['action'], $_GET['id'] ) ) { - $id = absint( $_GET['id'] ); - $scope = isset( $_GET['scope'] ) ? sanitize_key( wp_unslash( $_GET['scope'] ) ) : ''; - - // Verify they were sent from a trusted source. - $nonce_action = 'code_snippets_manage_snippet_' . $id; - if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), $nonce_action ) ) { - wp_nonce_ays( $nonce_action ); - } - - $_SERVER['REQUEST_URI'] = remove_query_arg( array( 'action', 'id', 'scope', '_wpnonce' ) ); - - // If so, then perform the requested action and inform the user of the result. - $result = $this->perform_action( $id, sanitize_key( $_GET['action'] ) ); - - if ( $result ) { - $redirect_args = array( 'result' => $result ); - - if ( 'deleted' === $result ) { - $redirect_args['ids'] = $id; - } - - wp_safe_redirect( esc_url_raw( add_query_arg( $redirect_args ) ) ); - exit; - } - } - - if ( isset( $_GET['action'] ) && 'restore' === $_GET['action'] && isset( $_GET['ids'] ) ) { - $ids = array_map( 'intval', explode( ',', sanitize_text_field( $_GET['ids'] ) ) ); - - if ( ! empty( $ids ) ) { - check_admin_referer( 'bulk-' . $this->_args['plural'] ); - - foreach ( $ids as $id ) { - restore_snippet( $id, $this->is_network ); - } - - wp_safe_redirect( esc_url_raw( add_query_arg( 'result', 'restored' ) ) ); - exit; - } - } - - // Only continue from this point if there are bulk actions to process. - if ( ! isset( $_POST['ids'] ) && ! isset( $_POST['shared_ids'] ) ) { - return; - } - - check_admin_referer( 'bulk-' . $this->_args['plural'] ); - - $ids = isset( $_POST['ids'] ) ? array_map( 'intval', $_POST['ids'] ) : array(); - $_SERVER['REQUEST_URI'] = remove_query_arg( 'action' ); - - switch ( $this->current_action() ) { - - case 'activate-selected': - activate_snippets( $ids ); - - // Process the shared network snippets. - if ( isset( $_POST['shared_ids'] ) && is_multisite() && ! $this->is_network ) { - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - - foreach ( array_map( 'intval', $_POST['shared_ids'] ) as $id ) { - if ( ! in_array( $id, $active_shared_snippets, true ) ) { - $active_shared_snippets[] = $id; - } - } - - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - - $result = 'activated-multi'; - break; - - case 'deactivate-selected': - foreach ( $ids as $id ) { - deactivate_snippet( $id, $this->is_network ); - } - - // Process the shared network snippets. - if ( isset( $_POST['shared_ids'] ) && is_multisite() && ! $this->is_network ) { - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - $active_shared_snippets = ( '' === $active_shared_snippets ) ? array() : $active_shared_snippets; - $active_shared_snippets = array_diff( $active_shared_snippets, array_map( 'intval', $_POST['shared_ids'] ) ); - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - - $result = 'deactivated-multi'; - break; - - case 'export-selected': - $export = new Export_Attachment( $ids, $this->is_network ); - $export->download_snippets_json(); - break; - - case 'download-selected': - $export = new Export_Attachment( $ids, $this->is_network ); - $export->download_snippets_code(); - break; - - case 'clone-selected': - $this->clone_snippets( $ids ); - $result = 'cloned-multi'; - break; - - case 'delete-selected': - foreach ( $ids as $id ) { - trash_snippet( $id, $this->is_network ); - } - $result = 'deleted-multi'; - break; - - case 'restore-selected': - foreach ( $ids as $id ) { - restore_snippet( $id, $this->is_network ); - } - $result = 'restored-multi'; - break; - - case 'delete-permanently-selected': - foreach ( $ids as $id ) { - delete_snippet( $id, $this->is_network ); - } - $result = 'deleted-permanently-multi'; - break; - } - - if ( isset( $result ) ) { - $redirect_args = array( 'result' => $result ); - - // Add snippet IDs for undo functionality on bulk delete - if ( 'deleted-multi' === $result && ! empty( $ids ) ) { - $redirect_args['ids'] = implode( ',', $ids ); - } - - wp_safe_redirect( esc_url_raw( add_query_arg( $redirect_args ) ) ); - exit; - } - } - - /** - * Message to display if no snippets are found. - * - * @return void - */ - public function no_items() { - - if ( ! empty( $GLOBALS['s'] ) || ! empty( $_GET['tag'] ) ) { - esc_html_e( 'No snippets were found matching the current search query. Please enter a new query or use the "Clear Filters" button above.', 'code-snippets' ); - - } else { - $add_url = code_snippets()->get_menu_url( 'add' ); - - if ( empty( $_GET['type'] ) ) { - esc_html_e( "It looks like you don't have any snippets.", 'code-snippets' ); - } else { - esc_html_e( "It looks like you don't have any snippets of this type.", 'code-snippets' ); - $add_url = add_query_arg( 'type', sanitize_key( wp_unslash( $_GET['type'] ) ), $add_url ); - } - - printf( - ' %s', - esc_url( $add_url ), - esc_html__( 'Perhaps you would like to add a new one?', 'code-snippets' ) - ); - } - } - - /** - * Fetch all shared network snippets for the current site. - * - * @param array $all_snippets List of snippets to merge with. - * - * @return array Updated list of snippets. - */ - private function fetch_shared_network_snippets( array $all_snippets ): array { - if ( ! is_multisite() ) { - return $all_snippets; - } - - $shared_ids = get_site_option( 'shared_network_snippets' ); - - if ( ! $shared_ids || ! is_array( $shared_ids ) ) { - return $all_snippets; - } - - if ( $this->is_network ) { - // Mark shared network snippets on the network admin page. - foreach ( $all_snippets as $snippet ) { - if ( in_array( $snippet->id, $shared_ids, true ) ) { - $snippet->shared_network = true; - $snippet->active = false; - } - } - } else { - // Fetch shared network snippets for subsites. - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - $shared_snippets = get_snippets( $shared_ids, true ); - - foreach ( $shared_snippets as $snippet ) { - $snippet->shared_network = true; - $snippet->active = in_array( $snippet->id, $active_shared_snippets, true ); - } - - $all_snippets = array_merge( $all_snippets, $shared_snippets ); - } - - return $all_snippets; - } - - /** - * Prepares the items to later display in the table. - * Should run before any headers are sent. - * - * @phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - * - * @return void - */ - public function prepare_items() { - /** - * Global variables. - * - * @var string $status Current status view. - * @var array $snippets List of snippets for views. - * @var array $totals List of total items for views. - * @var string $s Current search term. - */ - global $status, $snippets, $totals, $s; - - wp_reset_vars( array( 'orderby', 'order', 's' ) ); - - // Redirect tag filter from POST to GET. - if ( isset( $_POST['filter_action'] ) ) { - $location = empty( $_POST['tag'] ) ? - remove_query_arg( 'tag' ) : - add_query_arg( 'tag', sanitize_text_field( wp_unslash( $_POST['tag'] ) ) ); - wp_safe_redirect( esc_url_raw( $location ) ); - exit; - } - - $this->process_requested_actions(); - $snippets = array_fill_keys( $this->statuses, array() ); - - $all_snippets = apply_filters( 'code_snippets/list_table/get_snippets', $this->fetch_shared_network_snippets( get_snippets() ) ); - - // Separate trashed snippets from the main collection - $snippets['trashed'] = array_filter( $all_snippets, function( $snippet ) { - return $snippet->is_trashed(); - }); - - // Filter out trashed snippets from the 'all' collection - $snippets['all'] = array_filter( $all_snippets, function( $snippet ) { - return ! $snippet->is_trashed(); - }); - - foreach ( $snippets['all'] as $snippet ) { - if ( $snippet->active ) { - $this->active_by_condition[ $snippet->condition_id ][] = $snippet; - } - } - - // Filter snippets by type. - $type = sanitize_key( wp_unslash( $_GET['type'] ?? '' ) ); - - if ( $type && 'all' !== $type ) { - $snippets['all'] = array_filter( - $snippets['all'], - function ( Snippet $snippet ) use ( $type ) { - return $type === $snippet->type; - } - ); - - // Filter trashed snippets by type - $snippets['trashed'] = array_filter( - $snippets['trashed'], - function ( Snippet $snippet ) use ( $type ) { - return $type === $snippet->type; - } - ); - } - - // Add scope tags to all snippets (including trashed). - foreach ( $snippets['all'] as $snippet ) { - if ( 'global' !== $snippet->scope ) { - $snippet->add_tag( $snippet->scope ); - } - } - - foreach ( $snippets['trashed'] as $snippet ) { - if ( 'global' !== $snippet->scope ) { - $snippet->add_tag( $snippet->scope ); - } - } - - // Filter snippets by tag. - if ( ! empty( $_GET['tag'] ) ) { - $snippets['all'] = array_filter( $snippets['all'], array( $this, 'tags_filter_callback' ) ); - $snippets['trashed'] = array_filter( $snippets['trashed'], array( $this, 'tags_filter_callback' ) ); - } - - // Filter snippets based on search query. - if ( $s ) { - $snippets['all'] = array_filter( $snippets['all'], array( $this, 'search_by_line_callback' ) ); - $snippets['trashed'] = array_filter( $snippets['trashed'], array( $this, 'search_by_line_callback' ) ); - } - - if ( is_multisite() ) { - $snippets['shared_network'] = array_values( - array_filter( - $snippets['all'], - static function ( Snippet $snippet ) { - return $snippet->shared_network; - } - ) - ); - } else { - $snippets['shared_network'] = array(); - } - - // Clear recently activated snippets older than a week. - $recently_activated = $this->is_network ? - get_site_option( 'recently_activated_snippets', array() ) : - get_option( 'recently_activated_snippets', array() ); - - foreach ( $recently_activated as $key => $time ) { - if ( $time + WEEK_IN_SECONDS < time() ) { - unset( $recently_activated[ $key ] ); - } - } - - $this->is_network ? - update_site_option( 'recently_activated_snippets', $recently_activated ) : - update_option( 'recently_activated_snippets', $recently_activated ); - - /** - * Filter snippets into individual sections - * - * @var Snippet $snippet - */ - foreach ( $snippets['all'] as $snippet ) { - // Skip trashed snippets (they're already in their own section) - if ( $snippet->is_trashed() ) { - continue; - } - - if ( $snippet->active || $this->is_condition_active( $snippet ) ) { - $snippets['active'][] = $snippet; - } else { - $snippets['inactive'][] = $snippet; - - // Was the snippet recently deactivated? - if ( isset( $recently_activated[ $snippet->id ] ) ) { - $snippets['recently_activated'][] = $snippet; - } - } - } - - // Count the totals for each section. - $totals = array_map( - function ( $section_snippets ) { - return count( $section_snippets ); - }, - $snippets - ); - - // If the current status is empty, default to all. - if ( empty( $snippets[ $status ] ) ) { - $status = 'all'; - } - - // Get the current data. - $data = $snippets[ $status ]; - - // Decide how many records per page to show by getting the user's setting in the Screen Options panel. - $sort_by = $this->screen->get_option( 'per_page', 'option' ); - $per_page = get_user_meta( get_current_user_id(), $sort_by, true ); - - if ( empty( $per_page ) || $per_page < 1 ) { - $per_page = $this->screen->get_option( 'per_page', 'default' ); - } - - $per_page = (int) $per_page; - - $this->set_order_vars(); - usort( $data, array( $this, 'usort_reorder_callback' ) ); - - // Determine what page the user is currently looking at. - $current_page = $this->get_pagenum(); - - // Check how many items are in the data array. - $total_items = count( $data ); - - // The WP_List_Table class does not handle pagination for us, so we need to ensure that the data is trimmed to only the current page. - $data = array_slice( $data, ( ( $current_page - 1 ) * $per_page ), $per_page ); - - // Now we can add our *sorted* data to the 'items' property, where it can be used by the rest of the class. - $this->items = $data; - - // We register our pagination options and calculations. - $this->set_pagination_args( - [ - 'total_items' => $total_items, // Calculate the total number of items. - 'per_page' => $per_page, // Determine how many items to show on a page. - 'total_pages' => ceil( $total_items / $per_page ), // Calculate the total number of pages. - ] - ); - } - - /** - * Determine the sort ordering for two pieces of data. - * - * @param mixed $a_data First piece of data. - * @param mixed $b_data Second piece of data. - * - * @return int Returns -1 if $a_data is less than $b_data; 0 if they are equal; 1 otherwise - * @ignore - */ - private function get_sort_direction( $a_data, $b_data ) { - - // If the data is numeric, then calculate the ordering directly. - if ( is_numeric( $a_data ) && is_numeric( $b_data ) ) { - return $a_data - $b_data; - } - - // If only one of the data points is empty, then place it before the one which is not. - if ( empty( $a_data ) xor empty( $b_data ) ) { - return empty( $a_data ) ? 1 : -1; - } - - // Sort using the default string sort order if possible. - if ( is_string( $a_data ) && is_string( $b_data ) ) { - return strcasecmp( $a_data, $b_data ); - } - - // Otherwise, use basic comparison operators. - return $a_data === $b_data ? 0 : ( $a_data < $b_data ? -1 : 1 ); - } - - /** - * Set the $order_by and $order_dir class variables. - */ - private function set_order_vars() { - $order = Settings\get_setting( 'general', 'list_order' ); - - // set the order by based on the query variable, if set. - if ( ! empty( $_REQUEST['orderby'] ) ) { - $this->order_by = sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ); - } else { - // otherwise, fetch the order from the setting, ensuring it is valid. - $valid_fields = [ 'id', 'name', 'type', 'modified', 'priority' ]; - $order_parts = explode( '-', $order, 2 ); - - $this->order_by = in_array( $order_parts[0], $valid_fields, true ) ? $order_parts[0] : - apply_filters( 'code_snippets/list_table/default_orderby', 'priority' ); - } - - // set the order dir based on the query variable, if set. - if ( ! empty( $_REQUEST['order'] ) ) { - $this->order_dir = sanitize_key( wp_unslash( $_REQUEST['order'] ) ); - } elseif ( '-desc' === substr( $order, -5 ) ) { - $this->order_dir = 'desc'; - } elseif ( '-asc' === substr( $order, -4 ) ) { - $this->order_dir = 'asc'; - } else { - $this->order_dir = apply_filters( 'code_snippets/list_table/default_order', 'asc' ); - } - } - - /** - * Callback for usort() used to sort snippets - * - * @param Snippet $a The first snippet to compare. - * @param Snippet $b The second snippet to compare. - * - * @return int The sort order. - * @ignore - */ - private function usort_reorder_callback( Snippet $a, Snippet $b ) { - $orderby = $this->order_by; - $result = $this->get_sort_direction( $a->$orderby, $b->$orderby ); - - if ( 0 === $result && 'id' !== $orderby ) { - $result = $this->get_sort_direction( $a->id, $b->id ); - } - - // Apply the sort direction to the calculated order. - return ( 'asc' === $this->order_dir ) ? $result : -$result; - } - - /** - * Callback for search function - * - * @param Snippet $snippet The snippet being filtered. - * - * @return bool The result of the filter - * @ignore - */ - private function search_callback( Snippet $snippet ): bool { - global $s; - - $query = sanitize_text_field( wp_unslash( $s ) ); - $fields = [ 'name', 'desc', 'code', 'tags_list' ]; - - foreach ( $fields as $field ) { - if ( false !== stripos( $snippet->$field, $query ) ) { - return true; - } - } - - return false; - } - - /** - * Callback for search function - * - * @param Snippet $snippet The snippet being filtered. - * - * @return bool The result of the filter - * @ignore - */ - private function search_by_line_callback( Snippet $snippet ): bool { - global $s; - static $line_num; - - if ( is_null( $line_num ) ) { - - if ( preg_match( '/@line:(?P\d+)/', $s, $matches ) ) { - $s = trim( str_replace( $matches[0], '', $s ) ); - $line_num = (int) $matches['line'] - 1; - } else { - $line_num = -1; - } - } - - if ( $line_num < 0 ) { - return $this->search_callback( $snippet ); - } - - $code_lines = explode( "\n", $snippet->code ); - - return isset( $code_lines[ $line_num ] ) && false !== stripos( $code_lines[ $line_num ], $s ); - } - - /** - * Callback for filtering snippets by tag. - * - * @param Snippet $snippet The snippet being filtered. - * - * @return bool The result of the filter. - * @ignore - */ - private function tags_filter_callback( Snippet $snippet ): bool { - $tags = isset( $_GET['tag'] ) ? - explode( ',', sanitize_text_field( wp_unslash( $_GET['tag'] ) ) ) : - array(); - - foreach ( $tags as $tag ) { - if ( in_array( $tag, $snippet->tags, true ) ) { - return true; - } - } - - return false; - } - - /** - * Display a notice showing the current search terms - * - * @since 1.7 - */ - public function search_notice() { - if ( ! empty( $_REQUEST['s'] ) || ! empty( $_GET['tag'] ) ) { - - echo '' . esc_html__( 'Search results', 'code-snippets' ); - - if ( ! empty( $_REQUEST['s'] ) ) { - $s = sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ); - - if ( preg_match( '/@line:(?P\d+)/', $s, $matches ) ) { - - // translators: 1: search query, 2: line number. - $text = __( ' for “%1$s” on line %2$d', 'code-snippets' ); - printf( - esc_html( $text ), - esc_html( trim( str_replace( $matches[0], '', $s ) ) ), - intval( $matches['line'] ) - ); - - } else { - // translators: %s: search query. - echo esc_html( sprintf( __( ' for “%s”', 'code-snippets' ), $s ) ); - } - } - - if ( ! empty( $_GET['tag'] ) ) { - $tag = sanitize_text_field( wp_unslash( $_GET['tag'] ) ); - // translators: %s: tag name. - echo esc_html( sprintf( __( ' in tag “%s”', 'code-snippets' ), $tag ) ); - } - - echo ''; - - // translators: 1: link URL, 2: link text. - printf( - ' %s', - esc_url( remove_query_arg( array( 's', 'tag', 'cloud_search' ) ) ), - esc_html__( 'Clear Filters', 'code-snippets' ) - ); - } - } - - /** - * Outputs content for a single row of the table - * - * @param Snippet $item The snippet being used for the current row. - */ - public function single_row( $item ) { - $status = $item->active || $this->is_condition_active( $item ) ? 'active' : 'inactive'; - $row_class = "snippet $status-snippet $item->type-snippet $item->scope-scope"; - - if ( $item->shared_network ) { - $row_class .= ' shared-network-snippet'; - } - - printf( '', esc_attr( $row_class ), esc_attr( $item->scope ) ); - $this->single_row_columns( $item ); - echo ''; - } - - /** - * Clone a selection of snippets - * - * @param array $ids List of snippet IDs. - */ - private function clone_snippets( array $ids ) { - $snippets = get_snippets( $ids, $this->is_network ); - - foreach ( $snippets as $snippet ) { - $snippet->id = 0; - $snippet->active = false; - $snippet->cloud_id = ''; - - // translators: %s: snippet title. - $snippet->name = sprintf( __( '%s [CLONE]', 'code-snippets' ), $snippet->name ); - $snippet = apply_filters( 'code_snippets/list_table/clone_snippet', $snippet ); - - save_snippet( $snippet ); - } - } -} diff --git a/src/php/cloud/class-cloud-search-list-table.php b/src/php/cloud/class-cloud-search-list-table.php deleted file mode 100644 index af1051c7b..000000000 --- a/src/php/cloud/class-cloud-search-list-table.php +++ /dev/null @@ -1,343 +0,0 @@ - 'cloud-snippet', - 'plural' => 'cloud-snippets', - 'ajax' => false, - ] - ); - - // Strip the result query arg from the URL. - $_SERVER['REQUEST_URI'] = remove_query_arg( [ 'result' ] ); - - $this->cloud_api = code_snippets()->cloud_api; - } - - /** - * Prepare items for the table. - * - * @return void - */ - public function prepare_items() { - $per_page = $this->get_items_per_page( 'snippets_per_page', 10 ); - $user_per_page = (int) get_user_option( 'snippets_per_page', get_current_user_id() ); - if ( $user_per_page > 0 ) { - $per_page = $user_per_page; - } - - // Fetch snippets, passing a 0-based page index to the Cloud API (WP list tables are 1-based). - $page_index = max( 0, $this->get_pagenum() - 1 ); - $this->cloud_snippets = $this->fetch_snippets( $per_page, $page_index ); - $this->items = $this->cloud_snippets->snippets; - - $this->process_actions(); - - $this->set_pagination_args( - [ - 'per_page' => $per_page, - 'total_items' => $this->cloud_snippets->total_snippets, - 'total_pages' => $this->cloud_snippets->total_pages, - ] - ); - } - - /** - * Process any actions that have been submitted, such as downloading cloud snippets to the local database. - * - * @return void - */ - public function process_actions() { - $_SERVER['REQUEST_URI'] = remove_query_arg( - [ 'action', 'snippet', '_wpnonce', 'source', 'cloud-bundle-run', 'cloud-bundle-show', 'bundle_share_name', 'cloud_bundles' ] - ); - - // Check request is coming from the cloud search page. - if ( isset( $_REQUEST['type'] ) && 'cloud_search' === $_REQUEST['type'] ) { - if ( isset( $_REQUEST['action'], $_REQUEST['snippet'], $_REQUEST['source'] ) ) { - cloud_lts_process_download_action( - sanitize_key( wp_unslash( $_REQUEST['action'] ) ), - sanitize_key( wp_unslash( $_REQUEST['source'] ) ), - sanitize_key( wp_unslash( $_REQUEST['snippet'] ) ), - ); - } - } - } - - /** - * Output table rows. - * - * @return void - */ - public function display_rows() { - $status_descriptions = [ - Cloud_API::STATUS_PUBLIC => - __( 'Snippet has passed basic review.', 'code-snippets' ), - Cloud_API::STATUS_AI_VERIFIED => - __( 'Snippet has been tested by our AI bot.', 'code-snippets' ), - Cloud_API::STATUS_UNVERIFIED => - __( 'Snippet has not undergone any review yet.', 'code-snippets' ), - ]; - - /** - * The current table item. - * - * @var $item Cloud_Snippet - */ - foreach ( $this->items as $item ) { - ?> -
- -
-
-

- tags ) > 0 ? strtolower( esc_attr( $item->tags[0] ) ) : 'general'; - - printf( - '%s', - esc_url( "https://codesnippets.cloud/images/plugin-icons/$category-logo.png" ), - esc_attr( $category ) - ); - - $link = code_snippets()->cloud_api->get_link_for_cloud_snippet( $item ); - - if ( $link ) { - printf( '', esc_url( code_snippets()->get_snippet_edit_url( $link->local_id ) ) ); - } else { - printf( - '', - '#TB_inline?&width=700&height=500&inlineId=show-code-preview', - esc_attr__( 'Preview this snippet', 'code-snippets' ), - esc_attr( $item->id ), - esc_attr( Cloud_API::get_type_from_scope( $item->scope ) ) - ); - } - - echo esc_html( $item->name ); - - - - echo ''; - ?> -

-
    - -
-
-
-

process_description( $item->description ) ); ?>

-

- - %s', - esc_html__( 'Codevault:', 'code-snippets' ), - esc_url( sprintf( 'https://codesnippets.cloud/codevault/%s', $item->codevault ) ), - esc_html( $item->codevault ) - ); - ?> - -

-
-
-
-
-
-
- cloud_api->get_status_label( $item->status ) ); - - if ( isset( $status_descriptions[ $item->status ] ) ) { - echo ''; - printf( '
%s
', esc_html( $status_descriptions[ $item->status ] ) ); - } - ?> -
-
- -
- - - - -
- -
- - updated ) ) ) ); - ?> -
-
-
-
- 150 ? substr( $description, 0, 150 ) . '…' : $description; - } - - /** - * Text displayed when no snippet data is available. - * - * @return void - */ - public function no_items() { - if ( ! empty( $_REQUEST['cloud_search'] ) && count( $this->cloud_snippets->snippets ) < 1 ) { - echo '

', - esc_html__( 'No snippets or codevault could be found with that search term. Please try again.', 'code-snippets' ), - '

'; - } else { - echo '

', esc_html__( 'Please enter a term to start searching code snippets in the cloud.', 'code-snippets' ), '

'; - } - } - - /** - * Fetch the snippets used to populate the table. - * - * @return Cloud_Snippets - */ - public function fetch_snippets( int $per_page = 10, int $page_index = 0 ): Cloud_Snippets { - // Check if search term has been entered. - if ( isset( $_REQUEST['type'], $_REQUEST['cloud_search'], $_REQUEST['cloud_select'] ) && - 'cloud_search' === sanitize_key( wp_unslash( $_REQUEST['type'] ) ) - ) { - // If we have a search query, then send a search request to cloud server API search endpoint. - $search_query = sanitize_text_field( wp_unslash( $_REQUEST['cloud_search'] ) ); - $search_by = sanitize_text_field( wp_unslash( $_REQUEST['cloud_select'] ) ); - - // Pass the provided 0-based page index to the API. - return Cloud_API::fetch_search_results( $search_by, $search_query, $page_index ); - } - - // If no search results, then return empty object. - return new Cloud_Snippets(); - } - - /** - * Gets the current search result page number. - * - * @return integer - */ - public function get_pagenum(): int { - $page = isset( $_REQUEST['search_page'] ) ? absint( $_REQUEST['search_page'] ) : 0; - - if ( isset( $this->_pagination_args['total_pages'] ) && $page > $this->_pagination_args['total_pages'] ) { - $page = $this->_pagination_args['total_pages']; - } - - return max( 1, $page ); - } - - /** - * Display the table. - * - * @return void - */ - public function display() { - Cloud_API::render_cloud_snippet_thickbox(); - parent::display(); - } - - /** - * Displays the pagination. - * - * @param string $which Context where the pagination will be displayed. - * - * @return void - */ - protected function pagination( $which ) { - if ( empty( $this->_pagination_args ) ) { - return; - } - - $total_items = $this->_pagination_args['total_items'] ?? 0; - $total_pages = $this->_pagination_args['total_pages'] ?? 0; - // get_pagenum already returns a 1-based page number used for display. - $pagenum_display = $this->get_pagenum(); - - if ( 'top' === $which && $total_pages >= 1 ) { - $this->screen->render_screen_reader_content( 'heading_pagination' ); - } - - $paginate = cloud_lts_pagination( $which, 'search', $total_items, $total_pages, $pagenum_display ); - $page_class = $paginate['page_class']; - $output = $paginate['output']; - - $this->_pagination = "
$output
"; - - echo wp_kses_post( $this->_pagination ); - } -} diff --git a/src/php/cloud/list-table-shared-ops.php b/src/php/cloud/list-table-shared-ops.php deleted file mode 100644 index 7126f28e4..000000000 --- a/src/php/cloud/list-table-shared-ops.php +++ /dev/null @@ -1,267 +0,0 @@ -', - esc_attr( $column_name ), - esc_attr( $snippet->id ), - esc_attr( $column_name ), - esc_attr( $snippet->$column_name ) - ); -} - -/** - * Display a hidden input field for a certain column and snippet value. - * - * @param string $column_name Column name. - * @param Cloud_Snippet $snippet Column item. - * - * @return string HTML - */ -function cloud_lts_build_column_hidden_input( string $column_name, Cloud_Snippet $snippet ): string { - return sprintf( - '', - esc_attr( $column_name ), - esc_attr( $snippet->id ), - esc_attr( $column_name ), - esc_attr( $snippet->$column_name ) - ); -} - -/** - * Process the download snippet action - * - * @param string $action Action - 'download' or 'update'. - * @param string $source Source - 'search' or 'cloud'. - * @param string $snippet Snippet ID. - * - * @return void - */ -function cloud_lts_process_download_action( string $action, string $source, string $snippet ) { - if ( 'download' === $action || 'update' === $action ) { - $result = code_snippets()->cloud_api->download_or_update_snippet( $snippet, $source, $action ); - - if ( $result['success'] ) { - $redirect_uri = $result['snippet_id'] ? - code_snippets()->get_snippet_edit_url( (int) $result['snippet_id'] ) : - add_query_arg( 'result', $result['action'] ); - - wp_safe_redirect( esc_url_raw( $redirect_uri ) ); - exit; - } - } -} - -/** - * Build action links for snippet. - * - * @param Cloud_Snippet $cloud_snippet Snippet/Column item. - * @param string $source Source - 'search' or 'codevault'. - * - * @return string Action link HTML. - */ -function cloud_lts_build_action_links( Cloud_Snippet $cloud_snippet, string $source ): string { - $lang = Cloud_API::get_type_from_scope( $cloud_snippet->scope ); - $link = code_snippets()->cloud_api->get_link_for_cloud_snippet( $cloud_snippet ); - $is_licensed = code_snippets()->licensing->is_licensed(); - $download = $is_licensed || ! in_array( $lang, [ 'css', 'js' ], true ); - - if ( $link ) { - if ( $is_licensed && $link->update_available ) { - $update_url = add_query_arg( - [ - 'action' => 'update', - 'snippet' => $cloud_snippet->id, - 'source' => $source, - ] - ); - return sprintf( - '
  • %s
  • ', - esc_url( $update_url ), - esc_html__( 'Update Available', 'code-snippets' ) - ); - } else { - return sprintf( - '
  • %s
  • ', - esc_url( code_snippets()->get_snippet_edit_url( $link->local_id ) ), - esc_html__( 'View', 'code-snippets' ) - ); - } - } - - if ( $download ) { - $download_query = [ - 'action' => 'download', - 'snippet' => $cloud_snippet->id, - 'source' => $source, - ]; - - // Preserve current cloud page if present so downstream handlers receive pagination context. - if ( isset( $_REQUEST['cloud_page'] ) ) { - $download_query['cloud_page'] = (int) wp_unslash( $_REQUEST['cloud_page'] ); - } - - $download_url = add_query_arg( $download_query ); - - $download_button = sprintf( - '
  • %s
  • ', - esc_url( $download_url ), - esc_html__( 'Download', 'code-snippets' ) - ); - } else { - $download_button = sprintf( - '
  • %s %s
  • ', - 'button button-primary button-disabled tooltip tooltip-block tooltip-end', - esc_html__( 'Download', 'code-snippets' ), - esc_html__( 'This snippet type is only available in Code Snippets Pro', 'code-snippets' ) - ); - } - - $preview_button = sprintf( - '
  • %s
  • ', - '#TB_inline?&width=700&height=500&inlineId=show-code-preview', - esc_attr( $cloud_snippet->name ), - 'cloud-snippet-preview thickbox button', - esc_attr( $cloud_snippet->id ), - esc_attr( $lang ), - esc_html__( 'Preview', 'code-snippets' ) - ); - - return $download_button . $preview_button; -} - -/** - * Build the pagination functionality - * - * @param string $which Context where the pagination will be displayed. - * @param string $source Source - 'search' or 'cloud'. - * @param int $total_items Total number of items. - * @param int $total_pages Total number of pages. - * @param int $pagenum Current page number. - * - * @return array - */ -function cloud_lts_pagination( string $which, string $source, int $total_items, int $total_pages, int $pagenum ): array { - /* translators: %s: Number of items. */ - $num = sprintf( _n( '%s item', '%s items', $total_items, 'code-snippets' ), number_format_i18n( $total_items ) ); - $output = '' . $num . ''; - - $param_key = $source . '_page'; - $current = isset( $_REQUEST[ $param_key ] ) ? (int) $_REQUEST[ $param_key ] : $pagenum; - $current_url = remove_query_arg( wp_removable_query_args() ) . '#' . $source; - - $page_links = array(); - - $html_current_page = ''; - $total_pages_before = ''; - $total_pages_after = ''; - - $disable_first = false; - $disable_last = false; - $disable_prev = false; - $disable_next = false; - - if ( 1 === $current ) { - $disable_first = true; - $disable_prev = true; - } - - if ( $total_pages === $current ) { - $disable_last = true; - $disable_next = true; - } - - if ( $disable_first ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( remove_query_arg( $source . '_page', $current_url ) ), - esc_html__( 'First page', 'code-snippets' ) - ); - } - - if ( $disable_prev ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( add_query_arg( $source . '_page', max( 1, $current - 1 ), $current_url ) ), - esc_html__( 'Previous page', 'code-snippets' ) - ); - } - - if ( 'bottom' === $which ) { - $html_current_page = $current; - $total_pages_before = sprintf( '%s', __( 'Current page', 'code-snippets' ) ); - } - - if ( 'top' === $which ) { - $html_current_page = sprintf( - '', - __( 'Current page', 'code-snippets' ), - $source, - $current, - strlen( $total_pages ) - ); - } - - $html_total_pages = sprintf( '%s', number_format_i18n( $total_pages ) ); - - /* translators: 1: Current page, 2: Total pages. */ - $current_html = _x( '%1$s of %2$s', 'paging', 'code-snippets' ); - $page_links[] = $total_pages_before . sprintf( $current_html, $html_current_page, $html_total_pages ) . $total_pages_after; - - if ( $disable_next ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( add_query_arg( $source . '_page', min( $total_pages, $current + 1 ), $current_url ) ), - esc_html__( 'Next page', 'code-snippets' ), - '›' - ); - } - - if ( $disable_last ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( add_query_arg( $source . '_page', $total_pages, $current_url ) ), - esc_html__( 'Last page', 'code-snippets' ), - '»' - ); - } - - $pagination_links_class = 'pagination-links'; - if ( ! empty( $infinite_scroll ) ) { - $pagination_links_class .= ' hide-if-js'; - } - - $output .= "\n" . implode( "\n", $page_links ) . ''; - - $page_class = $total_pages ? '' : ' no-pages'; - - return [ - 'output' => $output, - 'page_class' => $page_class, - ]; -} diff --git a/src/php/export/class-export-attachment.php b/src/php/export/class-export-attachment.php deleted file mode 100644 index fdb1c1535..000000000 --- a/src/php/export/class-export-attachment.php +++ /dev/null @@ -1,55 +0,0 @@ -build_filename( $language ) ) ); - header( sprintf( 'Content-Type: %s; charset=%s', sanitize_mime_type( $mime_type ), get_bloginfo( 'charset' ) ) ); - } - - /** - * Export snippets in JSON format as a downloadable file. - */ - public function download_snippets_json() { - $this->do_headers( 'json', 'application/json' ); - // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped - echo wp_json_encode( - $this->create_export_object(), - apply_filters( 'code_snippets/export/json_encode_options', 0 ) - ); - exit; - } - - /** - * Export snippets in their code file format. - */ - public function download_snippets_code() { - $lang = $this->snippets_list[0]->lang; - - $mime_types = [ - 'php' => 'text/php', - 'css' => 'text/css', - 'js' => 'text/javascript', - 'json' => 'application/json', - ]; - - $this->do_headers( $lang, $mime_types[ $lang ] ?? 'text/plain' ); - - // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped - echo $this->export_snippets_code( $this->snippets_list[0]->type ); - exit; - } -} diff --git a/src/php/export/class-export.php b/src/php/export/class-export.php deleted file mode 100644 index 49f466d37..000000000 --- a/src/php/export/class-export.php +++ /dev/null @@ -1,194 +0,0 @@ - $ids List of snippet IDs to export. - * @param boolean|null $network Whether to fetch snippets from local or network table. - */ - public function __construct( array $ids, ?bool $network = null ) { - $this->snippets_list = get_snippets( $ids, $network ); - } - - /** - * Build the export filename. - * - * @param string $format File format. Used for file extension. - * - * @return string - */ - public function build_filename( string $format ): string { - if ( 1 === count( $this->snippets_list ) ) { - // If there is only snippet to export, use its name instead of the site name. - $title = strtolower( $this->snippets_list[0]->name ); - } else { - // Otherwise, use the site name as set in Settings > General. - $title = strtolower( get_bloginfo( 'name' ) ); - } - - $filename = "$title.code-snippets.$format"; - return apply_filters( 'code_snippets/export/filename', $filename, $title, $this->snippets_list ); - } - - /** - * Bundle snippets together into JSON format. - * - * @return array Snippets as JSON object. - */ - public function create_export_object(): array { - $snippets = array(); - - foreach ( $this->snippets_list as $snippet ) { - $snippets[] = array_map( - function ( $value ) { - return is_string( $value ) ? - str_replace( "\r\n", "\n", $value ) : - $value; - }, - $snippet->get_modified_fields() - ); - } - - return array( - 'generator' => 'Code Snippets v' . code_snippets()->version, - 'date_created' => gmdate( 'Y-m-d H:i' ), - 'snippets' => $snippets, - ); - } - - /** - * Bundle a snippets into a PHP file. - */ - public function export_snippets_php(): string { - $result = "snippets_list as $snippet ) { - $code = trim( $snippet->code ); - - if ( ( 'php' !== $snippet->type && 'html' !== $snippet->type ) || ! $code ) { - continue; - } - - $result .= "\n/**\n * $snippet->display_name\n"; - - if ( ! empty( $snippet->desc ) ) { - // Convert description to PhpDoc. - $desc = wp_strip_all_tags( str_replace( "\n", "\n * ", $snippet->desc ) ); - $result .= " *\n * $desc\n"; - } - - $result .= " */\n"; - - if ( 'content' === $snippet->scope ) { - $shortcode_tag = apply_filters( 'code_snippets_export_shortcode_tag', "code_snippets_export_$snippet->id", $snippet ); - - $code = sprintf( - "add_shortcode( '%s', function () {\n\tob_start();\n\t?>\n\n\t%s\n\n\tsnippets_list as $snippet ) { - $condition_data = []; - - if ( ! $snippet->code || 'cond' !== $snippet->type ) { - continue; - } - - $rules = json_decode( $snippet->code, false ); - - if ( json_last_error() !== JSON_ERROR_NONE ) { - continue; - } - - foreach ( $fields_to_copy as $field ) { - if ( ! empty( $snippet->$field ) ) { - $condition_data[ $field ] = $snippet->$field; - } - } - - $condition_data['rules'] = $rules; - $conditions_data[] = $condition_data; - } - - return wp_json_encode( 1 === count( $conditions_data ) ? $conditions_data[0] : $conditions_data, JSON_PRETTY_PRINT ); - } - - /** - * Generate a downloadable CSS or JavaScript file from a list of snippets - * - * @phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped - * - * @param string|null $type Snippet type. Supports 'css' or 'js'. - */ - public function export_snippets_code( ?string $type = null ): string { - $result = ''; - - if ( ! $type ) { - $type = $this->snippets_list[0]->type; - } - - if ( 'php' === $type || 'html' === $type ) { - return $this->export_snippets_php(); - } - - if ( 'cond' === $type ) { - return $this->export_conditions_json(); - } - - foreach ( $this->snippets_list as $snippet ) { - $snippet = new Snippet( $snippet ); - - if ( $snippet->type !== $type ) { - continue; - } - - $result .= "\n/*\n"; - - if ( $snippet->name ) { - $result .= wp_strip_all_tags( $snippet->name ) . "\n\n"; - } - - if ( ! empty( $snippet->desc ) ) { - $result .= wp_strip_all_tags( $snippet->desc ) . "\n"; - } - - $result .= "*/\n\n$snippet->code\n\n"; - } - - return $result; - } -} diff --git a/src/php/flat-files/classes/class-config-repository.php b/src/php/flat-files/classes/class-config-repository.php deleted file mode 100644 index 07ab54523..000000000 --- a/src/php/flat-files/classes/class-config-repository.php +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 62055253a..000000000 --- a/src/php/flat-files/classes/class-file-system-adapter.php +++ /dev/null @@ -1,47 +0,0 @@ -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/handlers/html-snippet-handler.php b/src/php/flat-files/handlers/html-snippet-handler.php deleted file mode 100644 index d7a4446aa..000000000 --- a/src/php/flat-files/handlers/html-snippet-handler.php +++ /dev/null @@ -1,17 +0,0 @@ -\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 deleted file mode 100644 index aaa212f9b..000000000 --- a/src/php/flat-files/handlers/php-snippet-handler.php +++ /dev/null @@ -1,18 +0,0 @@ - $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/migration/Export/Export.php b/src/php/migration/Export/Export.php new file mode 100644 index 000000000..6a38e098c --- /dev/null +++ b/src/php/migration/Export/Export.php @@ -0,0 +1,72 @@ + $ids List of snippet IDs to export. + * @param bool|null $network Whether to fetch snippets from local or network table. + */ + public function __construct( array $ids, ?bool $network = null ) { + $this->snippets_list = get_snippets( $ids, $network ); + } + + /** + * Get the list of snippets to export. + * + * @return Snippet[] List of snippets to export. + */ + protected function get_snippets_list(): array { + return $this->snippets_list; + } + + /** + * Get the file extension for the export format. + * + * @return string File extension for the export format. + */ + abstract public function get_file_extension(): string; + + /** + * Build the export filename. + * + * @return string + */ + public function build_filename(): string { + if ( 1 === count( $this->snippets_list ) ) { + // If there is only snippet to export, use its name instead of the site name. + $title = strtolower( $this->snippets_list[0]->name ); + } else { + // Otherwise, use the site name as set in Settings > General. + $title = strtolower( get_bloginfo( 'name' ) ); + } + + $filename = "$title.code-snippets.{$this->get_file_extension()}"; + return apply_filters( 'code_snippets/export/filename', $filename, $title, $this->snippets_list ); + } + + /** + * Generate the export data in the specified format. + * + * @return mixed + */ + abstract public function generate_export(); +} diff --git a/src/php/migration/Export/Export_Code.php b/src/php/migration/Export/Export_Code.php new file mode 100644 index 000000000..0436dface --- /dev/null +++ b/src/php/migration/Export/Export_Code.php @@ -0,0 +1,179 @@ + $ids The IDs of the snippets to export. + * @param bool|null $network Whether to export network-wide snippets. + * @param string|null $export_type The type of snippet to export (e.g., 'php', 'html', 'css', 'js', 'cond'). + */ + public function __construct( array $ids, ?bool $network = null, ?string $export_type = null ) { + parent::__construct( $ids, $network ); + + if ( $export_type && in_array( $export_type, Snippet::get_types(), true ) ) { + $this->export_type = $export_type; + } else { + // If no export type is specified, default to the type of the first snippet. + $snippets_list = $this->get_snippets_list(); + $this->export_type = $snippets_list[0]->type; + } + } + + /** + * Get the type of snippets being exported. + * + * @return string + */ + public function get_export_type(): string { + return $this->export_type; + } + + /** + * Get the file extension for the export format. + * + * @return string + */ + public function get_file_extension(): string { + return 'cond' === $this->export_type + ? 'json' + : $this->export_type; + } + + /** + * Bundle a snippets into a PHP file. + */ + protected function export_snippets_php(): string { + $result = "get_snippets_list() as $snippet ) { + $code = trim( $snippet->code ); + + if ( ( 'php' !== $snippet->type && 'html' !== $snippet->type ) || ! $code ) { + continue; + } + + $result .= "\n/**\n * $snippet->display_name\n"; + + if ( ! empty( $snippet->desc ) ) { + // Convert description to PhpDoc. + $desc = wp_strip_all_tags( str_replace( "\n", "\n * ", $snippet->desc ) ); + $result .= " *\n * $desc\n"; + } + + $result .= " */\n"; + + if ( 'content' === $snippet->scope ) { + $shortcode_tag = apply_filters( 'code_snippets_export_shortcode_tag', "code_snippets_export_$snippet->id", $snippet ); + + $code = sprintf( + "add_shortcode( '%s', function () {\n\tob_start();\n\t?>\n\n\t%s\n\n\tget_snippets_list() as $snippet ) { + $condition_data = []; + + if ( ! $snippet->code || 'cond' !== $snippet->type ) { + continue; + } + + $rules = json_decode( $snippet->code, false ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + continue; + } + + foreach ( $fields_to_copy as $field ) { + if ( ! empty( $snippet->$field ) ) { + $condition_data[ $field ] = $snippet->$field; + } + } + + $condition_data['rules'] = $rules; + $conditions_data[] = $condition_data; + } + + return wp_json_encode( 1 === count( $conditions_data ) ? $conditions_data[0] : $conditions_data, JSON_PRETTY_PRINT ); + } + + /** + * Export snippets in a generic format, compatible with CSS or JavaScript. + */ + protected function export_snippets_css_js(): string { + $result = ''; + + foreach ( $this->get_snippets_list() as $snippet ) { + $snippet = new Snippet( $snippet ); + + if ( $this->get_export_type() !== $snippet->type ) { + continue; + } + + $result .= "\n/*\n"; + + if ( $snippet->name ) { + $result .= wp_strip_all_tags( $snippet->name ) . "\n\n"; + } + + if ( ! empty( $snippet->desc ) ) { + $result .= wp_strip_all_tags( $snippet->desc ) . "\n"; + } + + $result .= "*/\n\n$snippet->code\n\n"; + } + + return $result; + } + + /** + * Generate a downloadable code file from the exported snippets. + */ + public function generate_export(): string { + switch ( $this->get_export_type() ) { + case 'php': + case 'html': + return $this->export_snippets_php(); + + case 'cond': + return $this->export_conditions_json(); + + default: + return $this->export_snippets_css_js(); + } + } +} diff --git a/src/php/migration/Export/Export_JSON.php b/src/php/migration/Export/Export_JSON.php new file mode 100644 index 000000000..89283d515 --- /dev/null +++ b/src/php/migration/Export/Export_JSON.php @@ -0,0 +1,49 @@ + Snippets as JSON object. + */ + public function generate_export(): array { + $snippets = []; + + foreach ( $this->get_snippets_list() as $snippet ) { + $snippets[] = array_map( + function ( $value ) { + return is_string( $value ) ? + str_replace( "\r\n", "\n", $value ) : + $value; + }, + $snippet->get_modified_fields() + ); + } + + return [ + 'generator' => 'Code Snippets v' . PLUGIN_VERSION, + 'date_created' => gmdate( 'Y-m-d H:i' ), + 'snippets' => $snippets, + ]; + } +} diff --git a/src/php/export/class-import.php b/src/php/migration/Export/Import.php similarity index 91% rename from src/php/export/class-import.php rename to src/php/migration/Export/Import.php index 4c2cfb9d2..ce9b1778c 100644 --- a/src/php/export/class-import.php +++ b/src/php/migration/Export/Import.php @@ -1,8 +1,12 @@ |bool An array of imported snippet IDs on success, false on failure + * @return array|bool An array of imported snippet IDs on success, false on failure */ public function import_json() { if ( ! file_exists( $this->file ) || ! is_file( $this->file ) ) { @@ -99,7 +103,7 @@ public function import_json() { /** * Imports snippets from an XML file * - * @return array|bool An array of imported snippet IDs on success, false on failure + * @return array|bool An array of imported snippet IDs on success, false on failure */ public function import_xml() { if ( ! file_exists( $this->file ) || ! is_file( $this->file ) ) { @@ -148,7 +152,7 @@ public function import_xml() { /** * Fetch a list of existing snippets for checking duplicates. * - * @return array + * @return array */ private function fetch_existing_snippets(): array { $existing_snippets = array(); @@ -173,7 +177,7 @@ private function fetch_existing_snippets(): array { * * @param array $snippets List of snippets to save. * - * @return array IDs of imported snippets. + * @return array IDs of imported snippets. */ private function save_snippets( array $snippets ): array { $existing_snippets = $this->fetch_existing_snippets(); diff --git a/src/php/migration/importers/files/file-upload-importer.php b/src/php/migration/Importers/Files/Files_Import_Manager.php similarity index 72% rename from src/php/migration/importers/files/file-upload-importer.php rename to src/php/migration/Importers/Files/Files_Import_Manager.php index e4ec32f91..60cb8deef 100644 --- a/src/php/migration/importers/files/file-upload-importer.php +++ b/src/php/migration/Importers/Files/Files_Import_Manager.php @@ -1,15 +1,23 @@ WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'parse_uploaded_files' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - ] ); - - register_rest_route( $namespace, 'file-upload/import', [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'import_selected_snippets' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - 'args' => [ - 'snippets' => [ - 'description' => __( 'Array of snippet data to import', 'code-snippets' ), - 'type' => 'array', - 'required' => true, - ], - 'duplicate_action' => [ - 'description' => __( 'Action to take when duplicate snippets are found', 'code-snippets' ), - 'type' => 'string', - 'enum' => [ 'ignore', 'replace', 'skip' ], - 'default' => 'ignore', - ], - 'network' => [ - 'description' => __( 'Whether to import to network table', 'code-snippets' ), - 'type' => 'boolean', - 'default' => false, + register_rest_route( + $namespace, + 'file-upload/parse', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'parse_uploaded_files' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] + ); + + register_rest_route( + $namespace, + 'file-upload/import', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'import_selected_snippets' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'snippets' => [ + 'description' => __( 'Array of snippet data to import', 'code-snippets' ), + 'type' => 'array', + 'required' => true, + ], + 'duplicate_action' => [ + 'description' => __( 'Action to take when duplicate snippets are found', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'ignore', 'replace', 'skip' ], + 'default' => 'ignore', + ], + 'network' => [ + 'description' => __( 'Whether to import to network table', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], ], - ], - ] ); + ] + ); } public function parse_uploaded_files( WP_REST_Request $request ) { - // Verify nonce for security $nonce = $request->get_header( 'X-WP-Nonce' ); + if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error( 'rest_cookie_invalid_nonce', @@ -97,7 +113,7 @@ public function parse_uploaded_files( WP_REST_Request $request ) { if ( UPLOAD_ERR_OK !== $file_error ) { $errors[] = sprintf( - /* translators: %1$s: file name, %2$s: error message */ + /* translators: %1$s: file name, %2$s: error message */ __( 'Upload error for file %1$s: %2$s', 'code-snippets' ), $file_name, $this->get_upload_error_message( $file_error ) @@ -111,7 +127,7 @@ public function parse_uploaded_files( WP_REST_Request $request ) { if ( ! $this->is_valid_file_type( $extension, $mime_type ) ) { $errors[] = sprintf( - /* translators: %s: file name */ + /* translators: %s: file name */ __( 'Invalid file type for %s. Only JSON and XML files are allowed.', 'code-snippets' ), $file_name, ); @@ -122,7 +138,7 @@ public function parse_uploaded_files( WP_REST_Request $request ) { if ( is_wp_error( $snippets ) ) { $errors[] = sprintf( - /* translators: %1$s: file name, %2$s: error message */ + /* translators: %1$s: file name, %2$s: error message */ __( 'Error parsing %1$s: %2$s', 'code-snippets' ), $file_name, $snippets->get_error_message(), @@ -143,19 +159,18 @@ public function parse_uploaded_files( WP_REST_Request $request ) { ); } + /* translators: %d: number of snippets */ + $message = _n( + 'Found %d snippet ready for import.', + 'Found %d snippets ready for import.', + count( $all_snippets ), + 'code-snippets', + ); + $response = [ - 'snippets' => $all_snippets, + 'snippets' => $all_snippets, 'total_count' => count( $all_snippets ), - 'message' => sprintf( - /* translators: %d: number of snippets */ - _n( - 'Found %d snippet ready for import.', - 'Found %d snippets ready for import.', - count( $all_snippets ), - 'code-snippets', - ), - count( $all_snippets ) - ), + 'message' => sprintf( $message, count( $all_snippets ) ), ]; if ( ! empty( $errors ) ) { @@ -208,10 +223,10 @@ public function import_selected_snippets( WP_REST_Request $request ) { $imported = $this->save_snippets( $snippets, $duplicate_action, $network ); $response = [ - 'imported' => count( $imported ), + 'imported' => count( $imported ), 'imported_ids' => $imported, - 'message' => sprintf( - /* translators: %d: number of snippets */ + 'message' => sprintf( + /* translators: %d: number of snippets */ _n( 'Successfully imported %d snippet.', 'Successfully imported %d snippets.', @@ -253,7 +268,7 @@ private function parse_json_file( string $file_path, string $file_name ) { return new WP_Error( 'invalid_json', sprintf( - /* translators: %1$s: file name, %2$s: error message */ + /* translators: %1$s: file name, %2$s: error message */ __( 'Invalid JSON in file %1$s: %2$s', 'code-snippets' ), $file_name, json_last_error_msg() @@ -265,7 +280,7 @@ private function parse_json_file( string $file_path, string $file_name ) { return new WP_Error( 'no_snippets_in_file', sprintf( - /* translators: %s: file name */ + /* translators: %s: file name */ __( 'No snippets found in file %s', 'code-snippets' ), $file_name ) @@ -277,12 +292,12 @@ private function parse_json_file( string $file_path, string $file_name ) { $snippet_data['source_file'] = $file_name; $snippet_data['table_data'] = [ - 'id' => $snippet_data['id'] ?? uniqid(), - 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), - 'scope' => $snippet_data['scope'] ?? 'global', - 'tags' => is_array( $snippet_data['tags'] ?? [] ) ? implode( ', ', $snippet_data['tags'] ) : '', + 'id' => $snippet_data['id'] ?? uniqid(), + 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), + 'scope' => $snippet_data['scope'] ?? 'global', + 'tags' => is_array( $snippet_data['tags'] ?? [] ) ? implode( ', ', $snippet_data['tags'] ) : '', 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '', - 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ) + 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ), ]; $snippets[] = $snippet_data; @@ -298,7 +313,7 @@ private function parse_xml_file( string $file_path, string $file_name ) { return new WP_Error( 'invalid_xml', sprintf( - /* translators: %s: file name */ + /* translators: %s: file name */ __( 'Invalid XML in file %s', 'code-snippets' ), $file_name ) @@ -330,12 +345,12 @@ private function parse_xml_file( string $file_path, string $file_name ) { $snippet_data['source_file'] = $file_name; $snippet_data['table_data'] = [ - 'id' => ++$index, - 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), - 'scope' => $snippet_data['scope'] ?? 'global', - 'tags' => $snippet_data['tags'] ?? '', + 'id' => ++$index, + 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), + 'scope' => $snippet_data['scope'] ?? 'global', + 'tags' => $snippet_data['tags'] ?? '', 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '', - 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ), + 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ), ]; $snippets[] = $snippet_data; @@ -384,21 +399,20 @@ private function is_valid_file_type( string $extension, string $mime_type ): boo $valid_extensions = [ 'json', 'xml' ]; $valid_mime_types = [ 'application/json', 'text/xml', 'application/xml' ]; - return in_array( $extension, $valid_extensions, true ) || - in_array( $mime_type, $valid_mime_types, true ); + return in_array( $extension, $valid_extensions, true ) || + in_array( $mime_type, $valid_mime_types, true ); } - private function get_upload_error_message( int $error_code ): string { $error_messages = [ - UPLOAD_ERR_INI_SIZE => __( 'File exceeds the upload_max_filesize directive.', 'code-snippets' ), - UPLOAD_ERR_FORM_SIZE => __( 'File exceeds the MAX_FILE_SIZE directive.', 'code-snippets' ), - UPLOAD_ERR_PARTIAL => __( 'File was only partially uploaded.', 'code-snippets' ), - UPLOAD_ERR_NO_FILE => __( 'No file was uploaded.', 'code-snippets' ), + UPLOAD_ERR_INI_SIZE => __( 'File exceeds the upload_max_filesize directive.', 'code-snippets' ), + UPLOAD_ERR_FORM_SIZE => __( 'File exceeds the MAX_FILE_SIZE directive.', 'code-snippets' ), + UPLOAD_ERR_PARTIAL => __( 'File was only partially uploaded.', 'code-snippets' ), + UPLOAD_ERR_NO_FILE => __( 'No file was uploaded.', 'code-snippets' ), UPLOAD_ERR_NO_TMP_DIR => __( 'Missing a temporary folder.', 'code-snippets' ), UPLOAD_ERR_CANT_WRITE => __( 'Failed to write file to disk.', 'code-snippets' ), - UPLOAD_ERR_EXTENSION => __( 'A PHP extension stopped the file upload.', 'code-snippets' ), + UPLOAD_ERR_EXTENSION => __( 'A PHP extension stopped the file upload.', 'code-snippets' ), ]; return $error_messages[ $error_code ] ?? __( 'Unknown upload error.', 'code-snippets' ); diff --git a/src/php/migration/importers/plugins/header-footer-code-manager.php b/src/php/migration/Importers/Plugins/Header_Footer_Code_Manager_Importer.php similarity index 83% rename from src/php/migration/importers/plugins/header-footer-code-manager.php rename to src/php/migration/Importers/Plugins/Header_Footer_Code_Manager_Importer.php index 8503a142d..603aac61c 100644 --- a/src/php/migration/importers/plugins/header-footer-code-manager.php +++ b/src/php/migration/Importers/Plugins/Header_Footer_Code_Manager_Importer.php @@ -1,8 +1,14 @@ get_results( - $sql - ); + $snippets = $wpdb->get_results( $sql ); foreach ( $snippets as $snippet ) { $snippet->table_data = [ @@ -82,7 +86,7 @@ public function create_snippet( $snippet_data, bool $multisite ): ?Snippet { return $snippet; } - private function transform_field_value( string $target_field, $value, $snippet_data ) { + private function transform_field_value( string $target_field, $value, $snippet_data ): ?string { if ( 'scope' === $target_field ) { return $this->transform_scope_value( $value, $snippet_data ); } @@ -117,7 +121,6 @@ private function transform_code_value( $code_value, $snippet_data ): ?string { $code_type = $snippet_data->snippet_type ?? ''; $code = $this->strip_wrapper_tags( $code, $code_type ); - $code = $this->apply_minification( $code, $code_type ); return trim( $code ); } @@ -132,18 +135,4 @@ private function strip_wrapper_tags( string $code, string $code_type ): string { return $code; } } - - private function apply_minification( string $code, string $code_type ): string { - if ( ! in_array( $code_type, [ 'css', 'js' ], true ) ) { - return $code; - } - - $setting = Settings\get_setting( 'general', 'minify_output' ); - if ( ! is_array( $setting ) || ! in_array( $code_type, $setting, true ) ) { - return $code; - } - - $minifier = 'css' === $code_type ? new Minify\CSS( $code ) : new Minify\JS( $code ); - return $minifier->minify(); - } } diff --git a/src/php/migration/importers/plugins/importer-base.php b/src/php/migration/Importers/Plugins/Importer_Base.php similarity index 63% rename from src/php/migration/importers/plugins/importer-base.php rename to src/php/migration/Importers/Plugins/Importer_Base.php index c84733b18..3afeb6e1b 100644 --- a/src/php/migration/importers/plugins/importer-base.php +++ b/src/php/migration/Importers/Plugins/Importer_Base.php @@ -1,13 +1,21 @@ get_name(), [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_items' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - ] ); - - register_rest_route( $namespace, $this->get_name() . '/import', [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'import' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - 'args' => [ - 'ids' => [ - 'type' => 'array', - 'required' => false, - ], - 'network' => [ - 'type' => 'boolean', - 'required' => false, - ], - 'auto_add_tags' => [ - 'type' => 'boolean', - 'required' => false, - ], - 'tag_value' => [ - 'type' => 'string', - 'required' => false, + register_rest_route( + $namespace, + $this->get_name(), + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] + ); + + register_rest_route( + $namespace, + "{$this->get_name()}/import", + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'import' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'ids' => [ + 'type' => 'array', + 'required' => false, + ], + 'network' => [ + 'type' => 'boolean', + 'required' => false, + ], + 'auto_add_tags' => [ + 'type' => 'boolean', + 'required' => false, + ], + 'tag_value' => [ + 'type' => 'string', + 'required' => false, + ], ], - ], - ] ); + ] + ); } } diff --git a/src/php/migration/importers/plugins/insert-headers-and-footers.php b/src/php/migration/Importers/Plugins/Insert_Headers_And_Footers_Importer.php similarity index 92% rename from src/php/migration/importers/plugins/insert-headers-and-footers.php rename to src/php/migration/Importers/Plugins/Insert_Headers_And_Footers_Importer.php index 98f5fdf3d..97e09d7fe 100644 --- a/src/php/migration/importers/plugins/insert-headers-and-footers.php +++ b/src/php/migration/Importers/Plugins/Insert_Headers_And_Footers_Importer.php @@ -1,6 +1,15 @@ get_data_for_caching(); $snippet_data['tags'] = $snippet->get_tags(); $snippet_data['note'] = $snippet->get_note(); @@ -70,9 +79,7 @@ public function get_data( array $ids_to_import = [] ) { $data[] = apply_filters( 'wpcode_export_snippet_data', $snippet_data, $snippet ); } - $data = array_reverse( $data ); - - return $data; + return array_reverse( $data ); } public function create_snippet( $snippet_data, bool $multisite ): ?Snippet { diff --git a/src/php/migration/importers/plugins/insert-php-code-snippet.php b/src/php/migration/Importers/Plugins/Insert_PHP_Code_Snippet_Importer.php similarity index 84% rename from src/php/migration/importers/plugins/insert-php-code-snippet.php rename to src/php/migration/Importers/Plugins/Insert_PHP_Code_Snippet_Importer.php index 7e5bb5799..adef090b3 100644 --- a/src/php/migration/importers/plugins/insert-php-code-snippet.php +++ b/src/php/migration/Importers/Plugins/Insert_PHP_Code_Snippet_Importer.php @@ -1,12 +1,20 @@ 'name', - 'content' => 'code', + 'title' => 'name', + 'content' => 'code', 'insertionLocationType' => 'scope', ]; @@ -35,19 +43,14 @@ public static function is_active(): bool { public function get_data( array $ids_to_import = [] ) { global $wpdb; $table_name = $wpdb->prefix . 'xyz_ips_short_code'; - $sql = "SELECT * FROM `{$table_name}`"; - - if ( ! empty( $ids_to_import ) ) { - $sql .= " WHERE id IN (" . implode( ',', $ids_to_import ) . ")"; - } - $snippets = $wpdb->get_results( - $sql - ); + $snippets = empty( $ids_to_import ) + ? $wpdb->get_results( "SELECT * FROM `{$table_name}`" ) + : $wpdb->get_results( "SELECT * FROM `{$table_name}` WHERE id IN (" . implode( ',', $ids_to_import ) . ")" ); foreach ( $snippets as $snippet ) { $snippet->table_data = [ - 'id' => (int) $snippet->id, + 'id' => (int) $snippet->id, 'title' => $snippet->title, ]; } diff --git a/src/php/migration/importers/plugins/manager.php b/src/php/migration/Importers/Plugins/Plugins_Import_Manager.php similarity index 58% rename from src/php/migration/importers/plugins/manager.php rename to src/php/migration/Importers/Plugins/Plugins_Import_Manager.php index 013541d42..242a972d4 100644 --- a/src/php/migration/importers/plugins/manager.php +++ b/src/php/migration/Importers/Plugins/Plugins_Import_Manager.php @@ -1,15 +1,21 @@ init_plugin_importers(); @@ -20,7 +26,7 @@ private function init_plugin_importers() { $this->plugin_importers = [ 'insert-headers-and-footers' => new Insert_Headers_And_Footers_Importer(), 'header-footer-code-manager' => new Header_Footer_Code_Manager_Importer(), - 'insert-php-code-snippet' => new Insert_PHP_Code_Snippet_Importer(), + 'insert-php-code-snippet' => new Insert_PHP_Code_Snippet_Importer(), ]; } @@ -28,7 +34,7 @@ public function get_importer( string $source ) { return $this->plugin_importers[ $source ] ?? null; } - public function get_importers() { + public function get_importers(): array { if ( empty( $this->plugin_importers ) ) { $this->init_plugin_importers(); } @@ -37,8 +43,8 @@ public function get_importers() { foreach ( $this->plugin_importers as $importer ) { $plugins_list[] = [ - 'name' => $importer->get_name(), - 'title' => $importer->get_title(), + 'name' => $importer->get_name(), + 'title' => $importer->get_title(), 'is_active' => $importer::is_active(), ]; } @@ -49,12 +55,16 @@ public function get_importers() { public function register_rest_routes() { $namespace = REST_API_NAMESPACE . self::VERSION; - register_rest_route( $namespace, 'importers', [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_importers' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - ] ); + register_rest_route( + $namespace, + 'importers', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_importers' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] + ); } } diff --git a/src/php/settings/class-version-switch.php b/src/php/settings/class-version-switch.php deleted file mode 100644 index 0b3b5173a..000000000 --- a/src/php/settings/class-version-switch.php +++ /dev/null @@ -1,366 +0,0 @@ - $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( - // translators: %s: Version number. - __( '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 { - ?> - - file ), - [ 'code-editor' ], - $plugin->version - ); - } - - // Enqueue the menu scripts. - wp_enqueue_script( - 'code-snippets-settings-menu', - plugins_url( 'dist/settings.js', $plugin->file ), - [ 'code-snippets-code-editor' ], - $plugin->version, - true - ); - - wp_set_script_translations( 'code-snippets-settings-menu', 'code-snippets' ); - - // Extract the CodeMirror-specific editor settings. - $setting_fields = get_settings_fields(); - $editor_fields = array(); - - foreach ( $setting_fields['editor'] as $name => $field ) { - if ( empty( $field['codemirror'] ) ) { - continue; - } - - $editor_fields[] = array( - 'name' => $name, - 'type' => $field['type'], - 'codemirror' => addslashes( $field['codemirror'] ), - ); - } - - // Pass the saved options to the external JavaScript file. - $inline_script = 'var code_snippets_editor_settings = ' . wp_json_encode( $editor_fields ) . ';'; - - wp_add_inline_script( 'code-snippets-settings-menu', $inline_script, 'before' ); - - // Provide configuration and simple i18n for the version switch JS module. - $version_switch = array( - 'ajaxurl' => 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' ); -} - -/** - * Retrieve the list of code editor themes. - * - * @return array List of editor themes. - */ -function get_editor_theme_list(): array { - $themes = [ - 'default' => __( 'Default', 'code-snippets' ), - ]; - - foreach ( get_editor_themes() as $theme ) { - - // Skip mobile themes. - if ( '-mobile' === substr( $theme, -7 ) ) { - continue; - } - - $themes[ $theme ] = ucwords( str_replace( '-', ' ', $theme ) ); - } - - return $themes; -} - -/** - * Render the editor preview setting - */ -function render_editor_preview() { - $settings = get_settings_values(); - $settings = $settings['editor']; - - $indent_unit = absint( $settings['indent_unit'] ); - $tab_size = absint( $settings['tab_size'] ); - - $n_tabs = $settings['indent_with_tabs'] ? floor( $indent_unit / $tab_size ) : 0; - $n_spaces = $settings['indent_with_tabs'] ? $indent_unit % $tab_size : $indent_unit; - - $indent = str_repeat( "\t", $n_tabs ) . str_repeat( ' ', $n_spaces ); - - $code = "add_filter( 'admin_footer_text', function ( \$text ) {\n\n" . - $indent . "\$site_name = get_bloginfo( 'name' );\n\n" . - $indent . '$text = "Thank you for visiting $site_name.";' . "\n" . - $indent . 'return $text;' . "\n" . - "} );\n"; - - echo ''; -} diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php deleted file mode 100644 index 72262cc77..000000000 --- a/src/php/settings/settings-fields.php +++ /dev/null @@ -1,263 +0,0 @@ -> - */ -function get_default_settings(): array { - static $defaults; - - if ( isset( $defaults ) ) { - return $defaults; - } - - $defaults = [ - 'general' => [ - 'activate_by_default' => true, - 'enable_tags' => true, - 'enable_description' => true, - 'visual_editor_rows' => 5, - 'list_order' => 'priority-asc', - 'disable_prism' => false, - 'hide_upgrade_menu' => false, - 'complete_uninstall' => false, - 'enable_flat_files' => false, - ], - 'editor' => [ - 'indent_with_tabs' => true, - 'tab_size' => 4, - 'indent_unit' => 4, - 'font_size' => 14, - 'wrap_lines' => true, - 'code_folding' => true, - 'line_numbers' => true, - 'auto_close_brackets' => true, - 'highlight_selection_matches' => true, - 'highlight_active_line' => true, - 'keymap' => 'default', - 'theme' => 'default', - ], - 'version-switch' => [ - 'selected_version' => '', - ], - 'debug' => [ - 'enable_version_change' => false, - ], - ]; - - $defaults = apply_filters( 'code_snippets_settings_defaults', $defaults ); - - return $defaults; -} - -/** - * Retrieve the settings fields - * - * @return array> - */ -function get_settings_fields(): array { - static $fields; - - if ( isset( $fields ) ) { - return $fields; - } - - $fields = []; - - $fields['debug'] = [ - 'database_update' => [ - 'name' => __( 'Database Table Upgrade', 'code-snippets' ), - 'type' => 'action', - 'label' => __( 'Upgrade Database Table', 'code-snippets' ), - 'desc' => __( 'Use this button to manually upgrade the Code Snippets database table. This action will only affect the snippets table and should be used only when necessary.', 'code-snippets' ), - ], - 'reset_caches' => [ - 'name' => __( 'Reset Caches', 'code-snippets' ), - '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'] = [ - 'activate_by_default' => [ - 'name' => __( 'Activate by Default', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( "Make the 'Save and Activate' button the default action when saving a snippet.", 'code-snippets' ), - ], - 'enable_tags' => [ - 'name' => __( 'Enable Snippet Tags', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Show snippet tags on admin pages.', 'code-snippets' ), - ], - 'enable_description' => [ - 'name' => __( 'Enable Snippet Descriptions', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Show snippet descriptions on admin pages.', 'code-snippets' ), - ], - 'visual_editor_rows' => [ - 'name' => __( 'Description Editor Height', 'code-snippets' ), - 'type' => 'number', - 'label' => _x( 'rows', 'unit', 'code-snippets' ), - 'min' => 0, - ], - 'list_order' => [ - 'name' => __( 'Snippets List Order', 'code-snippets' ), - 'type' => 'select', - 'desc' => __( 'Default way to order snippets on the All Snippets admin menu.', 'code-snippets' ), - 'options' => [ - 'priority-asc' => __( 'Priority', 'code-snippets' ), - 'name-asc' => __( 'Name (A-Z)', 'code-snippets' ), - 'name-desc' => __( 'Name (Z-A)', 'code-snippets' ), - 'modified-desc' => __( 'Modified (latest first)', 'code-snippets' ), - 'modified-asc' => __( 'Modified (oldest first)', 'code-snippets' ), - ], - ], - 'disable_prism' => [ - 'name' => __( 'Disable Syntax Highlighter', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Disable syntax highlighting when displaying snippet code on the front-end.', 'code-snippets' ), - ], - ]; - - if ( ! code_snippets()->licensing->is_licensed() ) { - $fields['general']['hide_upgrade_menu'] = [ - 'name' => __( 'Hide Upgrade Notices', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Hide notices inviting you to upgrade to Code Snippets Pro.', 'code-snippets' ), - ]; - } - - if ( ! is_multisite() || is_main_site() ) { - $fields['general']['complete_uninstall'] = [ - 'name' => __( 'Complete Uninstall', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'When the plugin is deleted from the Plugins menu, also delete all snippets and plugin settings.', 'code-snippets' ), - ]; - } - - $fields['editor'] = [ - 'indent_with_tabs' => [ - 'name' => __( 'Indent With Tabs', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Use hard tabs instead of spaces for indentation.', 'code-snippets' ), - 'codemirror' => 'indentWithTabs', - ], - 'tab_size' => [ - 'name' => __( 'Tab Size', 'code-snippets' ), - 'type' => 'number', - 'desc' => __( 'The width of a tab character.', 'code-snippets' ), - 'label' => _x( 'spaces', 'unit', 'code-snippets' ), - 'codemirror' => 'tabSize', - 'min' => 0, - ], - 'indent_unit' => [ - 'name' => __( 'Indent Unit', 'code-snippets' ), - 'type' => 'number', - 'desc' => __( 'The number of spaces to indent a block.', 'code-snippets' ), - 'label' => _x( 'spaces', 'unit', 'code-snippets' ), - 'codemirror' => 'indentUnit', - 'min' => 0, - ], - 'font_size' => [ - 'name' => __( 'Font Size', 'code-snippets' ), - 'type' => 'number', - 'label' => _x( 'px', 'unit', 'code-snippets' ), - 'codemirror' => 'fontSize', - 'min' => 8, - 'max' => 28, - ], - 'wrap_lines' => [ - 'name' => __( 'Wrap Lines', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Soft-wrap long lines of code instead of horizontally scrolling.', 'code-snippets' ), - 'codemirror' => 'lineWrapping', - ], - - 'code_folding' => [ - 'name' => __( 'Code Folding', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Allow folding functions or other blocks into a single line.', 'code-snippets' ), - 'codemirror' => 'foldGutter', - ], - 'line_numbers' => [ - 'name' => __( 'Line Numbers', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Show line numbers to the left of the editor.', 'code-snippets' ), - 'codemirror' => 'lineNumbers', - ], - 'auto_close_brackets' => [ - 'name' => __( 'Auto Close Brackets', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Auto-close brackets and quotes when typed.', 'code-snippets' ), - 'codemirror' => 'autoCloseBrackets', - ], - 'highlight_selection_matches' => [ - 'name' => __( 'Highlight Selection Matches', 'code-snippets' ), - 'label' => __( 'Highlight all instances of a currently selected word.', 'code-snippets' ), - 'type' => 'checkbox', - 'codemirror' => 'highlightSelectionMatches', - ], - 'highlight_active_line' => [ - 'name' => __( 'Highlight Active Line', 'code-snippets' ), - 'label' => __( 'Highlight the line that is currently being edited.', 'code-snippets' ), - 'type' => 'checkbox', - 'codemirror' => 'styleActiveLine', - ], - 'keymap' => [ - 'name' => __( 'Keymap', 'code-snippets' ), - 'type' => 'select', - 'desc' => __( 'The set of keyboard shortcuts to use in the code editor.', 'code-snippets' ), - 'options' => [ - 'default' => __( 'Default', 'code-snippets' ), - 'vim' => __( 'Vim', 'code-snippets' ), - 'emacs' => __( 'Emacs', 'code-snippets' ), - 'sublime' => __( 'Sublime Text', 'code-snippets' ), - ], - 'codemirror' => 'keyMap', - ], - 'theme' => [ - 'name' => __( 'Theme', 'code-snippets' ), - 'type' => 'select', - 'options' => get_editor_theme_list(), - 'codemirror' => 'theme', - ], - ]; - - $fields = apply_filters( 'code_snippets_settings_fields', $fields ); - - return $fields; -} diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index cfd661914..92a4d0d37 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -7,9 +7,13 @@ namespace Code_Snippets; +use Code_Snippets\Core\DB; use ParseError; -use function Code_Snippets\Settings\get_self_option; -use function Code_Snippets\Settings\update_self_option; +use Code_Snippets\Model\Snippet; +use Code_Snippets\Utils\Validator; +use Throwable; +use function Code_Snippets\Utils\get_self_option; +use function Code_Snippets\Utils\update_self_option; /** * Clean the cache where active snippets are stored. @@ -20,11 +24,13 @@ * @return void */ function clean_active_snippets_cache( string $table_name, $scopes = false ) { - $scope_groups = $scopes ? [ $scopes ] : [ - [ 'head-content', 'footer-content' ], - [ 'global', 'single-use', 'front-end' ], - [ 'global', 'single-use', 'admin' ], - ]; + $scope_groups = $scopes + ? [ $scopes ] + : [ + [ 'head-content', 'footer-content' ], + [ 'global', 'single-use', 'front-end' ], + [ 'global', 'single-use', 'admin' ], + ]; foreach ( $scope_groups as $scopes ) { wp_cache_delete( sprintf( 'active_snippets_%s_%s', sanitize_key( join( '_', $scopes ) ), $table_name ), CACHE_GROUP ); @@ -51,7 +57,7 @@ function clean_snippets_cache( string $table_name ) { * @param array $ids The IDs of the snippets to fetch. * @param bool|null $network Retrieve multisite-wide snippets (true) or site-wide snippets (false). * - * @return array List of Snippet objects. + * @return Snippet[] List of Snippet objects. * * @since 2.0 */ @@ -73,15 +79,15 @@ function get_snippets( array $ids = array(), ?bool $network = null ): array { if ( ! is_array( $snippets ) ) { $results = $wpdb->get_results( "SELECT * FROM $table_name", ARRAY_A ); - $snippets = $results ? - array_map( + $snippets = $results + ? array_map( function ( $snippet_data ) use ( $network ) { $snippet_data['network'] = $network; return new Snippet( $snippet_data ); }, $results - ) : - array(); + ) + : []; $snippets = apply_filters( 'code_snippets/get_snippets', $snippets, $network ); @@ -219,7 +225,7 @@ function get_snippet( int $id = 0, ?bool $network = null ): Snippet { * * @param Snippet[] $snippets Snippets that was recently updated. * - * @return boolean Whether an update was performed. + * @return bool Whether an update was performed. */ function update_shared_network_snippets( array $snippets ): bool { $shared_ids = []; @@ -296,8 +302,8 @@ function activate_snippet( int $id, ?bool $network = null ) { // translators: %d: snippet identifier. return sprintf( __( 'Could not locate snippet with ID %d.', 'code-snippets' ), $id ); } - - if('php' == $snippet->type ){ + + if ( 'php' === $snippet->type ) { $validator = new Validator( $snippet->code ); if ( $validator->validate() ) { return __( 'Could not activate snippet: code did not pass validation.', 'code-snippets' ); @@ -326,8 +332,8 @@ function activate_snippet( int $id, ?bool $network = null ) { * Activates multiple snippets. * Write operation. * - * @param array $ids The IDs of the snippets to activate. - * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false). + * @param array $ids The IDs of the snippets to activate. + * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false). * * @return Snippet[]|null Snippets which were successfully activated, or null on failure. * @@ -448,6 +454,13 @@ function delete_snippet( int $id, ?bool $network = null ): bool { do_action( 'code_snippets/delete_snippet', $snippet, $network ); clean_snippets_cache( $table ); code_snippets()->cloud_api->delete_snippet_from_transient_data( $id ); + + $recently_active = get_self_option( $network, 'recently_activated_snippets', [] ); + + if ( isset( $recently_active[ $id ] ) ) { + unset( $recently_active[ $id ] ); + update_self_option( $network, 'recently_activated_snippets', $recently_active ); + } } return (bool) $result; @@ -559,7 +572,7 @@ function test_snippet_code( Snippet $snippet ) { * * @since 2.0.0 */ -function save_snippet( $snippet ) { +function save_snippet( $snippet ): ?Snippet { global $wpdb; $table = code_snippets()->db->get_table_name( $snippet->network ); @@ -616,21 +629,37 @@ function save_snippet( $snippet ) { } $snippet->id = $wpdb->insert_id; - do_action( 'code_snippets/create_snippet', $snippet, $table ); + $updated = get_snippet( $snippet->id ); + do_action( 'code_snippets/create_snippet', $updated, $table ); } else { + $existing = get_snippet( $snippet->id, $snippet->network ); // Otherwise, update the snippet data. $result = $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] ); + if ( false === $result ) { return null; } - do_action( 'code_snippets/update_snippet', $snippet, $table ); + $updated = get_snippet( $snippet->id, $snippet->network ); + do_action( 'code_snippets/update_snippet', $updated, $table, $existing ); + + if ( ! $updated->active && $existing->active ) { + $recently_active = [ $updated->id => time() ] + get_self_option( $updated->network, 'recently_activated_snippets', [] ); + update_self_option( $updated->network, 'recently_activated_snippets', $recently_active ); + } elseif ( ! $updated->active ) { + $recently_active = get_self_option( $updated->network, 'recently_activated_snippets', [] ); + + if ( isset( $recently_active[ $updated->id ] ) ) { + unset( $recently_active[ $updated->id ] ); + update_self_option( $updated->network, 'recently_activated_snippets', $recently_active ); + } + } } - update_shared_network_snippets( [ $snippet ] ); + update_shared_network_snippets( [ $updated ] ); clean_snippets_cache( $table ); - return $snippet; + return $updated; } /** @@ -639,13 +668,16 @@ function save_snippet( $snippet ) { * * Code must NOT be escaped, as it will be executed directly. * - * @param string $code Snippet code to execute. - * @param integer $id Snippet ID. - * @param boolean $force Force snippet execution, even if save mode is active. + * @param string $code Snippet code to execute. + * @param int $id Snippet ID. + * @param bool $force Force snippet execution, even if save mode is active. * * @return ParseError|mixed Code error if encountered during execution, or result of snippet execution otherwise. * - * @since 2.0.0 + * @since 2.0.0 + * @noinspection PhpUndefinedConstantInspection + * + * phpcs:disable Squiz.PHP.Eval.Discouraged */ function execute_snippet( string $code, int $id = 0, bool $force = false ) { /** @@ -676,8 +708,8 @@ function execute_snippet( string $code, int $id = 0, bool $force = false ) { * * Read operation. * - * @param string $cloud_id The Cloud ID of the snippet to retrieve. - * @param boolean|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false). + * @param string $cloud_id The Cloud ID of the snippet to retrieve. + * @param bool|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false). * * @return Snippet|null A single snippet object or null if no snippet was found. * @@ -741,11 +773,23 @@ function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = clean_snippets_cache( $table ); } +/** + * Evaluate a snippet by loading it from the filesystem. + * + * @param $code + * @param $file + * @param int $id + * @param bool $force + * + * @return bool|Throwable|null + */ function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force = false ) { if ( ! is_file( $file ) ) { - return execute_snippet( $code, $id, $force ); + execute_snippet( $code, $id, $force ); + return true; } + /* @noinspection PhpUndefinedConstantInspection */ if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) { return false; } @@ -757,8 +801,6 @@ function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force $result = null; } catch ( ParseError $parse_error ) { $result = $parse_error; - } catch ( Error $error ) { - $result = $error; } catch ( Throwable $throwable ) { $result = $throwable; } diff --git a/src/php/uninstall.php b/src/php/uninstall.php deleted file mode 100644 index 5afaefb69..000000000 --- a/src/php/uninstall.php +++ /dev/null @@ -1,110 +0,0 @@ -query( "DROP TABLE IF EXISTS {$wpdb->prefix}snippets" ); - - delete_option( 'code_snippets_version' ); - delete_option( 'recently_activated_snippets' ); - delete_option( 'code_snippets_settings' ); - - delete_option( 'code_snippets_cloud_settings' ); - delete_transient( 'cs_codevault_snippets' ); - delete_transient( 'cs_local_to_cloud_map' ); -} - -/** - * Clean up data created by this plugin on multisite. - * - * phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange - */ -function uninstall_multisite() { - global $wpdb; - - // Loop through sites. - $blog_ids = get_sites( [ 'fields' => 'ids' ] ); - - foreach ( $blog_ids as $site_id ) { - switch_to_blog( $site_id ); - uninstall_current_site(); - } - - restore_current_blog(); - - // Remove network snippets table. - $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}ms_snippets" ); - - // Remove saved options. - delete_site_option( 'code_snippets_version' ); - 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. - * - * @return void - */ -function uninstall_plugin() { - if ( complete_uninstall_enabled() ) { - - if ( is_multisite() ) { - uninstall_multisite(); - } else { - uninstall_current_site(); - } - - delete_flat_files_directory(); - } -} diff --git a/src/php/views/import.php b/src/php/views/import.php deleted file mode 100644 index 11f372d81..000000000 --- a/src/php/views/import.php +++ /dev/null @@ -1,37 +0,0 @@ - -
    -

    - is_compact_menu() ) { - $this->render_page_title_actions( [ 'manage', 'add', 'settings' ] ); - } - - ?> -

    - -
    -
    diff --git a/src/php/views/manage.php b/src/php/views/manage.php deleted file mode 100644 index 5b0b71942..000000000 --- a/src/php/views/manage.php +++ /dev/null @@ -1,129 +0,0 @@ - __( 'All Snippets', 'code-snippets' ) ], Plugin::get_types() ); -$current_type = $this->get_current_type(); - -if ( false !== strpos( code_snippets()->version, 'beta' ) ) { - echo '

    '; - echo wp_kses( - __( 'Thank you for testing this beta version of Code Snippets. We would love to hear your thoughts.', 'code-snippets' ), - [ 'span' => [ 'class' => [ 'highlight-yellow' ] ] ] - ); - - printf( - ' %s', - esc_url( __( 'https://codesnippets.pro/beta-testing/feedback/', 'code-snippets' ) ), - esc_html__( 'Share feedback', 'code-snippets' ) - ); - echo '

    '; -} - -?> - -
    -

    - render_page_title_actions( code_snippets()->is_compact_menu() ? [ 'add', 'import', 'settings' ] : [ 'add', 'import' ] ); - - $this->list_table->search_notice(); - ?> -

    - - print_messages(); ?> - - - - [ - __( 'Function snippets are run on your site as if there were in a plugin or theme functions.php file.', 'code-snippets' ), - __( 'Learn more about function snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-php/', - ], - 'html' => [ - __( 'Content snippets are bits of reusable PHP and HTML content that can be inserted into posts and pages.', 'code-snippets' ), - __( 'Learn more about content snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-html/', - ], - 'css' => [ - __( 'Style snippets are written in CSS and loaded in the admin area or on the site front-end, just like the theme style.css.', 'code-snippets' ), - __( 'Learn more about style snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-css/', - ], - 'js' => [ - __( 'Script snippets are loaded on the site front-end in a JavaScript file, either in the head or body sections.', 'code-snippets' ), - __( 'Learn more about javascript snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-js/', - ], - 'cloud' => [ - __( 'See all your public and private snippets that are stored in your Code Snippet Cloud codevault.', 'code-snippets' ), - __( 'Learn more about Code Snippets Cloud →', 'code-snippets' ), - 'https://codesnippets.cloud/getstarted/', - ], - ]; - - - if ( isset( $type_info[ $current_type ] ) ) { - $info = $type_info[ $current_type ]; - - printf( - '

    %s %s

    ', - esc_html( $info[0] ), - esc_url( $info[2] ), - esc_html( $info[1] ) - ); - } - - do_action( 'code_snippets/admin/manage/before_list_table' ); - $this->list_table->views(); - - switch ( $current_type ) { - case 'cloud_search': - include_once 'partials/cloud-search.php'; - break; - - default: - include_once 'partials/list-table.php'; - break; - } - - do_action( 'code_snippets/admin/manage', $current_type ); - - ?> -
    diff --git a/src/php/views/partials/cloud-search.php b/src/php/views/partials/cloud-search.php deleted file mode 100644 index 078399ed1..000000000 --- a/src/php/views/partials/cloud-search.php +++ /dev/null @@ -1,76 +0,0 @@ - - -

    - - - - - -

    - -
    - - - ', esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['type'] ) ) ) ); - } - ?> -
    -

    - -

    -
    -
    - - - - -
    -
    -
    - - cloud_search_list_table->display(); - } - - ?> -
    diff --git a/src/php/views/partials/list-table-notices.php b/src/php/views/partials/list-table-notices.php deleted file mode 100644 index ae8287c25..000000000 --- a/src/php/views/partials/list-table-notices.php +++ /dev/null @@ -1,114 +0,0 @@ - -
    -

    - - CODE_SNIPPETS_SAFE_MODE', 'wp-config.php' ); - ?> - - - - -

    -
    - __( 'Snippet executed.', 'code-snippets' ), - 'activated' => __( 'Snippet activated.', 'code-snippets' ), - 'activated-multi' => __( 'Selected snippets activated.', 'code-snippets' ), - 'deactivated' => __( 'Snippet deactivated.', 'code-snippets' ), - 'deactivated-multi' => __( 'Selected snippets deactivated.', 'code-snippets' ), - 'deleted' => __( 'Snippet trashed.', 'code-snippets' ), - 'deleted-multi' => __( 'Selected snippets trashed.', 'code-snippets' ), - 'deleted_permanently' => __( 'Snippet permanently deleted.', 'code-snippets' ), - 'deleted-permanently-multi' => __( 'Selected snippets permanently deleted.', 'code-snippets' ), - 'restored' => __( 'Snippet restored.', 'code-snippets' ), - 'restored-multi' => __( 'Selected snippets restored.', 'code-snippets' ), - 'cloned' => __( 'Snippet cloned.', 'code-snippets' ), - 'cloned-multi' => __( 'Selected snippets cloned.', 'code-snippets' ), - 'cloud-refreshed' => __( 'Synced cloud data has been successfully refreshed.', 'code-snippets' ), -]; - -// Add undo link for single snippet trash action -if ( 'deleted' === $result && ! empty( $_REQUEST['ids'] ) ) { - $deleted_ids = sanitize_text_field( $_REQUEST['ids'] ); - $undo_url = wp_nonce_url( - add_query_arg( array( - 'action' => 'restore', - 'ids' => $deleted_ids - ) ), - 'bulk-snippets' - ); - - $result_messages['deleted'] = sprintf( - // translators: %s: Undo URL. - __( 'Snippet trashed. Undo', 'code-snippets' ), - esc_url( $undo_url ) - ); -} - -// Add undo link for bulk snippet trash action -if ( 'deleted-multi' === $result && ! empty( $_REQUEST['ids'] ) ) { - $deleted_ids = sanitize_text_field( $_REQUEST['ids'] ); - $undo_url = wp_nonce_url( - add_query_arg( array( - 'action' => 'restore', - 'ids' => $deleted_ids - ) ), - 'bulk-snippets' - ); - - $result_messages['deleted-multi'] = sprintf( - // translators: %s: Undo URL. - __( 'Selected snippets trashed. Undo', 'code-snippets' ), - esc_url( $undo_url ) - ); -} - -$result_messages = apply_filters( 'code_snippets/manage/result_messages', $result_messages ); - -if ( isset( $result_messages[ $result ] ) ) { - $result_kses = [ - 'strong' => [], - 'a' => [ - 'href' => [], - ], - ]; - - printf( - '

    %s

    ', - wp_kses( $result_messages[ $result ], $result_kses ) - ); -} diff --git a/src/php/views/partials/list-table.php b/src/php/views/partials/list-table.php deleted file mode 100644 index a9569386e..000000000 --- a/src/php/views/partials/list-table.php +++ /dev/null @@ -1,33 +0,0 @@ - - -
    - list_table->search_box( __( 'Search Snippets', 'code-snippets' ), 'search_id' ); - ?> -
    - -
    - - list_table->display(); - ?> -
    diff --git a/src/php/views/welcome.php b/src/php/views/welcome.php deleted file mode 100644 index 053161345..000000000 --- a/src/php/views/welcome.php +++ /dev/null @@ -1,200 +0,0 @@ -api->get_hero_item(); - -$changelog_sections = [ - 'Added' => [ - 'title' => __( 'New features', 'code-snippets' ), - 'icon' => 'lightbulb', - ], - 'Improved' => [ - 'title' => __( 'Improvements', 'code-snippets' ), - 'icon' => 'chart-line', - ], - 'Fixed' => [ - 'title' => __( 'Bug fixes', 'code-snippets' ), - 'icon' => 'buddicons-replies', - ], - 'Other' => [ - 'title' => __( 'Other', 'code-snippets' ), - 'icon' => 'open-folder', - ], -]; - -$plugin_types = [ - 'core' => __( 'Core', 'code-snippets' ), - 'pro' => __( 'Pro', 'code-snippets' ), -]; - -?> - - - - diff --git a/src/uninstall.php b/src/uninstall.php index 74f1d4b92..9d70f4bec 100644 --- a/src/uninstall.php +++ b/src/uninstall.php @@ -6,13 +6,16 @@ * @since 2.0.0 */ -namespace Code_Snippets\Uninstall; +namespace Code_Snippets; + +use Code_Snippets\Core\Uninstaller; // Ensure this plugin is actually being uninstalled. if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) || ( defined( 'CODE_SNIPPETS_PRO' ) && CODE_SNIPPETS_PRO ) ) { return; } -require_once __DIR__ . '/php/uninstall.php'; +require_once __DIR__ . '/php/Core/Uninstaller.php'; -uninstall_plugin(); +$uninstaller = new Uninstaller(); +$uninstaller->uninstall_plugin(); diff --git a/tests/test-rest-api-snippets.php b/tests/test-rest-api-snippets.php index cbf5499d0..fa7c5969c 100644 --- a/tests/test-rest-api-snippets.php +++ b/tests/test-rest-api-snippets.php @@ -1,8 +1,8 @@