diff --git a/.codecov.yml b/.codecov.yml index 8f6bdb07e4f..438637fe0ad 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -23,17 +23,15 @@ comment: off github_checks: annotations: false ignore: - - 'src/web/assets' - - 'src/test/internal' + - 'bootstrap' + - 'lib' - 'src/config' - 'src/icons' - - 'src/test/internal' - 'src/migrations' - 'src/templates' + - 'src/test/internal' - 'src/translations' - 'src/views' - 'src/web/assets' - - 'bootstrap' - - 'lib' - 'tests' - 'vendor' diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT-V5.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT-V5.yml new file mode 100644 index 00000000000..79ef6efce80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT-V5.yml @@ -0,0 +1,60 @@ +name: Bug Report – Craft 5 +description: Report an issue or unexpected behavior pertaining to Craft 5 +title: '[5.x]: ' +labels: + - bug + - craft5 +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to submit a bug report! Please fill out the fields to the best of your knowledge, so we can get to the bottom of the issue as quickly as possible. + - type: textarea + id: body + attributes: + label: What happened? + value: | + ### Description + + + + ### Steps to reproduce + + 1. + + ### Expected behavior + + + + ### Actual behavior + + validations: + required: true + - type: input + id: cmsVersion + attributes: + label: Craft CMS version + validations: + required: true + - type: input + id: phpVersion + attributes: + label: PHP version + - type: input + id: os + attributes: + label: Operating system and version + - type: input + id: db + attributes: + label: Database type and version + - type: input + id: imageDriver + attributes: + label: Image driver and version + - type: textarea + id: plugins + attributes: + label: Installed plugins and versions + value: | + - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b63ceb1a0c1..788286bc239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,8 @@ on: push: branches: - develop + - '4.7' pull_request: - types: - - opened permissions: contents: read concurrency: diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 2dc70fd6a97..72498838d02 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -35,6 +35,14 @@ jobs: php-version: 8.0.2 tools: composer:v2 + - name: 'Set up PHP for Craft 5' + uses: shivammathur/setup-php@2.23.0 + if: ${{ startsWith(github.event.client_payload.version, '5.') }} + with: + extensions: bcmath, curl, dom, json, intl, mbstring, mcrypt, openssl, pcre, pdo, zip + php-version: 8.2.0 + tools: composer:v2 + - name: 'Initialize Craft 3 starter project' if: ${{ startsWith(github.event.client_payload.version, '3.') }} run: 'composer create-project craftcms/craft=^1 ${{ env.PROJECT_DIRECTORY }}' @@ -43,6 +51,10 @@ jobs: if: ${{ startsWith(github.event.client_payload.version, '4.') }} run: 'composer create-project craftcms/craft ${{ env.PROJECT_DIRECTORY }}' + - name: 'Initialize Craft 5 starter project' + if: ${{ startsWith(github.event.client_payload.version, '5.') }} + run: 'composer create-project craftcms/craft=^5.0.0-alpha.1 ${{ env.PROJECT_DIRECTORY }}' + - name: 'Install specific Craft version' working-directory: ${{ env.PROJECT_DIRECTORY }} run: 'composer require craftcms/cms:${{ github.event.client_payload.version }} --update-with-dependencies' @@ -61,6 +73,13 @@ jobs: sed -i 's/CRAFT_SECURITY_KEY=.*/CRAFT_SECURITY_KEY=/g' .env sed -i 's/CRAFT_APP_ID=.*/CRAFT_APP_ID=/g' .env + - name: 'Update Craft 5 .env' + if: ${{ startsWith(github.event.client_payload.version, '5.') }} + working-directory: ${{ env.PROJECT_DIRECTORY }} + run: | + sed -i 's/CRAFT_SECURITY_KEY=.*/CRAFT_SECURITY_KEY=/g' .env + sed -i 's/CRAFT_APP_ID=.*/CRAFT_APP_ID=/g' .env + - name: 'Create zip' working-directory: ${{ env.PROJECT_DIRECTORY }} run: 'zip -r ../${{ env.BUNDLE_ZIP_FILENAME }} ./' diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000000..bfe094b5a40 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,21 @@ +# Release Notes for Craft CMS 4.7 (WIP) + +### Content Management +- Admin tables now have sticky footers. ([#14149](https://github.com/craftcms/cms/pull/14149)) + +### Administration +- Added “Save and continue editing” actions to all core settings pages with full-page forms. ([#14168](https://github.com/craftcms/cms/discussions/14168)) +- It’s no longer possible to select the temp asset volume within Assets fields. ([#11405](https://github.com/craftcms/cms/issues/11405), [#14141](https://github.com/craftcms/cms/pull/14141)) +- Added the `utils/prune-orphaned-matrix-blocks` command. ([#14154](https://github.com/craftcms/cms/pull/14154)) + +### Extensibility +- Added `craft\base\ElementInterface::beforeDeleteForSite()`. +- Added `craft\base\ElementInterface::afterDeleteForSite()`. +- Added `craft\base\FieldInterface::beforeElementDeleteForSite()`. +- Added `craft\base\FieldInterface::afterElementDeleteForSite()`. + +### System +- Reduced the system font file size, and prevented the flash of unstyled type for it. ([#13879](https://github.com/craftcms/cms/pull/13879)) +- Log message timestamps are now set to the system time zone. ([#13341](https://github.com/craftcms/cms/issues/13341)) +- Selectize inputs now use the `auto_position` plugin. ([#14160](https://github.com/craftcms/cms/pull/14160)) +- Fixed a bug where deleting an entry for a site wasn’t propagating to Matrix blocks for that entry/site. ([#13948](https://github.com/craftcms/cms/issues/13948)) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7467704ff..c4c591a7060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,303 @@ # Release Notes for Craft CMS 4 +## Unreleased + +- Fixed a bug where paths passed to `craft\web\CpScreenResponseBehavior::editUrl()` weren’t getting resolved to absolute URLs. + +## 4.6.1 - 2024-01-16 + +- `craft\log\MonologTarget` instances are now created via `Craft::createObject()`. ([#13341](https://github.com/craftcms/cms/issues/13341)) +- Fixed a bug where `craft\helpers\Db::prepareValueForDb()` wasn’t converting objects to arrays for JSON columns. +- Fixed a bug where Checkboxes, Multi-select, Dropdown, and Radio Buttons fields weren’t displaying `0` options within element indexes or condition rules. ([#14127](https://github.com/craftcms/cms/issues/14127), [#14143](https://github.com/craftcms/cms/pull/14143)) +- Fixed a bug where `craft\db\Migration::renameTable()` was renaming the table for the primary database connection, rather than the migration’s connection. ([#14131](https://github.com/craftcms/cms/issues/14131)) +- Fixed a bug where `Craft.FormObserver` wasn’t working reliably for non-`
` containers. +- Fixed a bug where Selectize inputs were triggering autosaves, even when the value didn’t change. +- Fixed a bug where custom source labels weren’t getting translated. ([#14137](https://github.com/craftcms/cms/issues/14137)) +- Fixed a bug where Dropdown columns within Table fields were loosing their options when the field was edited. ([#14134](https://github.com/craftcms/cms/issues/14134)) + +## 4.6.0 - 2024-01-09 + +### Content Management +- Added live conditional field support to asset edit pages, as well as asset, user, and tag slideouts. ([#14115](https://github.com/craftcms/cms/pull/14115)) +- Added the “Country” field type. ([#13789](https://github.com/craftcms/cms/discussions/13789)) +- It’s now possible to delete volume folders using the “Delete” asset action. ([#13086](https://github.com/craftcms/cms/discussions/13086)) +- Date range condition rules are now inclusive of their end dates. ([#13435](https://github.com/craftcms/cms/issues/13435)) +- Custom field condition rules now show their field handles, for users with the “Show field handles in edit forms” preference enabled. ([#13300](https://github.com/craftcms/cms/pull/13300)) +- Element conditions now include condition rules for fields with duplicate names, for users with the “Show field handles in edit forms” preference enabled. ([#13300](https://github.com/craftcms/cms/pull/13300)) +- Improved element search performance. ([#14055](https://github.com/craftcms/cms/pull/14055)) +- Improved the performance of large editable tables. ([#13852](https://github.com/craftcms/cms/issues/13852)) + +### Administration +- Edit Field pages now have a “Save and add another” action. ([#13865](https://github.com/craftcms/cms/discussions/13865)) +- Added the `disabledUtilities` config setting. ([#14044](https://github.com/craftcms/cms/discussions/14044)) +- Added the `showFirstAndLastNameFields` config setting. ([#14097](https://github.com/craftcms/cms/pull/14097)) +- `resave` commands now pass an empty string (`''`) to fields’ `normalizeValue()` methods when `--to` is set to `:empty:`. ([#13951](https://github.com/craftcms/cms/issues/13951)) +- The `sections/create` command now supports `--name`, `--handle`, `--type`, `--no-versioning`, `--uri-format`, and `--template` options, and can now be run non-interactively. ([#13864](https://github.com/craftcms/cms/discussions/13864)) +- The `index-assets/one` and `index-assets/all` commands now accept a `--delete-empty-folders` option. ([#13947](https://github.com/craftcms/cms/discussions/13947)) + +### Extensibility +- Added partial support for field types storing data in JSON columns (excluding MariaDB). ([#13916](https://github.com/craftcms/cms/issues/13916)) +- Added `craft\base\conditions\ConditionRuleInterface::getLabelHint()`. +- Added `craft\helpers\AdminTable::moveToPage()`. ([#14051](https://github.com/craftcms/cms/pull/14051)) +- Added `craft\helpers\App::dbMutexConfig()`. +- Added `craft\helpers\ElementHelper::searchableAttributes()`. +- Added `craft\services\Elements::setElementUri()`. +- Added `craft\services\Elements::EVENT_SET_ELEMENT_URI`. ([#13930](https://github.com/craftcms/cms/discussions/13930)) +- Added `craft\services\Search::createDbQuery()`. +- `craft\base\MemoizableArray` now supports passing a normalizer method to the constructor, which will be lazily applied to each array item once, only if returned by `all()` or `firstWhere()`. ([#14104](https://github.com/craftcms/cms/pull/14104)) +- `craft\elements\actions\DeleteAssets` is no longer deprecated. +- `craft\helpers\ArrayHelper::firstWhere()` now has a `$valueKey` argument, which can be passed a variable by reference that should be set to the resulting value’s key in the array. +- Deprecated `craft\helpers\App::mutexConfig()`. +- Added `Craft.FormObserver`. ([#14114](https://github.com/craftcms/cms/pull/14114)) +- Admin tables now have `footerActions`, `moveToPageAction`, `onCellClicked`, `onCellDoubleClicked`, `onRowClicked`, `onRowDoubleClicked`, and `paginatedReorderAction` settings. ([#14051](https://github.com/craftcms/cms/pull/14051)) + +### System +- “Updating search indexes” jobs are no longer queued when saving elements with change tracking enabled, if no searchable fields or attributes were changed. ([#13917](https://github.com/craftcms/cms/issues/13917)) +- `queue/get-job-info` action requests no longer create a mutex lock. +- The `mutex` driver is now set to `yii\mutex\MysqlMutex` or `yii\mutex\PgsqlMutex` by default, once again. ([#14102](https://github.com/craftcms/cms/pull/14102)) + +## 4.5.14 - 2024-01-02 + +- Improved the performance of input namespacing. +- The Licensing Issues alert now includes a “Refresh” button. ([#14080](https://github.com/craftcms/cms/pull/14080)) +- `relatedToAssets`, `relatedToCategories`, `relatedToEntries`, `relatedToTags`, and `relatedToUsers` are now reserved user field handles. ([#14075](https://github.com/craftcms/cms/issues/14075)) +- `craft\services\Security::$sensitiveKeywords` is no longer case-sensitive. ([#14064](https://github.com/craftcms/cms/discussions/14064)) +- Fixed a bug where the `index-assets/cleanup` command accepted `--cache-remote-images`, `--create-missing-assets`, and `--delete-missing-assets` options, even though they didn’t do anything. +- Fixed a bug where automatically-created relations could be lost when a new site was added to an entry. ([#14065](https://github.com/craftcms/cms/issues/14065)) +- Fixed a bug where `craft\web\Request::getIsPreview()` was returning `true` for requests with expired tokens. ([#14066](https://github.com/craftcms/cms/discussions/14066)) +- Fixed a bug where asset conflict resolution modals were closing prematurely if there were multiple conflicts. ([#14045](https://github.com/craftcms/cms/issues/14045)) +- Fixed a bug where meta fields weren’t showing change indicators. +- Fixed a bug where the `index-assets/one` command was overly-destructive when run with a subpath and the `--delete-missing-assets` option. ([#14087](https://github.com/craftcms/cms/issues/14087)) +- Fixed a privilege escalation vulnerability. + +## 4.5.13 - 2023-12-15 + +- Address fields now have the appropriate `autocomplete` values when editing an address that belongs to the current user. ([#13938](https://github.com/craftcms/cms/pull/13938)) +- The `|markdown` and `|md` filters now accept an `encode` argument, which can be set to `true` to HTML-encode the content before parsing it as Markdown. +- Added the `pre-encoded` Markdown flavor, which can be used when the content has already been HTML-encoded. +- Added `craft\elements\Address::getBelongsToCurrentUser()`. +- Fixed a bug where `{% namespace %}` tags weren’t respecting namespaces set to `0`. ([#13943](https://github.com/craftcms/cms/issues/13943)) +- Fixed an error that could occur when using a custom asset uploader. ([#14029](https://github.com/craftcms/cms/pull/14029)) +- Fixed an error that could occur when saving an asset using `SCENARIO_CREATE`, if `Asset::$tempFilePath` wasn’t set. ([#14041](https://github.com/craftcms/cms/pull/14041)) +- Fixed a bug where some HTML entities within Tip and Warning field layout elements colud get double-encoded. ([#13959](https://github.com/craftcms/cms/issues/13959)) +- Fixed an infinite recursion bug. ([#14033](https://github.com/craftcms/cms/issues/14033)) + +## 4.5.12 - 2023-12-12 + +- It’s no longer possible to dismiss asset conflict resolution modals by pressing Esc or clicking outside of the modal. ([#14002](https://github.com/craftcms/cms/issues/14002)) +- Improved performance for sites with lots of custom fields in non-global contexts. ([#13992](https://github.com/craftcms/cms/issues/13992)) +- Username, Full Name, and Email fields now have the appropriate `autocomplete` values when editing the current user. ([#13941](https://github.com/craftcms/cms/pull/13941)) +- Queue job info is now broadcasted to other browser tabs opened to the same control panel. ([#13990](https://github.com/craftcms/cms/issues/13990)) +- Volumes’ Asset Filesystem settings now list filesystems that are already selected by another volume, as disabled options. ([#14004](https://github.com/craftcms/cms/pull/14004)) +- Added `craft\db\Connection::onAfterTransaction()`. +- Added `craft\errors\MutexException`. ([#13985](https://github.com/craftcms/cms/pull/13985)) +- Added `craft\fieldlayoutelements\TextField::$inputType`. ([#13988](https://github.com/craftcms/cms/issues/13988)) +- Deprecated `craft\fieldlayoutelements\TextField::$type`. `$inputType` should be used instead. ([#13988](https://github.com/craftcms/cms/issues/13988)) +- Fixed a bug where WebP image transforms weren’t respecting transform quality settings. ([#13998](https://github.com/craftcms/cms/issues/13998)) +- Fixed a bug where `craft\base\ApplicationTrait::onAfterRequest()` callbacks weren’t necessarily triggered if an `EVENT_AFTER_REQUEST` handler got in the way. +- Fixed a bug where keyboard shortcuts could stop working. ([#14011](https://github.com/craftcms/cms/issues/14011)) +- Fixed a bug where the `craft\services\Elements::EVENT_AUTHORIZE_VIEW` event wasn’t always triggered when editing elements. ([#13981](https://github.com/craftcms/cms/issues/13981)) +- Fixed a bug that prevented Live Preview from opening for edited entries, when the `autosaveDrafts` config setting was disabled. ([#13921](https://github.com/craftcms/cms/issues/13921)) +- Fixed a bug where JavaScript-based slug generation wasn’t working consistently with PHP. ([#13971](https://github.com/craftcms/cms/pull/13971)) +- Fixed a bug where asset upload failure notifications could be ambiguous if a server connection issue occurred. ([#14003](https://github.com/craftcms/cms/issues/14003)) +- Fixed a “Changes to the project config are not possible while in read-only mode.” error that could occur when adimn changes were disallowed. ([#14018](https://github.com/craftcms/cms/issues/14018)) +- Fixed a bug where it was possible to create a volume without a filesystem selected. ([#14004](https://github.com/craftcms/cms/pull/14004)) +- Fixed a privilege escalation vulnerability. + +## 4.5.11.1 - 2023-11-23 + +- Fixed a PHP error that occurred due to a conflict with psr/log v3. ([#13963](https://github.com/craftcms/cms/issues/13963)) + +## 4.5.11 - 2023-11-16 + +- Date fields with “Show Time Zone” enabled will now remember IANA-formatted time zones set via GraphQL. ([#13893](https://github.com/craftcms/cms/issues/13893)) +- Added `craft\gql\types\DateTime::$setToSystemTimeZone`. +- `craft\gql\types\DateTime` now supports JSON-encoded objects with `date`, `time`, and `timezone` keys. +- `craft\web\Response::setCacheHeaders()` now includes the `public` directive in the `Cache-Control` header. ([#13922](https://github.com/craftcms/cms/pull/13922)) +- Fixed a bug where and key presses would set focus to disabled menu options. ([#13911](https://github.com/craftcms/cms/issues/13911)) +- Fixed a bug where elements’ `localized` GraphQL field wasn’t returning any results for drafts or revisions. ([#13924](https://github.com/craftcms/cms/issues/13924)) +- Fixed a bug where dropdown option labels within Table fields weren’t getting translated. ([#13914](https://github.com/craftcms/cms/issues/13914)) +- Fixed a bug where “Updating search indexes” jobs were getting queued for Matrix block revisions. ([#13917](https://github.com/craftcms/cms/issues/13917)) +- Fixed a bug where control panel resources weren’t getting published on demand. ([#13935](https://github.com/craftcms/cms/issues/13935)) +- Fixed privilege escalation vulnerabilities. + +## 4.5.10 - 2023-11-07 + +- Added the `db/drop-table-prefix` command. +- Top-level disabled related/nested elements are now included in “Extended” element exports. ([#13496](https://github.com/craftcms/cms/issues/13496)) +- Related element validation is no longer recursive. ([#13904](https://github.com/craftcms/cms/issues/13904)) +- Addresses’ owner elements are now automatically set on them during initialization, if they were queried with the `owner` address query param. +- Entry Title fields are no longer shown when “Show the Title field” is disabled and there’s a validation error on the `title` attribute. ([#13876](https://github.com/craftcms/cms/issues/13876)) +- Improved the reliability of image dimension detection. ([#13886](https://github.com/craftcms/cms/pull/13886)) +- The default backup command for PostgreSQL no longer passes in `--column-inserts` to `pg_dump`. +- Log contexts now include the environment name. ([#13882](https://github.com/craftcms/cms/pull/13882)) +- Added `craft\web\AssetManager::$cacheSourcePaths`. +- Fixed a bug where disclosure menus could be positioned off-screen on mobile. +- Fixed a bug where element edit pages could show a context menu when it wasn’t necessary. +- Fixed a bug where the “Delete entry for this site” action wasn’t deleting the canonical entry for the selected site, when editing a provisional draft. +- Fixed an error that occurred when cropping an image that was missing its dimension info. ([#13884](https://github.com/craftcms/cms/issues/13884)) +- Fixed an error that occurred if a filesystem didn’t have any settings. ([#13883](https://github.com/craftcms/cms/pull/13883)) +- Fixed a bug where related element validation wansn’t ensuring that related elements were loaded in the same site as the source element when possible. ([#13907](https://github.com/craftcms/cms/issues/13907)) +- Fixed a bug where sites weren’t always getting queried in the same order, if multiple sites’ `sortOrder` values were the same. ([#13896](https://github.com/craftcms/cms/issues/13896)) + +## 4.5.9 - 2023-10-23 + +- Fixed a bug where it was possible to change the status for entries that didn’t show the Status field, via bulk editing. ([#13854](https://github.com/craftcms/cms/issues/13854)) +- Fixed a PHP error that could occur when editing elements via slideouts. ([#13867](https://github.com/craftcms/cms/issues/13867)) +- Fixed an error that could occur if no `storage/` folder existed. + +## 4.5.8 - 2023-10-20 + +- Improved the styling and accessibility of revision pages. ([#13857](https://github.com/craftcms/cms/pull/13857), [#13850](https://github.com/craftcms/cms/issues/13850)) +- Added the `focalPoint` argument to asset save mutations. ([#13846](https://github.com/craftcms/cms/discussions/13846)) +- The `up` command now accepts a `--no-backup` option. +- `{% cache %}` tags now store any `` tags registered with `yii\web\View::registerMetaTag()`. ([#13832](https://github.com/craftcms/cms/issues/13832)) +- Added `craft\errors\ExitException`. +- Added `craft\web\View::startMetaTagBuffer()`. +- Added `craft\web\View::clearMetaTagBuffer()`. +- Added support for modifying the application config via a global `craft_modify_app_config()` function. ([#13855](https://github.com/craftcms/cms/pull/13855)) +- Fixed a bug where `{% exit %}` tags without a status code weren’t outputting any HTML that had already been output in the template. ([#13848](https://github.com/craftcms/cms/discussions/13848)) +- Fixed a bug where it wasn’t possible to Ctrl/Command-click on multiple elements to select them. ([#13853](https://github.com/craftcms/cms/issues/13853)) + +## 4.5.7 - 2023-10-17 + +- Field containers are no longer focusable unless a corresponding validation message is clicked on. ([#13782](https://github.com/craftcms/cms/issues/13782)) +- Improved element save performance. +- Added `pgpassword` and `pwd` to the list of keywords that Craft will look for when determining whether a value is sensitive and should be redacted from logs, etc. +- Added `craft\events\DefineCompatibleFieldTypesEvent`. +- Added `craft\services\Fields::EVENT_DEFINE_COMPATIBLE_FIELD_TYPES`. ([#13793](https://github.com/craftcms/cms/discussions/13793)) +- Added `craft\web\assets\inputmask\InputmaskAsset`. +- `craft\web\Request::accepts()` now supports wildcard (e.g. `application/*`). ([#13759](https://github.com/craftcms/cms/issues/13759)) +- `Craft.ElementEditor` instances are now configured with an `elementId` setting, which is kept up-to-date when a provisional draft is created. ([#13795](https://github.com/craftcms/cms/discussions/13795)) +- Added `Garnish.isPrimaryClick()`. +- Fixed a bug where relational fields’ element selector modals weren’t always getting set to the correct site per the field’s “Relate entries from a specific site?” setting. ([#13750](https://github.com/craftcms/cms/issues/13750)) +- Fixed a bug where Dropdown fields weren’t visible when viewing revisions and other static forms. ([#13753](https://github.com/craftcms/cms/issues/13753), [craftcms/commerce#3270](https://github.com/craftcms/commerce/issues/3270)) +- Fixed a bug where the `defaultDirMode` config setting wasn’t being respected when the `storage/runtime/` and `storage/logs/` folders were created. ([#13756](https://github.com/craftcms/cms/issues/13756)) +- Fixed a bug where the “Save and continue editing” action wasn’t working on Edit User pages if they contained a Money field. ([#13760](https://github.com/craftcms/cms/issues/13760)) +- Fixed a bug where relational fields’ validation messages weren’t using the actual field name. ([#13807](https://github.com/craftcms/cms/issues/13807)) +- Fixed a bug where element editor slideouts were appearing behind element selector modals within Live Preview. ([#13798](https://github.com/craftcms/cms/issues/13798)) +- Fixed a bug where element URIs weren’t getting updated for propagated sites automatically. ([#13812](https://github.com/craftcms/cms/issues/13812)) +- Fixed a bug where dropdown input labels could overflow out of their containers. ([#13817](https://github.com/craftcms/cms/issues/13817)) +- Fixed a bug where the `transformGifs` and `transformSvgs` config settings weren’t always being respected when using `@transform` GraphQL directives. ([#13808](https://github.com/craftcms/cms/issues/13808)) +- Fixed a bug where Composer operations were sorting `require` packages differently than how Composer does it natively, when `config.sort-packages` was set to `true`. ([#13806](https://github.com/craftcms/cms/issues/13806)) +- Fixed a MySQL error that could occur when creating a Plain Text field with a high charcter limit. ([#13781](https://github.com/craftcms/cms/pull/13781)) +- Fixed a bug where entries weren’t always being treated as live for View and Preview buttons, when editing a non-primary site. ([#13746](https://github.com/craftcms/cms/issues/13746)) +- Fixed a bug where Ctrl-clicks were being treated as primary clicks in some browsers. ([#13823](https://github.com/craftcms/cms/issues/13823)) +- Fixed a bug where some language options were showing “false” hints. ([#13837](https://github.com/craftcms/cms/issues/13837)) +- Fixed a bug where Craft was tracking changes to elements when they were being resaved. ([#13761](https://github.com/craftcms/cms/issues/13761)) +- Fixed a bug where sensitive keywords weren’t getting redacted from log contexts. +- Fixed RCE vulnerabilities. + +## 4.5.6.1 - 2023-09-27 + +- Crossdomain JavaScript resources are now loaded via a proxy action. +- Fixed JavaScript errors that could occur after loading new UI components over Ajax. ([#13751](https://github.com/craftcms/cms/issues/13751)) + +## 4.5.6 - 2023-09-26 + +- When slideouts are opened within Live Preview, they now slide up over the editor pane, rather than covering the preview pane. ([#13739](https://github.com/craftcms/cms/pull/13739)) +- Cross-site validation now only involves fields which were actually modified in the element save. ([#13675](https://github.com/craftcms/cms/discussions/13675)) +- Row headings within Table fields now get statically translated. ([#13703](https://github.com/craftcms/cms/discussions/13703)) +- Element condition settings within field layout components now display a warning if the `autosaveDrafts` config setting is disabled. ([#12348](https://github.com/craftcms/cms/issues/12348)) +- Added the `resave/addresses` command. ([#13720](https://github.com/craftcms/cms/discussions/13720)) +- The `resave/matrix-blocks` command now supports an `--owner-id` option. +- Added `craft\helpers\App::phpExecutable()`. +- `craft\helpers\Component::createComponent()` now filters out `as X` and `on X` keys from the component config. +- `craft\services\Announcements::push()` now has an `$adminsOnly` argument. ([#13728](https://github.com/craftcms/cms/discussions/13728)) +- `Craft.appendHeadHtml()` and `appendBodyHtml()` now load external scripts asynchronously, and return promises. +- Improved the reliability of Composer operations when PHP is running via FastCGI. ([#13681](https://github.com/craftcms/cms/issues/13681)) +- Fixed a bug where it wasn’t always possible to create new entries from custom sources which were limited to one section. +- Fixed a bug where relational fields weren’t factoring in cross-site elements when enforcing their “Min Relations”, “Max Relations”, and “Validate related entries” settings. ([#13699](https://github.com/craftcms/cms/issues/13699)) +- Fixed a bug where pagination wasn’t working for admin tables, if the `onQueryParams` callback method wasn’t set. ([#13677](https://github.com/craftcms/cms/issues/13677)) +- Fixed a bug where relations within Matrix blocks weren’t getting restored when restoring a revision’s content. ([#13626](https://github.com/craftcms/cms/issues/13626)) +- Fixed a bug where the filesystem and volume-creation slideouts could keep reappearing if canceled. ([#13707](https://github.com/craftcms/cms/issues/13707)) +- Fixed an error that could occur when reattempting to update to Craft 4.5. ([#13714](https://github.com/craftcms/cms/issues/13714)) +- Fixed a bug where date and time inputs could be parsed incorrectly, if the user’s formatting locale wasn’t explicitly set, or it changed between page load and form submit. ([#13731](https://github.com/craftcms/cms/issues/13731)) +- Fixed JavaScript errors that could occur when control panel resources were being loaded from a different domain. ([#13715](https://github.com/craftcms/cms/issues/13715)) +- Fixed a PHP error that occurred if the `CRAFT_DOTENV_PATH` environment variable was set, or a console command was executed with the `--dotenvPath` option. ([#13725](https://github.com/craftcms/cms/issues/13725)) +- Fixed a bug where long element titles weren’t always getting truncated in the control panel. ([#13718](https://github.com/craftcms/cms/issues/13718)) +- Fixed a bug where checkboxes could be preselected if they had an empty value. ([#13710](https://github.com/craftcms/cms/issues/13710)) +- Fixed a bug where links in validation summaries weren’t working if the offending field was in a collapsed Matrix block. ([#13708](https://github.com/craftcms/cms/issues/13708)) +- Fixed a bug where cross-site validation could apply even if `craft\services\Elements::saveElement()` was called with `$runValidation` set to `false`. +- Fixed some wonky scrolling behavior on pages where the details pane was shorter than the content pane. ([#13637](https://github.com/craftcms/cms/issues/13637)) +- Fixed a division by zero error. ([#13712](https://github.com/craftcms/cms/issues/13712)) +- Fixed an RCE vulnerability. + +## 4.5.5 - 2023-09-14 + +- Added the `maxGraphqlBatchSize` config setting. ([#13693](https://github.com/craftcms/cms/issues/13693)) +- Fixed a bug where page sidebars and detail panes weren’t scrolling properly if their height was greater than the main content pane height. ([#13637](https://github.com/craftcms/cms/issues/13637)) +- Fixed an error that could occur when changing a field’s type, if a backup table needed to be created to store the old field values. ([#13669](https://github.com/craftcms/cms/issues/13669)) +- Fixed a bug where it wasn’t possible to save blank Dropdown values. ([#13695](https://github.com/craftcms/cms/issues/13695)) + +## 4.5.4 - 2023-09-12 + +- Added the `@stripTags` and `@trim` GraphQL directives. ([#9971](https://github.com/craftcms/cms/discussions/9971)) +- Added `SK` to the list of keywords that Craft will look for when determining whether a value is sensitive and should be redacted from logs, etc. ([#3619](https://github.com/craftcms/cms/issues/3619)) +- Improved the scrolling behavior for page sidebars and detail panes. ([#13637](https://github.com/craftcms/cms/issues/13637)) +- Filesystem edit pages now have a “Save and continue editing” alternative submit action, and the Command/Ctrl + S keyboard shortcut now redirects back to the edit page. ([#13658](https://github.com/craftcms/cms/pull/13658)) +- Attribute labels are no longer surrounded by asterisks for front-end validation messages. ([#13640](https://github.com/craftcms/cms/issues/13640)) +- The `|replace` Twig filter now has a `regex` argument, which can be set to `false` to disable regular expression parsing. ([#13642](https://github.com/craftcms/cms/discussions/13642)) +- Added `craft\events\DefineUserGroupsEvent`. +- Added `craft\services\Users::EVENT_DEFINE_DEFAULT_USER_GROUPS`. ([#12283](https://github.com/craftcms/cms/issues/12283)) +- Added `craft\services\Users::getDefaultUserGroups()`. +- `craft\events\UserAssignGroupEvent` now extends `DefineUserGroupsEvent`, giving it a new `$userGroups` property. +- `craft\helpers\DateTimeHelper::toDateTime()` now supports `DateTimeImmutable` values. ([#13656](https://github.com/craftcms/cms/issues/13656)) +- `craft\web\Response::setCacheHeaders()` no longer includes `public` in the `Cache-Control` header when `$overwrite` is `false`. ([#13676](https://github.com/craftcms/cms/issues/13676)) +- Deprecated `craft\events\UserAssignGroupEvent`. `DefineUserGroupsEvent` should be used instead. +- Fixed a bug where the “Active Trials” section in the Plugin Store cart modal wasn’t listing plugins in trial. ([#13661](https://github.com/craftcms/cms/issues/13661)) +- Fixed a bug where changed fields weren’t being tracked properly when applying a draft for a multi-site entry. +- `craft\services\Elements::duplicateElement()` now supports passing a `siteAttributes` array to the `$attributes` argument, for setting site-specific attributes. +- Fixed an error that could occur when executing a GraphQL query with fragments. ([#13622](https://github.com/craftcms/cms/issues/13622)) +- Fixed a bug where addresses queried via GraphQL had a `photo` field. +- Fixed a bug where boolean environment variables weren’t always getting the correct value indicators within Selectize fields. ([#13613](https://github.com/craftcms/cms/issues/13613)) +- Fixed a bug where some system icons were getting black backgrounds when displayed within Vue apps. ([#13632](https://github.com/craftcms/cms/issues/13632)) +- Fixed a bug where the user and address field layouts were getting new UUIDs each time they were saved. ([#13588](https://github.com/craftcms/cms/issues/13588)) +- Fixed an error that could occur if a Money field was set to an array without a `value` key. ([#13648](https://github.com/craftcms/cms/pull/13648)) +- Fixed a bug where relations weren’t getting restored when restoring a revision’s content. ([#13626](https://github.com/craftcms/cms/issues/13626)) +- Fixed a bug where “Entry Type” fields were showing `typeId` labels for admin users with “Show field handles in edit forms” enabled. ([#13627](https://github.com/craftcms/cms/issues/13627)) +- Fixed a bug where Lightswitch fields with only one label weren’t getting the correct padding on the unlabelled side of the container. ([#13629](https://github.com/craftcms/cms/issues/13629)) +- Fixed a bug where the `transformGifs` and `transformSvgs` config settings weren’t always being respected. ([#13624](https://github.com/craftcms/cms/issues/13624), [#13635](https://github.com/craftcms/cms/issues/13635)) +- Fixed a bug where filesystems weren’t requiring the “Base URL” setting to be set. ([#13657](https://github.com/craftcms/cms/pull/13657)) +- Fixed a bug where applying a draft could redirect to the “Current” revision on a different site, if a new site had been added on the draft. ([#13668](https://github.com/craftcms/cms/pull/13668)) +- Fixed an error that could occur when changing a field’s type, if a backup table needed to be created to store the old field values. ([#13669](https://github.com/craftcms/cms/issues/13669)) +- Fixed a bug where Matrix blocks that were initially created for a newly-added site within a draft could be lost when applying the draft. ([#13670](https://github.com/craftcms/cms/pull/13670)) +- Fixed a bug where `fill` transform properties weren’t being passed along by `craft\elements\Asset::getUrlsBySize()` and `getSrcset()`. ([#13650](https://github.com/craftcms/cms/issues/13650)) +- Fixed a bug where SVG asset icons weren’t visible in Safari. ([#13685](https://github.com/craftcms/cms/issues/13685)) +- Fixed two RCE vulnerabilities. + +## 4.5.3 - 2023-08-29 + +- Fixed a bug where custom fields could be marked as changed within element editor slideouts, if they modified their input values on initialization. ([craftcms/ckeditor#128](https://github.com/craftcms/ckeditor/issues/128)) +- Fixed a bug where elements were getting saved a second time after being converted to a provisional draft within a slideout. ([#13604](https://github.com/craftcms/cms/issues/13604)) +- Fixed a JavaScript error. ([#13605](https://github.com/craftcms/cms/issues/13605)) +- Fixed support for storing PHP session info in the database on PostgreSQL. +- Fixed a bug where search inputs within element selector modals weren’t getting focus rings. +- Fixed a bug where boolean menu inputs were initially treating `null` values as `true`. +- Fixed a bug where boolean menu inputs weren’t toggling other fields. ([#13613](https://github.com/craftcms/cms/issues/13613)) +- Fixed a bug where `Craft.namespaceId()` wasn’t working properly if the namespace ended in a `]` character. +- Fixed a bug where the `|replace` Twig filter wasn’t identifying regular expressions that contained escaped slashes. ([#13618](https://github.com/craftcms/cms/issues/13618)) +- Fixed a bug where entries that were cloned from a provisional draft weren’t getting propagated to other sites initially. ([#13599](https://github.com/craftcms/cms/issues/13599)) +- Fixed an error that could occur when cloning a multi-site provisional draft that contained nested Matrix/Neo/Super Table blocks. + +## 4.5.2 - 2023-08-24 + +- `craft\helpers\UrlHelper::buildQuery()` is no longer deprecated. ([#12796](https://github.com/craftcms/cms/issues/12796)) +- Fixed a bug where control panel notifications weren’t always closing automatically if they contained interactive elements. ([#13591](https://github.com/craftcms/cms/issues/13591)) +- Fixed a bug where default user avatars were getting black backgrounds when displayed within Vue apps. ([#13597](https://github.com/craftcms/cms/issues/13597)) +- Fixed a bug where the Username and Email fields weren’t required for public registrations forms, if “Deactivate users by default” was enabled. ([#13596](https://github.com/craftcms/cms/issues/13596)) +- Fixed a bug where switching sites when editing a global site wasn’t working. ([#12796](https://github.com/craftcms/cms/issues/12796), [#13603](https://github.com/craftcms/cms/issues/13603)) +- Fixed a bug where page shortcuts weren’t working after a related element was saved via a slideout. ([#13601](https://github.com/craftcms/cms/issues/13601)) + +## 4.5.1 - 2023-08-23 + +- Control panel notifications no longer block page keyboard shortcuts. ([#13591](https://github.com/craftcms/cms/issues/13591)) +- `Garnish.uiLayerManager.addLayer()` now supports a `bubble` option, which allows non-matching keyboard shortcuts to bubble up to the parent layer. +- Fixed an error that could occur when Craft was performing a Composer operation, if no `HOME` environment variable was set for PHP. ([#13590](https://github.com/craftcms/cms/issues/13590)) +- Fixed a bug where `craft\fields\Matrix::serializeValue()` was setting `fields` keys to a closure. ([#13592](https://github.com/craftcms/cms/issues/13592)) +- Fixed a bug where time values weren’t saving properly for Greek locales. ([#9942](https://github.com/craftcms/cms/issues/9942)) +- Fixed a bug where the “Status” lightswitch would always be enabled on edit pages for single-site elements. ([#13595](https://github.com/craftcms/cms/issues/13595)) + ## 4.5.0 - 2023-08-22 ### Content Management @@ -208,7 +506,7 @@ - Fixed a bug where date and time inputs weren’t always working properly on mobile. ([#13424](https://github.com/craftcms/cms/issues/13424)) - Fixed an RCE vulnerability. -## 4.4.15 - 2023-07-03 +## 4.4.15 - 2023-07-03 [CRITICAL] - The control panel footer now includes a message about active trials, with a link to purchase the licenses. - Tags fields now only show up to five suggestions. ([#13322](https://github.com/craftcms/cms/issues/13322)) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index 42b45ebcf3e..d668f0be786 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -9,6 +9,7 @@ use craft\helpers\App; use craft\helpers\ArrayHelper; +use craft\helpers\FileHelper; use craft\services\Config; use yii\base\ErrorException; @@ -16,38 +17,60 @@ // see https://stackoverflow.com/a/21601349/1688568 $lastError = error_get_last(); -// Setup +// Validate the app type // ----------------------------------------------------------------------------- -// Validate the app type if (!isset($appType) || ($appType !== 'web' && $appType !== 'console')) { throw new Exception('$appType must be set to "web" or "console".'); } -$createFolder = function($path) { - // Code borrowed from Io... - if (!is_dir($path)) { - $oldumask = umask(0); - - if (!mkdir($path, 0755, true)) { - // Set a 503 response header so things like Varnish won't cache a bad page. - http_response_code(503); - exit('Tried to create a folder at ' . $path . ', but could not.' . PHP_EOL); - } +// Determine the paths +// ----------------------------------------------------------------------------- - // Because setting permission with mkdir is a crapshoot. - chmod($path, 0755); - umask($oldumask); - } +$findConfig = function(string $cliName, string $envName) { + return App::cliOption($cliName, true) ?? App::env($envName); }; -$findConfigPath = function($cliName, $envName) use ($createFolder) { - $path = App::cliOption($cliName, true) ?? App::env($envName); - if (!$path) { - return null; +// Set the vendor path. By default assume that it's 4 levels up from here +$vendorPath = FileHelper::normalizePath($findConfig('--vendorPath', 'CRAFT_VENDOR_PATH') ?? dirname(__DIR__, 3)); + +// Set the "project root" path that contains config/, storage/, etc. By default assume that it's up a level from vendor/. +$rootPath = FileHelper::normalizePath($findConfig('--basePath', 'CRAFT_BASE_PATH') ?? dirname($vendorPath)); + +// By default the remaining files/directories will be in the base directory +$dotenvPath = FileHelper::normalizePath($findConfig('--dotenvPath', 'CRAFT_DOTENV_PATH') ?? "$rootPath/.env"); +$configPath = FileHelper::normalizePath($findConfig('--configPath', 'CRAFT_CONFIG_PATH') ?? "$rootPath/config"); +$contentMigrationsPath = FileHelper::normalizePath($findConfig('--contentMigrationsPath', 'CRAFT_CONTENT_MIGRATIONS_PATH') ?? "$rootPath/migrations"); +$storagePath = FileHelper::normalizePath($findConfig('--storagePath', 'CRAFT_STORAGE_PATH') ?? "$rootPath/storage"); +$templatesPath = FileHelper::normalizePath($findConfig('--templatesPath', 'CRAFT_TEMPLATES_PATH') ?? "$rootPath/templates"); +$translationsPath = FileHelper::normalizePath($findConfig('--translationsPath', 'CRAFT_TRANSLATIONS_PATH') ?? "$rootPath/translations"); +$testsPath = FileHelper::normalizePath($findConfig('--testsPath', 'CRAFT_TESTS_PATH') ?? "$rootPath/tests"); + +// Set the environment +// ----------------------------------------------------------------------------- + +$environment = App::cliOption('--env', true) + ?? App::env('CRAFT_ENVIRONMENT') + ?? App::env('ENVIRONMENT') + ?? $_SERVER['SERVER_NAME'] + ?? null; + +// Load the general config +// ----------------------------------------------------------------------------- + +$configService = new Config(); +$configService->env = $environment; +$configService->configDir = $configPath; +$configService->appDefaultsDir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'defaults'; +$generalConfig = $configService->getConfigFromFile('general'); + +// Validation +// ----------------------------------------------------------------------------- + +$createFolder = function($path) use ($generalConfig) { + if (!is_dir($path)) { + FileHelper::createDirectory($path, $generalConfig['defaultDirMode'] ?? 0775); } - $createFolder($path); - return realpath($path); }; $ensureFolderIsReadable = function($path, $writableToo = false) { @@ -67,31 +90,6 @@ } }; -// Determine the paths -// ----------------------------------------------------------------------------- - -// Set the vendor path. By default assume that it's 4 levels up from here -$vendorPath = $findConfigPath('--vendorPath', 'CRAFT_VENDOR_PATH') ?? dirname(__DIR__, 3); - -// Set the "project root" path that contains config/, storage/, etc. By default assume that it's up a level from vendor/. -$rootPath = $findConfigPath('--basePath', 'CRAFT_BASE_PATH') ?? dirname($vendorPath); - -// By default the remaining directories will be in the base directory -$dotenvPath = $findConfigPath('--dotenvPath', 'CRAFT_DOTENV_PATH') ?? "$rootPath/.env"; -$configPath = $findConfigPath('--configPath', 'CRAFT_CONFIG_PATH') ?? "$rootPath/config"; -$contentMigrationsPath = $findConfigPath('--contentMigrationsPath', 'CRAFT_CONTENT_MIGRATIONS_PATH') ?? "$rootPath/migrations"; -$storagePath = $findConfigPath('--storagePath', 'CRAFT_STORAGE_PATH') ?? "$rootPath/storage"; -$templatesPath = $findConfigPath('--templatesPath', 'CRAFT_TEMPLATES_PATH') ?? "$rootPath/templates"; -$translationsPath = $findConfigPath('--translationsPath', 'CRAFT_TRANSLATIONS_PATH') ?? "$rootPath/translations"; -$testsPath = $findConfigPath('--testsPath', 'CRAFT_TESTS_PATH') ?? "$rootPath/tests"; - -// Set the environment -$environment = App::cliOption('--env', true) - ?? App::env('CRAFT_ENVIRONMENT') - ?? App::env('ENVIRONMENT') - ?? $_SERVER['SERVER_NAME'] - ?? null; - // Validate the paths // ----------------------------------------------------------------------------- @@ -132,6 +130,7 @@ } } +$createFolder($storagePath); $ensureFolderIsReadable($storagePath, true); // Create the storage/runtime/ folder if it doesn't already exist @@ -158,15 +157,6 @@ $errorLevel = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED; error_reporting($errorLevel); -// Load the general config -// ----------------------------------------------------------------------------- - -$configService = new Config(); -$configService->env = $environment; -$configService->configDir = $configPath; -$configService->appDefaultsDir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'defaults'; -$generalConfig = $configService->getConfigFromFile('general'); - // Determine if Craft is running in Dev Mode // ----------------------------------------------------------------------------- @@ -251,6 +241,10 @@ $configService->getConfigFromFile("app.{$appType}") ); +if (function_exists('craft_modify_app_config')) { + craft_modify_app_config($config, $appType); +} + // Initialize the application /** @var \craft\web\Application|craft\console\Application $app */ $app = Craft::createObject($config); diff --git a/composer.lock b/composer.lock index 00859b54713..70b00bc2ee6 100644 --- a/composer.lock +++ b/composer.lock @@ -285,16 +285,16 @@ }, { "name": "composer/composer", - "version": "2.5.8", + "version": "2.6.5", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "4c516146167d1392c8b9b269bb7c24115d262164" + "reference": "4b0fe89db9e65b1e64df633a992e70a7a215ab33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/4c516146167d1392c8b9b269bb7c24115d262164", - "reference": "4c516146167d1392c8b9b269bb7c24115d262164", + "url": "https://api.github.com/repos/composer/composer/zipball/4b0fe89db9e65b1e64df633a992e70a7a215ab33", + "reference": "4b0fe89db9e65b1e64df633a992e70a7a215ab33", "shasum": "" }, "require": { @@ -302,23 +302,23 @@ "composer/class-map-generator": "^1.0", "composer/metadata-minifier": "^1.0", "composer/pcre": "^2.1 || ^3.1", - "composer/semver": "^3.0", + "composer/semver": "^3.2.5", "composer/spdx-licenses": "^1.5.7", "composer/xdebug-handler": "^2.0.2 || ^3.0.3", "justinrainbow/json-schema": "^5.2.11", "php": "^7.2.5 || ^8.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "react/promise": "^2.8", + "react/promise": "^2.8 || ^3", "seld/jsonlint": "^1.4", "seld/phar-utils": "^1.2", "seld/signal-handler": "^2.0", - "symfony/console": "^5.4.11 || ^6.0.11", - "symfony/filesystem": "^5.4 || ^6.0", - "symfony/finder": "^5.4 || ^6.0", + "symfony/console": "^5.4.11 || ^6.0.11 || ^7", + "symfony/filesystem": "^5.4 || ^6.0 || ^7", + "symfony/finder": "^5.4 || ^6.0 || ^7", "symfony/polyfill-php73": "^1.24", "symfony/polyfill-php80": "^1.24", "symfony/polyfill-php81": "^1.24", - "symfony/process": "^5.4 || ^6.0" + "symfony/process": "^5.4 || ^6.0 || ^7" }, "require-dev": { "phpstan/phpstan": "^1.9.3", @@ -326,7 +326,7 @@ "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1", "phpstan/phpstan-symfony": "^1.2.10", - "symfony/phpunit-bridge": "^6.0" + "symfony/phpunit-bridge": "^6.0 || ^7" }, "suggest": { "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", @@ -339,7 +339,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "2.6-dev" }, "phpstan": { "includes": [ @@ -349,7 +349,7 @@ }, "autoload": { "psr-4": { - "Composer\\": "src/Composer" + "Composer\\": "src/Composer/" } }, "notification-url": "https://packagist.org/downloads/", @@ -378,7 +378,8 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.5.8" + "security": "https://github.com/composer/composer/security/policy", + "source": "https://github.com/composer/composer/tree/2.6.5" }, "funding": [ { @@ -394,7 +395,7 @@ "type": "tidelift" } ], - "time": "2023-06-09T15:13:21+00:00" + "time": "2023-10-06T08:11:52+00:00" }, { "name": "composer/metadata-minifier", @@ -904,16 +905,16 @@ }, { "name": "defuse/php-encryption", - "version": "v2.4.0", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/defuse/php-encryption.git", - "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + "reference": "77880488b9954b7884c25555c2a0ea9e7053f9d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", - "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/77880488b9954b7884c25555c2a0ea9e7053f9d2", + "reference": "77880488b9954b7884c25555c2a0ea9e7053f9d2", "shasum": "" }, "require": { @@ -922,8 +923,7 @@ "php": ">=5.6.0" }, "require-dev": { - "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", - "yoast/phpunit-polyfills": "^2.0.0" + "phpunit/phpunit": "^4|^5|^6|^7|^8|^9" }, "bin": [ "bin/generate-defuse-key" @@ -965,9 +965,9 @@ ], "support": { "issues": "https://github.com/defuse/php-encryption/issues", - "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + "source": "https://github.com/defuse/php-encryption/tree/v2.3.1" }, - "time": "2023-06-19T06:10:36+00:00" + "time": "2021-04-09T23:57:26+00:00" }, { "name": "doctrine/collections", @@ -1041,29 +1041,25 @@ }, { "name": "doctrine/deprecations", - "version": "v1.1.1", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3" + "reference": "8cffffb2218e01f3b370bf763e00e81697725259" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", - "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/8cffffb2218e01f3b370bf763e00e81697725259", + "reference": "8cffffb2218e01f3b370bf763e00e81697725259", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^7.1|^8.0" }, "require-dev": { "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" + "phpunit/phpunit": "^7.5|^8.5|^9.5", + "psr/log": "^1|^2|^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -1082,9 +1078,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/v1.1.1" + "source": "https://github.com/doctrine/deprecations/tree/v1.1.0" }, - "time": "2023-06-03T09:27:29+00:00" + "time": "2023-05-29T18:55:17+00:00" }, { "name": "doctrine/lexer", @@ -1509,16 +1505,16 @@ }, { "name": "guzzlehttp/promises", - "version": "2.0.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "111166291a0f8130081195ac4556a5587d7f1b5d" + "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/111166291a0f8130081195ac4556a5587d7f1b5d", - "reference": "111166291a0f8130081195ac4556a5587d7f1b5d", + "url": "https://api.github.com/repos/guzzle/promises/zipball/3a494dc7dc1d7d12e511890177ae2d0e6c107da6", + "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6", "shasum": "" }, "require": { @@ -1572,7 +1568,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.1" + "source": "https://github.com/guzzle/promises/tree/2.0.0" }, "funding": [ { @@ -1588,20 +1584,20 @@ "type": "tidelift" } ], - "time": "2023-08-03T15:11:55+00:00" + "time": "2023-05-21T13:50:22+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.6.0", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "8bd7c33a0734ae1c5d074360512beb716bef3f77" + "reference": "b635f279edd83fc275f822a1188157ffea568ff6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/8bd7c33a0734ae1c5d074360512beb716bef3f77", - "reference": "8bd7c33a0734ae1c5d074360512beb716bef3f77", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/b635f279edd83fc275f822a1188157ffea568ff6", + "reference": "b635f279edd83fc275f822a1188157ffea568ff6", "shasum": "" }, "require": { @@ -1688,7 +1684,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.6.0" + "source": "https://github.com/guzzle/psr7/tree/2.5.0" }, "funding": [ { @@ -1704,20 +1700,20 @@ "type": "tidelift" } ], - "time": "2023-08-03T15:06:02+00:00" + "time": "2023-04-17T16:11:26+00:00" }, { "name": "illuminate/collections", - "version": "v9.52.14", + "version": "v9.52.8", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "d3710b0b244bfc62c288c1a87eaa62dd28352d1f" + "reference": "0168d0e44ea0c4fe5451fe08cde7049b9e9f9741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/d3710b0b244bfc62c288c1a87eaa62dd28352d1f", - "reference": "d3710b0b244bfc62c288c1a87eaa62dd28352d1f", + "url": "https://api.github.com/repos/illuminate/collections/zipball/0168d0e44ea0c4fe5451fe08cde7049b9e9f9741", + "reference": "0168d0e44ea0c4fe5451fe08cde7049b9e9f9741", "shasum": "" }, "require": { @@ -1759,11 +1755,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2023-06-11T21:17:10+00:00" + "time": "2023-02-22T11:32:27+00:00" }, { "name": "illuminate/conditionable", - "version": "v9.52.14", + "version": "v9.52.8", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -1809,7 +1805,7 @@ }, { "name": "illuminate/contracts", - "version": "v9.52.14", + "version": "v9.52.8", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", @@ -1857,7 +1853,7 @@ }, { "name": "illuminate/macroable", - "version": "v9.52.14", + "version": "v9.52.8", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -2428,16 +2424,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.23.1", + "version": "1.22.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26" + "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/846ae76eef31c6d7790fac9bc399ecee45160b26", - "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", + "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", "shasum": "" }, "require": { @@ -2469,9 +2465,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.23.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.22.0" }, - "time": "2023-08-03T16:32:59+00:00" + "time": "2023-06-01T12:35:21+00:00" }, { "name": "pixelandtonic/imagine", @@ -3472,16 +3468,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v5.4.26", + "version": "v5.4.22", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "5dcc00e03413f05c1e7900090927bb7247cb0aac" + "reference": "1df20e45d56da29a4b1d8259dd6e950acbf1b13f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/5dcc00e03413f05c1e7900090927bb7247cb0aac", - "reference": "5dcc00e03413f05c1e7900090927bb7247cb0aac", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1df20e45d56da29a4b1d8259dd6e950acbf1b13f", + "reference": "1df20e45d56da29a4b1d8259dd6e950acbf1b13f", "shasum": "" }, "require": { @@ -3537,7 +3533,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.26" + "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.22" }, "funding": [ { @@ -3553,7 +3549,7 @@ "type": "tidelift" } ], - "time": "2023-07-06T06:34:20+00:00" + "time": "2023-03-17T11:31:58+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5412,16 +5408,16 @@ }, { "name": "voku/anti-xss", - "version": "4.1.42", + "version": "4.1.41", "source": { "type": "git", "url": "https://github.com/voku/anti-xss.git", - "reference": "bca1f8607e55a3c5077483615cd93bd8f11bd675" + "reference": "55a403436494e44a2547a8d42de68e6cad4bca1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/anti-xss/zipball/bca1f8607e55a3c5077483615cd93bd8f11bd675", - "reference": "bca1f8607e55a3c5077483615cd93bd8f11bd675", + "url": "https://api.github.com/repos/voku/anti-xss/zipball/55a403436494e44a2547a8d42de68e6cad4bca1d", + "reference": "55a403436494e44a2547a8d42de68e6cad4bca1d", "shasum": "" }, "require": { @@ -5467,7 +5463,7 @@ ], "support": { "issues": "https://github.com/voku/anti-xss/issues", - "source": "https://github.com/voku/anti-xss/tree/4.1.42" + "source": "https://github.com/voku/anti-xss/tree/4.1.41" }, "funding": [ { @@ -5491,7 +5487,7 @@ "type": "tidelift" } ], - "time": "2023-07-03T14:40:46+00:00" + "time": "2023-02-12T15:56:55+00:00" }, { "name": "voku/arrayy", @@ -6113,16 +6109,16 @@ }, { "name": "webonyx/graphql-php", - "version": "v14.11.10", + "version": "v14.11.9", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19" + "reference": "ff91c9f3cf241db702e30b2c42bcc0920e70ac70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/ff91c9f3cf241db702e30b2c42bcc0920e70ac70", + "reference": "ff91c9f3cf241db702e30b2c42bcc0920e70ac70", "shasum": "" }, "require": { @@ -6142,7 +6138,8 @@ "phpunit/phpunit": "^7.2 || ^8.5", "psr/http-message": "^1.0", "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0" + "simpod/php-coveralls-mirror": "^3.0", + "squizlabs/php_codesniffer": "3.5.4" }, "suggest": { "psr/http-message": "To use standard GraphQL server", @@ -6166,7 +6163,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10" + "source": "https://github.com/webonyx/graphql-php/tree/v14.11.9" }, "funding": [ { @@ -6174,7 +6171,7 @@ "type": "open_collective" } ], - "time": "2023-07-05T14:23:37+00:00" + "time": "2023-01-06T12:12:50+00:00" }, { "name": "yiisoft/yii2", @@ -7391,16 +7388,16 @@ }, { "name": "fakerphp/faker", - "version": "v1.23.0", + "version": "v1.22.0", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01" + "reference": "f85772abd508bd04e20bb4b1bbe260a68d0066d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", - "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/f85772abd508bd04e20bb4b1bbe260a68d0066d2", + "reference": "f85772abd508bd04e20bb4b1bbe260a68d0066d2", "shasum": "" }, "require": { @@ -7453,9 +7450,9 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.23.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.22.0" }, - "time": "2023-06-12T08:44:38+00:00" + "time": "2023-05-14T12:31:37+00:00" }, { "name": "graham-campbell/result-type", @@ -7723,16 +7720,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.16.0", + "version": "v4.15.5", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "19526a33fb561ef417e822e85f08a00db4059c17" + "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/19526a33fb561ef417e822e85f08a00db4059c17", - "reference": "19526a33fb561ef417e822e85f08a00db4059c17", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/11e2663a5bc9db5d714eedb4277ee300403b4a9e", + "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e", "shasum": "" }, "require": { @@ -7773,9 +7770,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.16.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.5" }, - "time": "2023-06-25T14:52:30+00:00" + "time": "2023-05-19T20:20:00+00:00" }, { "name": "phar-io/manifest", @@ -7965,16 +7962,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.27", + "version": "1.10.32", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "a9f44dcea06f59d1363b100bb29f297b311fa640" + "reference": "c47e47d3ab03137c0e121e77c4d2cb58672f6d44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a9f44dcea06f59d1363b100bb29f297b311fa640", - "reference": "a9f44dcea06f59d1363b100bb29f297b311fa640", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c47e47d3ab03137c0e121e77c4d2cb58672f6d44", + "reference": "c47e47d3ab03137c0e121e77c4d2cb58672f6d44", "shasum": "" }, "require": { @@ -8023,20 +8020,20 @@ "type": "tidelift" } ], - "time": "2023-08-05T09:57:55+00:00" + "time": "2023-08-24T21:54:50+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.27", + "version": "9.2.26", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1" + "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b0a88255cb70d52653d80c890bd7f38740ea50d1", - "reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", + "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", "shasum": "" }, "require": { @@ -8092,8 +8089,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.27" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.26" }, "funding": [ { @@ -8101,7 +8097,7 @@ "type": "github" } ], - "time": "2023-07-26T13:44:30+00:00" + "time": "2023-03-06T12:58:08+00:00" }, { "name": "phpunit/php-file-iterator", @@ -8346,16 +8342,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.10", + "version": "9.6.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328" + "reference": "17d621b3aff84d0c8b62539e269e87d8d5baa76e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a6d351645c3fe5a30f5e86be6577d946af65a328", - "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/17d621b3aff84d0c8b62539e269e87d8d5baa76e", + "reference": "17d621b3aff84d0c8b62539e269e87d8d5baa76e", "shasum": "" }, "require": { @@ -8429,7 +8425,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.10" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.8" }, "funding": [ { @@ -8445,7 +8441,7 @@ "type": "tidelift" } ], - "time": "2023-07-10T04:04:23+00:00" + "time": "2023-05-11T05:14:45+00:00" }, { "name": "sebastian/cli-parser", @@ -8953,16 +8949,16 @@ }, { "name": "sebastian/global-state", - "version": "5.0.6", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bde739e7565280bda77be70044ac1047bc007e34" + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", - "reference": "bde739e7565280bda77be70044ac1047bc007e34", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", "shasum": "" }, "require": { @@ -9005,7 +9001,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.6" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" }, "funding": [ { @@ -9013,7 +9009,7 @@ "type": "github" } ], - "time": "2023-08-02T09:26:13+00:00" + "time": "2022-02-14T08:28:10+00:00" }, { "name": "sebastian/lines-of-code", @@ -9550,16 +9546,16 @@ }, { "name": "symfony/css-selector", - "version": "v5.4.26", + "version": "v5.4.21", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "0ad3f7e9a1ab492c5b4214cf22a9dc55dcf8600a" + "reference": "95f3c7468db1da8cc360b24fa2a26e7cefcb355d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/0ad3f7e9a1ab492c5b4214cf22a9dc55dcf8600a", - "reference": "0ad3f7e9a1ab492c5b4214cf22a9dc55dcf8600a", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/95f3c7468db1da8cc360b24fa2a26e7cefcb355d", + "reference": "95f3c7468db1da8cc360b24fa2a26e7cefcb355d", "shasum": "" }, "require": { @@ -9596,7 +9592,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v5.4.26" + "source": "https://github.com/symfony/css-selector/tree/v5.4.21" }, "funding": [ { @@ -9612,20 +9608,20 @@ "type": "tidelift" } ], - "time": "2023-07-07T06:10:25+00:00" + "time": "2023-02-14T08:03:56+00:00" }, { "name": "symfony/dom-crawler", - "version": "v5.4.25", + "version": "v5.4.23", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "d2aefa5a7acc5511422792931d14d1be96fe9fea" + "reference": "4a286c916b74ecfb6e2caf1aa31d3fe2a34b7e08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/d2aefa5a7acc5511422792931d14d1be96fe9fea", - "reference": "d2aefa5a7acc5511422792931d14d1be96fe9fea", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4a286c916b74ecfb6e2caf1aa31d3fe2a34b7e08", + "reference": "4a286c916b74ecfb6e2caf1aa31d3fe2a34b7e08", "shasum": "" }, "require": { @@ -9671,7 +9667,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v5.4.25" + "source": "https://github.com/symfony/dom-crawler/tree/v5.4.23" }, "funding": [ { @@ -9687,7 +9683,7 @@ "type": "tidelift" } ], - "time": "2023-06-05T08:05:41+00:00" + "time": "2023-04-08T21:20:19+00:00" }, { "name": "symplify/easy-coding-standard", diff --git a/crowdin.yml b/crowdin.yml index 5a696b24bfa..e66e3fcbcd0 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -9,6 +9,7 @@ files: excluded_target_languages: - cy - el + - hr - id - ky commit_message: '[ci skip]' diff --git a/package-lock.json b/package-lock.json index 201d282a8b7..fbed8b5ac6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "react": "^16.14.0", "react-dom": "^16.14.0", "timepicker": "^1.13.18", + "ttf2woff2": "^5.0.0", "typescript": "^4.7.4", "v-tooltip": "^2.0.3", "velocity-animate": "^1.5.0", @@ -1789,6 +1790,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" + }, "node_modules/@graphiql/toolkit": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.5.tgz", @@ -2040,6 +2046,75 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/fs/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@playwright/test": { "version": "1.28.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.1.tgz", @@ -2078,6 +2153,14 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==" }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -2731,6 +2814,11 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2813,11 +2901,32 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -2962,6 +3071,23 @@ "svg.select.js": "^3.0.1" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3188,7 +3314,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -3367,6 +3492,17 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/bufferstreams": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-3.0.0.tgz", + "integrity": "sha512-Qg0ggJUWJq90vtg4lDsGN9CDWvzBMQxhiEkSOD/sJfYt6BLect3eV1/S6K7SCSKJ34n60rf6U5eUPmQENVE4UA==", + "dependencies": { + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">=8.12.0" + } + }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -3375,6 +3511,132 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cacache/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==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -3553,6 +3815,14 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -3573,7 +3843,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, "engines": { "node": ">=6" } @@ -3730,6 +3999,14 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -3820,6 +4097,11 @@ "node": ">=0.8" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "node_modules/consolidate": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", @@ -4782,6 +5064,11 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5043,6 +5330,27 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.12.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", @@ -5063,6 +5371,14 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, "node_modules/envinfo": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", @@ -5074,6 +5390,11 @@ "node": ">=4" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -5613,6 +5934,11 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==" + }, "node_modules/expose-loader": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-3.1.0.tgz", @@ -5811,8 +6137,7 @@ "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "optional": true + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, "node_modules/fill-range": { "version": "7.0.1", @@ -5991,6 +6316,17 @@ "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==" }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/fs-monkey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", @@ -6019,6 +6355,69 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/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==" + }, + "node_modules/gauge/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/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==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6275,6 +6674,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "node_modules/hash-sum": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz", @@ -6367,6 +6771,11 @@ "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.8.4.tgz", "integrity": "sha512-T+1Z9RAZpJ1Ia6cvpV67lstvJ5UQkNpUAulpyfxUCHYbzYcaUVQC/PrcIdPNRU9e1ii6Ec7EEw7GnenavOZB6g==" }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -6405,6 +6814,19 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/http-proxy-middleware": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", @@ -6443,6 +6865,18 @@ "npm": ">=1.3.7" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -6451,6 +6885,14 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", @@ -6558,11 +7000,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6590,6 +7036,11 @@ "node": ">= 0.10" } }, + "node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + }, "node_modules/ipaddr.js": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", @@ -6670,6 +7121,11 @@ "node": ">=0.10.0" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7503,6 +7959,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, "node_modules/map-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", @@ -7743,6 +8233,110 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -7763,8 +8357,7 @@ "node_modules/nan": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", - "optional": true + "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==" }, "node_modules/nanoid": { "version": "3.3.4", @@ -7803,11 +8396,93 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==" }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7846,6 +8521,20 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -8038,7 +8727,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, "dependencies": { "aggregate-error": "^3.0.0" }, @@ -8955,6 +9643,31 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9696,6 +10409,11 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9773,6 +10491,15 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -9791,6 +10518,32 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sortablejs": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", @@ -9896,6 +10649,17 @@ "integrity": "sha512-NXzN+/HPObKAx191H3zKlYomE5WrVIkoCB5IaSdvKokxTpjBdWfr0RaP+1Z5KOfDT0ZVz+2tdtiBkhsEQ9p+0A==", "peer": true }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -10254,6 +11018,35 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", + "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/terser": { "version": "5.16.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.0.tgz", @@ -10530,6 +11323,24 @@ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", "dev": true }, + "node_modules/ttf2woff2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ttf2woff2/-/ttf2woff2-5.0.0.tgz", + "integrity": "sha512-FplhShJd3rT8JGa8N04YWQuP7xRvwr9AIq+9/z5O/5ubqNiCADshKl8v51zJDFkhDVcYpdUqUpm7T4M53Z2JoQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "bufferstreams": "^3.0.0", + "nan": "^2.14.2", + "node-gyp": "^9.0.0" + }, + "bin": { + "ttf2woff2": "bin/ttf2woff2.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -10637,6 +11448,28 @@ "node": ">=4" } }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11526,6 +12359,59 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/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==" + }, + "node_modules/wide-align/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/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==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", @@ -13064,6 +13950,11 @@ } } }, + "@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" + }, "@graphiql/toolkit": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.5.tgz", @@ -13260,6 +14151,57 @@ "fastq": "^1.6.0" } }, + "@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "requires": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "requires": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "dependencies": { + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + } + } + }, "@playwright/test": { "version": "1.28.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.1.tgz", @@ -13280,6 +14222,11 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==" }, + "@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==" + }, "@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -13861,6 +14808,11 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -13923,11 +14875,26 @@ "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", "dev": true }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, + "agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "requires": { + "humanize-ms": "^1.2.1" + } + }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, "requires": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -14026,6 +14993,20 @@ "svg.select.js": "^3.0.1" } }, + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, "arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -14196,7 +15177,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "optional": true, "requires": { "file-uri-to-path": "1.0.0" } @@ -14336,11 +15316,119 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "bufferstreams": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-3.0.0.tgz", + "integrity": "sha512-Qg0ggJUWJq90vtg4lDsGN9CDWvzBMQxhiEkSOD/sJfYt6BLect3eV1/S6K7SCSKJ34n60rf6U5eUPmQENVE4UA==", + "requires": { + "readable-stream": "^3.4.0" + } + }, "bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==" }, + "cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "requires": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + }, + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + }, + "dependencies": { + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + } + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -14468,6 +15556,11 @@ } } }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, "chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -14481,8 +15574,7 @@ "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==" }, "clean-webpack-plugin": { "version": "4.0.0", @@ -14597,6 +15689,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" + }, "colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -14677,6 +15774,11 @@ "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==" }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "consolidate": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", @@ -15419,6 +16521,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -15634,6 +16741,26 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, + "encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "requires": { + "iconv-lite": "^0.6.2" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "enhanced-resolve": { "version": "5.12.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", @@ -15648,11 +16775,21 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" + }, "envinfo": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==" }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -16034,6 +17171,11 @@ "strip-final-newline": "^2.0.0" } }, + "exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==" + }, "expose-loader": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-3.1.0.tgz", @@ -16197,8 +17339,7 @@ "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "optional": true + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, "fill-range": { "version": "7.0.1", @@ -16321,6 +17462,14 @@ "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==" }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + } + }, "fs-monkey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", @@ -16342,6 +17491,56 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "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==" + }, + "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==" + }, + "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==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -16520,6 +17719,11 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "hash-sum": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz", @@ -16607,6 +17811,11 @@ "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.8.4.tgz", "integrity": "sha512-T+1Z9RAZpJ1Ia6cvpV67lstvJ5UQkNpUAulpyfxUCHYbzYcaUVQC/PrcIdPNRU9e1ii6Ec7EEw7GnenavOZB6g==" }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, "http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -16639,6 +17848,16 @@ "requires-port": "^1.0.0" } }, + "http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "requires": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + } + }, "http-proxy-middleware": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", @@ -16662,11 +17881,28 @@ "sshpk": "^1.7.0" } }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" }, + "humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "requires": { + "ms": "^2.0.0" + } + }, "husky": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", @@ -16728,8 +17964,12 @@ "indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" + }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==" }, "inflight": { "version": "1.0.6", @@ -16755,6 +17995,11 @@ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==" }, + "ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + }, "ipaddr.js": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", @@ -16805,6 +18050,11 @@ "is-extglob": "^2.1.1" } }, + "is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==" + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -17427,6 +18677,36 @@ "semver": "^6.0.0" } }, + "make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "requires": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" + } + } + }, "map-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", @@ -17598,6 +18878,85 @@ "brace-expansion": "^1.1.7" } }, + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "requires": { + "yallist": "^4.0.0" + }, + "dependencies": { + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "requires": { + "encoding": "^0.1.13", + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + } + }, + "minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "requires": { + "minipass": "^3.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "dependencies": { + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -17615,8 +18974,7 @@ "nan": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", - "optional": true + "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==" }, "nanoid": { "version": "3.3.4", @@ -17643,11 +19001,68 @@ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==" }, + "node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "requires": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, "node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==" }, + "nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "requires": { + "abbrev": "^1.0.0" + } + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -17671,6 +19086,17 @@ "path-key": "^3.0.0" } }, + "npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "requires": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + } + }, "nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -17806,7 +19232,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, "requires": { "aggregate-error": "^3.0.0" } @@ -18367,6 +19792,27 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==" + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "dependencies": { + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + } + } + }, "prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -18916,6 +20362,11 @@ "send": "0.18.0" } }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -18972,6 +20423,11 @@ "is-fullwidth-code-point": "^4.0.0" } }, + "smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" + }, "sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -18989,6 +20445,25 @@ } } }, + "socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "requires": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + } + }, + "socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "requires": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + } + }, "sortablejs": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", @@ -19074,6 +20549,14 @@ "integrity": "sha512-NXzN+/HPObKAx191H3zKlYomE5WrVIkoCB5IaSdvKokxTpjBdWfr0RaP+1Z5KOfDT0ZVz+2tdtiBkhsEQ9p+0A==", "peer": true }, + "ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "requires": { + "minipass": "^3.1.1" + } + }, "stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -19329,6 +20812,31 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" }, + "tar": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", + "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, "terser": { "version": "5.16.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.0.tgz", @@ -19518,6 +21026,17 @@ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", "dev": true }, + "ttf2woff2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ttf2woff2/-/ttf2woff2-5.0.0.tgz", + "integrity": "sha512-FplhShJd3rT8JGa8N04YWQuP7xRvwr9AIq+9/z5O/5ubqNiCADshKl8v51zJDFkhDVcYpdUqUpm7T4M53Z2JoQ==", + "requires": { + "bindings": "^1.5.0", + "bufferstreams": "^3.0.0", + "nan": "^2.14.2", + "node-gyp": "^9.0.0" + } + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -19591,6 +21110,22 @@ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==" }, + "unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "requires": { + "unique-slug": "^3.0.0" + } + }, + "unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "requires": { + "imurmurhash": "^0.1.4" + } + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -20224,6 +21759,49 @@ "isexe": "^2.0.0" } }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "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==" + }, + "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==" + }, + "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==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, "wildcard": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", diff --git a/package.json b/package.json index 2a4c6e37373..03f71827f8d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react": "^16.14.0", "react-dom": "^16.14.0", "timepicker": "^1.13.18", + "ttf2woff2": "^5.0.0", "typescript": "^4.7.4", "v-tooltip": "^2.0.3", "velocity-animate": "^1.5.0", diff --git a/packages/craftcms-vue/admintable/App.vue b/packages/craftcms-vue/admintable/App.vue index 1bae16f1876..b26ec921f47 100644 --- a/packages/craftcms-vue/admintable/App.vue +++ b/packages/craftcms-vue/admintable/App.vue @@ -18,6 +18,7 @@ :error="action.error" :ajax="action.ajax" v-on:reload="reload" + v-on:click="handleActionClick" > @@ -81,11 +82,16 @@ :per-page="perPage" :no-data-template="noDataTemplate" :query-params="queryParams" + :row-class="rowClass" pagination-path="pagination" @vuetable:loaded="init" @vuetable:loading="loading" @vuetable:pagination-data="onPaginationData" @vuetable:load-success="onLoadSuccess" + @vuetable:cell-clicked="handleCellClicked" + @vuetable:cell-dblclicked="handleCellDoubleClicked" + @vuetable:row-clicked="handleRowClicked" + @vuetable:row-dblclicked="handleRowDoubleClicked" > + + diff --git a/packages/craftcms-vue/admintable/components/AdminTablePagination.vue b/packages/craftcms-vue/admintable/components/AdminTablePagination.vue index cb15187adcf..52e3726cac0 100644 --- a/packages/craftcms-vue/admintable/components/AdminTablePagination.vue +++ b/packages/craftcms-vue/admintable/components/AdminTablePagination.vue @@ -54,11 +54,3 @@ }, }; - - diff --git a/src/Craft.php b/src/Craft.php index ac34c126f57..c329f1db664 100644 --- a/src/Craft.php +++ b/src/Craft.php @@ -17,6 +17,7 @@ use GuzzleHttp\Client; use Symfony\Component\VarDumper\Cloner\VarCloner; use yii\base\ExitException; +use yii\base\InvalidConfigException; use yii\db\Expression; use yii\helpers\VarDumper; use yii\web\Request; @@ -51,6 +52,10 @@ class Craft extends Yii */ public static function createObject($type, array $params = []) { + if (is_array($type) && isset($type['__class']) && isset($type['class'])) { + throw new InvalidConfigException('`__class` and `class` cannot both be specified.'); + } + return parent::createObject($type, $params); } diff --git a/src/base/ApplicationTrait.php b/src/base/ApplicationTrait.php index 51d3dc56633..72123036480 100644 --- a/src/base/ApplicationTrait.php +++ b/src/base/ApplicationTrait.php @@ -49,6 +49,7 @@ use craft\markdown\GithubMarkdown; use craft\markdown\Markdown; use craft\markdown\MarkdownExtra; +use craft\markdown\PreEncodedMarkdown; use craft\models\FieldLayout; use craft\models\Info; use craft\queue\QueueInterface; @@ -283,6 +284,12 @@ trait ApplicationTrait */ private bool $_waitingToSaveInfo = false; + /** + * @var callable[] + * @see onAfterRequest() + */ + private array $afterRequestCallbacks = []; + /** * @inheritdoc */ @@ -459,7 +466,7 @@ public function getIsInitialized(): bool } /** - * Invokes a callback method when Craft is fully initialized. + * Invokes a callback function when Craft is fully initialized. * * If Craft is already fully initialized, the callback will be invoked immediately. * @@ -477,6 +484,45 @@ public function onInit(callable $callback): void } } + /** + * Invokes a callback function at the end of the request. + * + * If the request is already ending, the callback will be invoked immediately. + * + * @param callable $callback + * @since 4.5.11 + */ + public function onAfterRequest(callable $callback): void + { + if (in_array($this->state, [ + Application::STATE_AFTER_REQUEST, + Application::STATE_SENDING_RESPONSE, + Application::STATE_END, + ], true)) { + $callback(); + } else { + $this->afterRequestCallbacks[] = $callback; + } + } + + /** + * @inheritdoc + */ + public function trigger($name, Event $event = null) + { + // call the onAfterRequest() callbacks directly + if ($name === self::EVENT_AFTER_REQUEST && !empty($this->afterRequestCallbacks)) { + $event ??= new Event(); + $event->sender = $this; + $event->name = $name; + while ($callback = array_shift($this->afterRequestCallbacks)) { + $callback($event); + } + } + + parent::trigger($name, $event); + } + /** * Returns whether this Craft install has multiple sites. * @@ -783,16 +829,9 @@ public function saveInfoAfterRequest(): void if (!$this->_waitingToSaveInfo) { $this->_waitingToSaveInfo = true; - // If the request is already over, trigger this immediately - if (in_array($this->state, [ - Application::STATE_AFTER_REQUEST, - Application::STATE_SENDING_RESPONSE, - Application::STATE_END, - ], true)) { + $this->onAfterRequest(function() { $this->saveInfoAfterRequestHandler(); - } else { - Craft::$app->on(WebApplication::EVENT_AFTER_REQUEST, [$this, 'saveInfoAfterRequestHandler']); - } + }); } } @@ -895,10 +934,15 @@ public function getIsDbConnectionValid(): bool try { $this->getDb()->open(); } catch (DbConnectException|InvalidConfigException $e) { - Craft::error('There was a problem connecting to the database: ' . $e->getMessage(), __METHOD__); - /** @var ErrorHandler $errorHandler */ - $errorHandler = $this->getErrorHandler(); - $errorHandler->logException($e); + + // Only log for web requests + if ($this instanceof WebApplication) { + Craft::error('There was a problem connecting to the database: ' . $e->getMessage(), __METHOD__); + /** @var ErrorHandler $errorHandler */ + $errorHandler = $this->getErrorHandler(); + $errorHandler->logException($e); + } + return false; } @@ -1568,13 +1612,14 @@ private function _preInit(): void // Use our own Markdown parser classes $flavors = [ 'original' => Markdown::class, + 'pre-encoded' => PreEncodedMarkdown::class, 'gfm' => GithubMarkdown::class, 'gfm-comment' => GithubMarkdown::class, 'extra' => MarkdownExtra::class, ]; foreach ($flavors as $flavor => $class) { - if (isset(MarkdownHelper::$flavors[$flavor]) && !is_object(MarkdownHelper::$flavors[$flavor])) { + if (!isset(MarkdownHelper::$flavors[$flavor]) || !is_object(MarkdownHelper::$flavors[$flavor])) { MarkdownHelper::$flavors[$flavor]['class'] = $class; } } diff --git a/src/base/Element.php b/src/base/Element.php index 4e0d113d1fa..42c4a199a38 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -73,7 +73,6 @@ use craft\validators\SlugValidator; use craft\validators\StringValidator; use craft\web\UploadedFile; -use DateTime; use Illuminate\Support\Collection; use Throwable; use Traversable; @@ -277,26 +276,26 @@ abstract class Element extends Component implements ElementInterface * * ```php * use craft\base\Element; + * use craft\base\ElementInterface; * use craft\db\Query; * use craft\elements\Entry; * use craft\events\DefineEagerLoadingMapEvent; - * use craft\helpers\ArrayHelper; * use yii\base\Event; * * // Add support for `with(['bookClub'])` to entries * Event::on( * Entry::class, * Element::EVENT_DEFINE_EAGER_LOADING_MAP, - * function(DefineEagerLoadingMapEvent $e) { - * if ($e->handle === 'bookClub') { - * $bookEntryIds = ArrayHelper::getColumn($e->elements, 'id'); - * $e->elementType = \my\plugin\BookClub::class, - * $e->map = (new Query) + * function(DefineEagerLoadingMapEvent $event) { + * if ($event->handle === 'bookClub') { + * $bookEntryIds = array_map(fn(ElementInterface $element) => $element->id, $event->elements); + * $event->elementType = \my\plugin\BookClub::class, + * $event->map = (new Query) * ->select(['source' => 'bookId', 'target' => 'clubId']) * ->from('{{%bookclub_books}}') * ->where(['bookId' => $bookEntryIds]) * ->all(); - * $e->handled = true; + * $event->handled = true; * } * } * ); @@ -574,8 +573,8 @@ abstract class Element extends Component implements ElementInterface * * Note that [[EVENT_DEFINE_URL]] will still be called regardless of what happens with this event. * - * @since 4.4.6 * @see getUrl() + * @since 4.4.6 */ public const EVENT_BEFORE_DEFINE_URL = 'beforeDefineUrl'; @@ -609,8 +608,8 @@ abstract class Element extends Component implements ElementInterface * To prevent the element from getting a URL, ensure `$event->url` is set to `null`, * and set `$event->handled` to `true`. * - * @since 4.3.0 * @see getUrl() + * @since 4.3.0 */ public const EVENT_DEFINE_URL = 'defineUrl'; @@ -1424,7 +1423,7 @@ public static function eagerLoadingMap(array $sourceElements, string $handle): a private static function _mapDescendants(array $sourceElements, bool $children): ?array { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); // Get the structure data for these elements $selectColumns = ['structureId', 'elementId', 'lft', 'rgt']; @@ -1510,7 +1509,7 @@ private static function _mapDescendants(array $sourceElements, bool $children): private static function _mapAncestors(array $sourceElements, bool $parents): ?array { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); // Get the structure data for these elements $selectColumns = ['structureId', 'elementId', 'lft', 'rgt']; @@ -1623,6 +1622,9 @@ private static function _mapLocalized(array $sourceElements): array 'map' => $map, 'criteria' => [ 'siteId' => $otherSiteIds, + 'drafts' => null, + 'provisionalDrafts' => null, + 'revisions' => null, ], ]; } @@ -1636,7 +1638,7 @@ private static function _mapLocalized(array $sourceElements): array private static function _mapCurrentRevisions(array $sourceElements): array { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); $map = (new Query()) ->select([ @@ -1666,7 +1668,7 @@ private static function _mapCurrentRevisions(array $sourceElements): array private static function _mapDrafts(array $sourceElements): array { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); $map = (new Query()) ->select([ @@ -1694,7 +1696,7 @@ private static function _mapDrafts(array $sourceElements): array private static function _mapRevisions(array $sourceElements): array { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); $map = (new Query()) ->select([ @@ -1722,7 +1724,7 @@ private static function _mapRevisions(array $sourceElements): array private static function _mapDraftCreators(array $sourceElements): array { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); $map = (new Query()) ->select([ @@ -1750,7 +1752,7 @@ private static function _mapDraftCreators(array $sourceElements): array private static function _mapRevisionCreators(array $sourceElements): array { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); $map = (new Query()) ->select([ @@ -2261,41 +2263,45 @@ public function init(): void */ public function attributes(): array { - $names = parent::attributes(); + $names = array_flip(parent::attributes()); if ($this->structureId) { - $names[] = 'parentId'; + $names['parentId'] = true; } else { - ArrayHelper::removeValue($names, 'structureId'); - ArrayHelper::removeValue($names, 'root'); - ArrayHelper::removeValue($names, 'lft'); - ArrayHelper::removeValue($names, 'rgt'); - ArrayHelper::removeValue($names, 'level'); + unset( + $names['structureId'], + $names['root'], + $names['lft'], + $names['rgt'], + $names['level'], + ); } - ArrayHelper::removeValue($names, 'searchScore'); - ArrayHelper::removeValue($names, 'awaitingFieldValues'); - ArrayHelper::removeValue($names, 'firstSave'); - ArrayHelper::removeValue($names, 'propagating'); - ArrayHelper::removeValue($names, 'propagateAll'); - ArrayHelper::removeValue($names, 'newSiteIds'); - ArrayHelper::removeValue($names, 'resaving'); - ArrayHelper::removeValue($names, 'duplicateOf'); - ArrayHelper::removeValue($names, 'mergingCanonicalChanges'); - ArrayHelper::removeValue($names, 'updatingFromDerivative'); - ArrayHelper::removeValue($names, 'previewing'); - ArrayHelper::removeValue($names, 'hardDelete'); + unset( + $names['searchScore'], + $names['awaitingFieldValues'], + $names['firstSave'], + $names['propagating'], + $names['propagateAll'], + $names['newSiteIds'], + $names['resaving'], + $names['duplicateOf'], + $names['mergingCanonicalChanges'], + $names['updatingFromDerivative'], + $names['previewing'], + $names['hardDelete'], + ); - $names[] = 'canonicalId'; - $names[] = 'isDraft'; - $names[] = 'isRevision'; - $names[] = 'isUnpublishedDraft'; - $names[] = 'ref'; - $names[] = 'status'; - $names[] = 'structureId'; - $names[] = 'url'; + $names['canonicalId'] = true; + $names['isDraft'] = true; + $names['isRevision'] = true; + $names['isUnpublishedDraft'] = true; + $names['ref'] = true; + $names['status'] = true; + $names['structureId'] = true; + $names['url'] = true; - return $names; + return array_keys($names); } /** @@ -2507,6 +2513,18 @@ public function afterValidate(): void } } + if (Craft::$app->getRequest()->getIsCpRequest()) { + $allErrors = $this->getErrors(); + $this->clearErrors(); + foreach ($allErrors as $attribute => &$errors) { + $label = $this->getAttributeLabel($attribute); + foreach ($errors as &$error) { + $error = str_replace($label, "*$label*", $error); + } + } + $this->addErrors($allErrors); + } + parent::afterValidate(); } @@ -3476,11 +3494,11 @@ public function getLocalized(): ElementQueryInterface|Collection ->id($this->id ?: false) ->structureId($this->structureId) ->siteId(['not', $this->siteId]) - ->drafts($this->getIsDraft()) + ->drafts(null) // the provisionalDraft state could have just changed (e.g. `elements/save-draft`) // so don't filter based on one or the other ->provisionalDrafts(null) - ->revisions($this->getIsRevision()); + ->revisions(null); } /** @@ -4305,7 +4323,7 @@ public function isFieldDirty(string $fieldHandle): bool public function getDirtyFields(): array { if ($this->_allDirty()) { - return ArrayHelper::getColumn($this->fieldLayoutFields(), 'handle'); + return array_map(fn(FieldInterface $field) => $field->handle, $this->fieldLayoutFields()); } return array_keys($this->_dirtyFields); @@ -4907,6 +4925,7 @@ protected function slugFieldHtml(bool $static): string } return Cp::textFieldHtml([ + 'status' => $this->getAttributeStatus('slug'), 'label' => Craft::t('app', 'Slug'), 'siteId' => $this->siteId, 'translatable' => $this->getIsSlugTranslatable(), @@ -4922,9 +4941,9 @@ protected function slugFieldHtml(bool $static): string } /** - * Whether status field should be shown for this element. - * If set to `false`, status can't be updated via editing entry, action or resave command. - * `true` for all elements by default for backwards compatibility. + * Returns whether the Status field should be shown for this element. + * + * If set to `false`, the element’s status can't be updated via edit forms, the Set Status action, or `resave/*` commands. * * @return bool * @since 4.5.0 @@ -5206,6 +5225,32 @@ public function afterDelete(): void } } + /** + * @inheritdoc + */ + public function beforeDeleteForSite(): bool + { + // Tell the fields about it + foreach ($this->fieldLayoutFields() as $field) { + if (!$field->beforeElementDeleteForSite($this)) { + return false; + } + } + + return true; + } + + /** + * @inheritdoc + */ + public function afterDeleteForSite(): void + { + // Tell the fields about it + foreach ($this->fieldLayoutFields() as $field) { + $field->afterElementDeleteForSite($this); + } + } + /** * @inheritdoc */ diff --git a/src/base/ElementInterface.php b/src/base/ElementInterface.php index 57bb80d974c..44cdb96384d 100644 --- a/src/base/ElementInterface.php +++ b/src/base/ElementInterface.php @@ -505,14 +505,14 @@ public static function defaultTableAttributes(string $source): array; * query result data, and the first source element that the result was eager-loaded for * * ```php + * use craft\base\ElementInterface; * use craft\db\Query; - * use craft\helpers\ArrayHelper; * * public static function eagerLoadingMap(array $sourceElements, string $handle) * { * switch ($handle) { * case 'author': - * $bookIds = ArrayHelper::getColumn($sourceElements, 'id'); + * $bookIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); * $map = (new Query) * ->select(['source' => 'id', 'target' => 'authorId']) * ->from('{{%books}}') @@ -523,7 +523,7 @@ public static function defaultTableAttributes(string $source): array; * 'map' => $map, * ]; * case 'bookClubs': - * $bookIds = ArrayHelper::getColumn($sourceElements, 'id'); + * $bookIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); * $map = (new Query) * ->select(['source' => 'bookId', 'target' => 'clubId']) * ->from('{{%bookclub_books}}') @@ -1715,6 +1715,21 @@ public function beforeDelete(): bool; */ public function afterDelete(): void; + /** + * Performs actions before an element is deleted for a site. + * + * @return bool Whether the element should be deleted + * @since 4.7.0 + */ + public function beforeDeleteForSite(): bool; + + /** + * Performs actions after an element is deleted for a site. + * + * @since 4.7.0 + */ + public function afterDeleteForSite(): void; + /** * Performs actions before an element is restored. * diff --git a/src/base/ElementTrait.php b/src/base/ElementTrait.php index 7ab8dd07523..c0caca6aff1 100644 --- a/src/base/ElementTrait.php +++ b/src/base/ElementTrait.php @@ -163,6 +163,13 @@ trait ElementTrait */ public bool $propagating = false; + /** + * @var bool Whether the element is currently being validated via BaseRelationField::validateRelatedElements() + * @since 4.5.10 + * @deprecated in 4.5.13 + */ + public bool $validatingRelatedElement = false; + /** * @var bool Whether all element attributes should be propagated across all its supported sites, even if that means * overwriting existing site-specific values. diff --git a/src/base/Field.php b/src/base/Field.php index ec08654694d..e3db671c99f 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -331,6 +331,11 @@ protected function defineRules(): array 'propagateAll', 'propagating', 'ref', + 'relatedToAssets', + 'relatedToCategories', + 'relatedToEntries', + 'relatedToTags', + 'relatedToUsers', 'resaving', 'revisionId', 'rgt', @@ -845,6 +850,22 @@ public function afterElementDelete(ElementInterface $element): void } } + /** + * @inheritdoc + */ + public function beforeElementDeleteForSite(ElementInterface $element): bool + { + return true; + } + + /** + * @inheritdoc + */ + public function afterElementDeleteForSite(ElementInterface $element): void + { + // carry on + } + /** * @inheritdoc */ diff --git a/src/base/FieldInterface.php b/src/base/FieldInterface.php index cba00db4f03..7cc7dd79460 100644 --- a/src/base/FieldInterface.php +++ b/src/base/FieldInterface.php @@ -98,6 +98,11 @@ public static function valueType(): string; * array, whose keys match the keys returned by this method. The field type should also override * [[serializeValue()]] to ensure values are being returned as associative arrays using the same keys. * + * ::: warning + * JSON columns do not work with MariaDB, so they should not be used by plugins which will be + * shared publicly. + * ::: + * * @return string|string[] The column type(s). [[\yii\db\QueryBuilder::getColumnType()]] will be called * to convert the give column type to the physical one. For example, `string` will be converted * as `varchar(255)` and `string(100)` becomes `varchar(100)`. `not null` will automatically be @@ -363,6 +368,8 @@ public function getSearchKeywords(mixed $value, ElementInterface $element): stri * - If an existing element was retrieved from the database, the value will be whatever is stored in the field’s * `content` table column. (Or if the field doesn’t have a `content` table column per [[hasContentColumn()]], * the value will be `null`.) + * - If the field is being cleared out (e.g. via the `resave/entries` command with `--to :empty:`), + * the value will be an empty string (`''`). * * There are cases where a pre-normalized value could be passed in as well, so be sure to account for that. * @@ -533,6 +540,23 @@ public function beforeElementDelete(ElementInterface $element): bool; */ public function afterElementDelete(ElementInterface $element): void; + /** + * Performs actions before an element is deleted for a site. + * + * @param ElementInterface $element The element that is about to be deleted + * @return bool Whether the element should be deleted for a site + * @since 4.7.0 + */ + public function beforeElementDeleteForSite(ElementInterface $element): bool; + + /** + * Performs actions after the element has been deleted. + * + * @param ElementInterface $element The element that was just deleted for a site + * @since 4.7.0 + */ + public function afterElementDeleteForSite(ElementInterface $element): void; + /** * Performs actions before an element is restored. * diff --git a/src/base/Fs.php b/src/base/Fs.php index 5d147b2cba0..a62e9c62780 100644 --- a/src/base/Fs.php +++ b/src/base/Fs.php @@ -54,6 +54,7 @@ public function attributeLabels(): array return [ 'handle' => Craft::t('app', 'Handle'), 'name' => Craft::t('app', 'Name'), + 'url' => Craft::t('app', 'Base URL'), ]; } @@ -80,6 +81,12 @@ protected function defineRules(): array { $rules = parent::defineRules(); $rules[] = [['name', 'handle'], 'required']; + $rules[] = [ + 'url', + 'required', + 'when' => fn(self $fs) => $fs->hasUrls && $this->getShowUrlSetting(), + ]; + $rules[] = [ ['handle'], HandleValidator::class, diff --git a/src/base/LogTargetTrait.php b/src/base/LogTargetTrait.php index 3612f2f480b..9007ceca133 100644 --- a/src/base/LogTargetTrait.php +++ b/src/base/LogTargetTrait.php @@ -27,8 +27,8 @@ trait LogTargetTrait { /** * @var bool Whether the user IP should be included in the default log prefix. - * @since 3.0.25 * @see Target::$prefix + * @since 3.0.25 */ public bool $includeUserIp = false; diff --git a/src/base/MemoizableArray.php b/src/base/MemoizableArray.php index 98a92503b1f..0a1c5dc181d 100644 --- a/src/base/MemoizableArray.php +++ b/src/base/MemoizableArray.php @@ -39,6 +39,16 @@ class MemoizableArray implements IteratorAggregate, Countable */ private array $_elements; + /** + * @var callable|null Normalizer method + */ + private $_normalizer; + + /** + * @var array Normalized elements + */ + private array $_normalized = []; + /** * @var array Memoized array elements */ @@ -46,10 +56,41 @@ class MemoizableArray implements IteratorAggregate, Countable /** * Constructor + * + * @param array $elements The items to be memoized + * @param callable|null $normalizer A method that the items should be normalized with when first returned by + * [[all()]] or [[firstWhere()]]. */ - public function __construct(array $elements) + public function __construct(array $elements, ?callable $normalizer = null) { $this->_elements = $elements; + $this->_normalizer = $normalizer; + } + + private function normalize(array $elements): array + { + if (!isset($this->_normalizer)) { + return $elements; + } + + return array_values(array_map(fn($key) => $this->normalizeByKey($key), array_keys($elements))); + } + + private function normalizeByKey(int|string|null $key): mixed + { + if ($key === null) { + return null; + } + + if (!isset($this->_normalizer)) { + return $this->_elements[$key]; + } + + if (!isset($this->_normalized[$key])) { + $this->_normalized[$key] = call_user_func($this->_normalizer, $this->_elements[$key], $key); + } + + return $this->_normalized[$key]; } /** @@ -60,7 +101,7 @@ public function __construct(array $elements) */ public function all(): array { - return $this->_elements; + return $this->normalize($this->_elements); } /** @@ -78,7 +119,10 @@ public function where(string $key, mixed $value = true, bool $strict = false): s $memKey = $this->_memKey(__METHOD__, $key, $value, $strict); if (!isset($this->_memoized[$memKey])) { - $this->_memoized[$memKey] = new MemoizableArray(ArrayHelper::where($this, $key, $value, $strict, false)); + $this->_memoized[$memKey] = new MemoizableArray( + ArrayHelper::where($this->_elements, $key, $value, $strict), + isset($this->_normalizer) ? fn($element, $key) => $this->normalizeByKey($key) : null, + ); } return $this->_memoized[$memKey]; @@ -100,7 +144,10 @@ public function whereIn(string $key, array $values, bool $strict = false): self $memKey = $this->_memKey(__METHOD__, $key, $values, $strict); if (!isset($this->_memoized[$memKey])) { - $this->_memoized[$memKey] = new MemoizableArray(ArrayHelper::whereIn($this, $key, $values, $strict, false)); + $this->_memoized[$memKey] = new MemoizableArray( + ArrayHelper::whereIn($this->_elements, $key, $values, $strict), + isset($this->_normalizer) ? fn($element, $key) => $this->normalizeByKey($key) : null, + ); } return $this->_memoized[$memKey]; @@ -112,7 +159,7 @@ public function whereIn(string $key, array $values, bool $strict = false): self * @param string $key the column name whose result will be used to index the array * @param mixed $value the value that `$key` should be compared with * @param bool $strict whether a strict type comparison should be used when checking array element values against `$value` - * @return T the first matching value, or `null` if no match is found + * @return T|null the first matching value, or `null` if no match is found */ public function firstWhere(string $key, mixed $value = true, bool $strict = false) { @@ -120,7 +167,8 @@ public function firstWhere(string $key, mixed $value = true, bool $strict = fals // Use array_key_exists() because it could be null if (!array_key_exists($memKey, $this->_memoized)) { - $this->_memoized[$memKey] = ArrayHelper::firstWhere($this, $key, $value, $strict); + ArrayHelper::firstWhere($this->_elements, $key, $value, $strict, valueKey: $valueKey); + $this->_memoized[$memKey] = $this->normalizeByKey($valueKey); } return $this->_memoized[$memKey]; @@ -148,7 +196,7 @@ private function _memKey(string $method, string $key, mixed $value, bool $strict */ public function getIterator(): ArrayIterator { - return new ArrayIterator($this->_elements); + return new ArrayIterator($this->normalize($this->_elements)); } /** diff --git a/src/base/WidgetTrait.php b/src/base/WidgetTrait.php index f50a11ad456..e1b7e6bdef3 100644 --- a/src/base/WidgetTrait.php +++ b/src/base/WidgetTrait.php @@ -16,7 +16,7 @@ trait WidgetTrait { /** - * @var int|null The user’s chosen cospan for the widget + * @var int|null The user’s chosen colspan for the widget */ public ?int $colspan = null; } diff --git a/src/base/conditions/BaseCondition.php b/src/base/conditions/BaseCondition.php index 36fe17d106b..af46e6b6cc8 100644 --- a/src/base/conditions/BaseCondition.php +++ b/src/base/conditions/BaseCondition.php @@ -461,19 +461,32 @@ private function _ruleTypeMenu( if ($rule) { $ruleLabel = $rule->getLabel(); + $hint = $rule->getLabelHint(); $groupLabel = $rule->getGroupLabel() ?? '__UNGROUPED__'; + $groupedRuleTypeOptions[$groupLabel] = [ - ['value' => $ruleValue, 'label' => $ruleLabel], + [ + 'label' => $ruleLabel, + 'hint' => $hint, + 'value' => $ruleValue, + ], ]; - $labelsByGroup[$groupLabel][$ruleLabel] = true; + $labelsByGroup[$groupLabel][$hint] = true; } foreach ($selectableRules as $value => $selectableRule) { $label = $selectableRule->getLabel(); + $hint = $selectableRule->getLabelHint(); + $key = $label . ($hint !== null ? " - $hint" : ''); $groupLabel = $selectableRule->getGroupLabel() ?? '__UNGROUPED__'; - if (!isset($labelsByGroup[$groupLabel][$label])) { - $groupedRuleTypeOptions[$groupLabel][] = compact('value', 'label'); - $labelsByGroup[$groupLabel][$label] = true; + + if (!isset($labelsByGroup[$groupLabel][$key])) { + $groupedRuleTypeOptions[$groupLabel][] = [ + 'label' => $label, + 'hint' => $hint, + 'value' => $value, + ]; + $labelsByGroup[$groupLabel][$key] = true; } } @@ -491,17 +504,30 @@ private function _ruleTypeMenu( $optionsHtml .= Html::tag('hr', options: ['class' => 'padded']) . Html::tag('h6', Html::encode($groupLabel), ['class' => 'padded']); } - ArrayHelper::multisort($groupRuleTypeOptions, 'label'); + ArrayHelper::multisort($groupRuleTypeOptions, ['label', 'hint']); $optionsHtml .= Html::beginTag('ul', ['class' => 'padded']) . - implode("\n", array_map(fn(array $option) => Html::beginTag('li') . - Html::a(Html::encode($option['label']), options: [ + implode("\n", array_map(function(array $option) use ($ruleValue) { + $html = Html::beginTag('li'); + + $label = Html::encode($option['label']); + if ($option['hint'] !== null) { + $label .= ' ' . + Html::tag('span', sprintf('– %s', Html::encode($option['hint'])), [ + 'class' => 'light', + ]); + } + + $html .= Html::a($label, options: [ 'class' => $option['value'] === $ruleValue ? 'sel' : false, 'data' => [ 'value' => $option['value'], ], - ]) . - Html::endTag('li'), + ]); + $html .= Html::endTag('li'); + + return $html; + }, $groupRuleTypeOptions)) . Html::endTag('ul'); } diff --git a/src/base/conditions/BaseConditionRule.php b/src/base/conditions/BaseConditionRule.php index b78d54a336b..e31172d9503 100644 --- a/src/base/conditions/BaseConditionRule.php +++ b/src/base/conditions/BaseConditionRule.php @@ -44,6 +44,14 @@ public static function supportsProjectConfig(): bool return true; } + /** + * @inheritdoc + */ + public function getLabelHint(): ?string + { + return null; + } + /** * @var string|null UUID */ diff --git a/src/base/conditions/BaseDateRangeConditionRule.php b/src/base/conditions/BaseDateRangeConditionRule.php index 449d569d3f6..f3f482dbdd4 100644 --- a/src/base/conditions/BaseDateRangeConditionRule.php +++ b/src/base/conditions/BaseDateRangeConditionRule.php @@ -326,7 +326,7 @@ protected function queryParamValue(): array|string|null return array_filter([ 'and', $this->_startDate ? ">= $this->_startDate" : null, - $this->_endDate ? "< $this->_endDate" : null, + $this->_endDate ? "< " . DateTimeHelper::toIso8601($this->_inclusiveEndDate()) : null, ]); case DateRange::TYPE_BEFORE: @@ -366,7 +366,7 @@ protected function matchValue(?DateTime $value): bool case DateRange::TYPE_RANGE: return ( (!$this->_startDate || ($value && $value >= DateTimeHelper::toDateTime($this->_startDate))) && - (!$this->_endDate || ($value && $value < DateTimeHelper::toDateTime($this->_endDate))) + (!$this->_endDate || ($value && $value < $this->_inclusiveEndDate())) ); case DateRange::TYPE_BEFORE: @@ -394,4 +394,9 @@ protected function matchValue(?DateTime $value): bool return $value && $value >= $startDate && $value < $endDate; } } + + private function _inclusiveEndDate(): DateTime + { + return DateTimeHelper::toDateTime($this->_endDate)->modify('+1 day'); + } } diff --git a/src/base/conditions/ConditionRuleInterface.php b/src/base/conditions/ConditionRuleInterface.php index aea30cbe1a6..250d15a958a 100644 --- a/src/base/conditions/ConditionRuleInterface.php +++ b/src/base/conditions/ConditionRuleInterface.php @@ -33,6 +33,14 @@ public static function supportsProjectConfig(): bool; */ public function getLabel(): string; + /** + * Returns the rule’s option label hint. + * + * @return string|null + * @since 4.6.0 + */ + public function getLabelHint(): ?string; + /** * Returns the optgroup label the condition rule should be grouped under. * diff --git a/src/config/DbConfig.php b/src/config/DbConfig.php index 382510f7fa0..a38a1a2167c 100644 --- a/src/config/DbConfig.php +++ b/src/config/DbConfig.php @@ -534,8 +534,8 @@ public function setSchemaOnConnect(bool $value = true): self * @param string|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $tablePrefix + * @since 4.2.0 */ public function tablePrefix(?string $value): self { @@ -627,8 +627,8 @@ public function url(?string $value): self * @param string|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $driver + * @since 4.2.0 */ public function driver(?string $value): self { @@ -647,8 +647,8 @@ public function driver(?string $value): self * @param string|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $server + * @since 4.2.0 */ public function server(?string $value): self { @@ -667,8 +667,8 @@ public function server(?string $value): self * @param int|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $port + * @since 4.2.0 */ public function port(?int $value): self { @@ -688,8 +688,8 @@ public function port(?int $value): self * @param string|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $unixSocket + * @since 4.2.0 */ public function unixSocket(?string $value): self { @@ -708,8 +708,8 @@ public function unixSocket(?string $value): self * @param string|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $database + * @since 4.2.0 */ public function database(?string $value): self { diff --git a/src/config/GeneralConfig.php b/src/config/GeneralConfig.php index 77757c78c73..2b6c5738d38 100644 --- a/src/config/GeneralConfig.php +++ b/src/config/GeneralConfig.php @@ -82,8 +82,8 @@ class GeneralConfig extends BaseConfig * ]) * ``` * - * @since 3.6.4 * @group System + * @since 3.6.4 */ public array $accessibilityDefaults = [ 'alwaysShowFocusRings' => false, @@ -178,8 +178,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.1.0 * @group System + * @since 3.1.0 */ public bool $allowAdminChanges = true; @@ -200,8 +200,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.5.0 * @group GraphQL + * @since 3.5.0 */ public array|null|false $allowedGraphqlOrigins = null; @@ -387,22 +387,17 @@ class GeneralConfig extends BaseConfig /** * @var bool Whether drafts should be saved automatically as they are edited. * - * ::: warning - * Disabling this will also disable Live Preview. - * ::: + * Note that drafts *will* be autosaved while Live Preview is open, regardless of this setting. * * ::: code - * ```php Static Config - * ->autosaveDrafts(false) - * ``` * ```shell Environment Override * CRAFT_AUTOSAVE_DRAFTS=false * ``` * ::: * + * @group System * @since 3.5.6 * @deprecated in 4.0.0 - * @group System */ public bool $autosaveDrafts = true; @@ -516,8 +511,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.5.0 * @group Image Handling + * @since 3.5.0 */ public ?string $brokenImagePath = null; @@ -535,8 +530,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 4.0.0 * @group Environment + * @since 4.0.0 */ public ?string $buildId = null; @@ -623,8 +618,8 @@ class GeneralConfig extends BaseConfig * ]) * ``` * - * @since 3.5.0 * @group System + * @since 3.5.0 */ public array $cpHeadTags = []; @@ -743,8 +738,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.5.0 * @group System + * @since 3.5.0 */ public ?string $defaultCpLocale = null; @@ -954,11 +949,31 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.1.9 * @group System + * @since 3.1.9 */ public string|array|null $disabledPlugins = null; + /** + * @var string[] Array of utility IDs that should be disabled. + * + * ::: code + * ```php Static Config + * ->disabledUtilities([ + * 'updates', + * 'find-replace', + * ]) + * ``` + * ```shell Environment Override + * CRAFT_DISABLED_UTILITIES=updates,find-replace + * ``` + * ::: + * + * @group System + * @since 4.6.0 + */ + public array $disabledUtilities = []; + /** * @var bool Whether front end requests should respond with `X-Robots-Tag: none` HTTP headers, indicating that pages should not be indexed, * and links on the page should not be followed, by web crawlers. @@ -976,8 +991,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.5.10 * @group System + * @since 3.5.10 */ public bool $disallowRobots = false; @@ -993,8 +1008,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.0 * @group GraphQL + * @since 3.6.0 */ public bool $disableGraphqlTransformDirective = false; @@ -1010,8 +1025,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.5.0 * @group Security + * @since 3.5.0 */ public bool $enableBasicHttpAuth = false; @@ -1046,8 +1061,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.0 * @group GraphQL + * @since 3.6.0 */ public bool $enableGraphqlIntrospection = true; @@ -1065,8 +1080,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.3.1 * @group GraphQL + * @since 3.3.1 */ public bool $enableGql = true; @@ -1127,8 +1142,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.3.12 * @group GraphQL + * @since 3.3.12 */ public bool $enableGraphqlCaching = true; @@ -1144,8 +1159,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.7.0 * @group GraphQL + * @since 3.7.0 */ public bool $setGraphqlDatesToSystemTimeZone = false; @@ -1213,8 +1228,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.0.24 * @group System + * @since 3.0.24 */ public ?array $extraAppLocales = null; @@ -1241,8 +1256,8 @@ class GeneralConfig extends BaseConfig * the config setting. * ::: * - * @since 3.0.37 * @group Assets + * @since 3.0.37 */ public array $extraFileKinds = []; @@ -1364,8 +1379,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.0 * @group System + * @since 3.6.0 */ public string $handleCasing = self::CAMEL_CASE; @@ -1396,8 +1411,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.3.0 * @group System + * @since 3.3.0 */ public bool $headlessMode = false; @@ -1644,9 +1659,25 @@ class GeneralConfig extends BaseConfig */ public int $maxCachedCloudImageSize = 2000; + /** + * @var int The maximum allowed GraphQL queries that can be executed in a single batched request. Set to `0` to allow any number of queries. + * + * ::: code + * ```php Static Config + * ->maxGraphqlBatchSize(5) + * ``` + * ```shell Environment Override + * CRAFT_MAX_GRAPHQL_BATCH_SIZE=5 + * ``` + * ::: + * + * @group GraphQL + * @since 4.5.5 + */ + public int $maxGraphqlBatchSize = 0; + /** * @var int The maximum allowed complexity a GraphQL query is allowed to have. Set to `0` to allow any complexity. - * @since 3.6.0 * * ::: code * ```php Static Config @@ -1658,6 +1689,7 @@ class GeneralConfig extends BaseConfig * ::: * * @group GraphQL + * @since 3.6.0 */ public int $maxGraphqlComplexity = 0; @@ -1673,8 +1705,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.0 * @group GraphQL + * @since 3.6.0 */ public int $maxGraphqlDepth = 0; @@ -1690,8 +1722,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.0 * @group GraphQL + * @since 3.6.0 */ public int $maxGraphqlResults = 0; @@ -1742,8 +1774,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.2.0 * @group System + * @since 3.2.0 */ public ?int $maxRevisions = 50; @@ -1899,8 +1931,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.14 * @group System + * @since 3.6.14 */ public ?string $permissionsPolicyHeader = null; @@ -2015,8 +2047,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.6 * @group GraphQL + * @since 3.6.6 */ public bool $prefixGqlRootTypes = true; @@ -2059,8 +2091,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.0.8 * @group Image Handling + * @since 3.0.8 */ public bool $preserveCmykColorspace = false; @@ -2132,8 +2164,8 @@ class GeneralConfig extends BaseConfig * ]) * ``` * - * @since 3.5.0 * @group System + * @since 3.5.0 */ public array $previewIframeResizerOptions = []; @@ -2226,9 +2258,9 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.3.0 * @group Garbage Collection * @defaultAlt 90 days + * @since 3.3.0 */ public mixed $purgeStaleUserSessionDuration = 7776000; @@ -2248,9 +2280,9 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.2.0 * @group Garbage Collection * @defaultAlt 30 days + * @since 3.2.0 */ public mixed $purgeUnsavedDraftsDuration = 2592000; @@ -2268,8 +2300,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.0 * @group Image Handling + * @since 3.6.0 */ public bool $rasterizeSvgThumbs = false; @@ -2420,8 +2452,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.7.0 * @group Assets + * @since 3.7.0 */ public bool $revAssetUrls = false; @@ -2482,8 +2514,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.6.0 * @group Security + * @since 3.6.0 */ public bool $sanitizeCpImageUploads = true; @@ -2502,8 +2534,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.1.33 * @group System + * @since 3.1.33 */ public ?string $sameSiteCookieValue = null; @@ -2550,8 +2582,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.7.3 * @group System + * @since 3.7.3 */ public bool $sendContentLengthHeader = false; @@ -2655,8 +2687,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.5.0 * @group Routing + * @since 3.5.0 */ public string $siteToken = 'siteToken'; @@ -2728,6 +2760,23 @@ class GeneralConfig extends BaseConfig */ public ?array $secureProtocolHeaders = null; + /** + * @var bool Whether “First Name” and “Last Name” fields should be shown in place of “Full Name” fields. + * + * ::: code + * ```php Static Config + * ->showFirstAndLastNameFields() + * ``` + * ```shell Environment Override + * CRAFT_SHOW_FIRST_AND_LAST_NAME_FIELDS=true + * ``` + * ::: + * + * @group Users + * @since 4.6.0 + */ + public bool $showFirstAndLastNameFields = false; + /** * @var mixed The amount of time before a soft-deleted item will be up for hard-deletion by garbage collection. * @@ -2744,9 +2793,9 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.1.0 * @group Garbage Collection * @defaultAlt 30 days + * @since 3.1.0 */ public mixed $softDeleteDuration = 2592000; @@ -2762,8 +2811,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.1.0 * @group Security + * @since 3.1.0 */ public bool $storeUserIps = false; @@ -2817,8 +2866,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.0.7 * @group Image Handling + * @since 3.0.7 */ public bool $transformGifs = true; @@ -2834,8 +2883,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.7.1 * @group Image Handling + * @since 3.7.1 */ public bool $transformSvgs = true; @@ -2904,8 +2953,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.4.0 * @group Image Handling + * @since 3.4.0 */ public bool $upscaleImages = true; @@ -2958,8 +3007,8 @@ class GeneralConfig extends BaseConfig * ``` * ::: * - * @since 3.5.5 * @group System + * @since 3.5.5 */ public bool $useIframeResizer = false; @@ -3102,8 +3151,8 @@ class GeneralConfig extends BaseConfig * ::: * * @see getVerifyEmailPath() - * @since 3.4.0 * @group Routing + * @since 3.4.0 */ public mixed $verifyEmailPath = 'verifyemail'; @@ -3122,8 +3171,8 @@ class GeneralConfig extends BaseConfig * ::: * * @see getVerifyEmailSuccessPath() - * @since 3.1.20 * @group Routing + * @since 3.1.20 */ public mixed $verifyEmailSuccessPath = ''; @@ -3799,8 +3848,8 @@ public function defaultCountryCode(string $value): self * @param string|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $defaultCpLanguage + * @since 4.2.0 */ public function defaultCpLanguage(?string $value): self { @@ -4057,7 +4106,7 @@ public function devMode(bool $value = true): self * ```php * ->dev([ * 'disabledPlugins' => '*', - * ], + * ]) * ``` * * ::: warning @@ -4085,6 +4134,33 @@ public function disabledPlugins(string|array|null $value): self return $this; } + /** + * Array of utility IDs that should be disabled. + * + * ::: code + * ```php Static Config + * ->disabledUtilities([ + * 'updates', + * 'find-replace', + * ]) + * ``` + * ```shell Environment Override + * CRAFT_DISABLED_UTILITIES=updates,find-replace + * ``` + * ::: + * + * @group System + * @param string[] $value + * @return self + * @see $disabledUtilities + * @since 4.6.0 + */ + public function disabledUtilities(array $value): self + { + $this->disabledUtilities = $value; + return $this; + } + /** * Whether front end requests should respond with `X-Robots-Tag: none` HTTP headers, indicating that pages should not be indexed, * and links on the page should not be followed, by web crawlers. @@ -4370,8 +4446,8 @@ public function extraAllowedFileExtensions(?array $value): self * @param string[]|null $value * @return self * @throws InvalidConfigException - * @since 4.2.0 * @see $extraAppLocales + * @since 4.2.0 */ public function extraAppLocales(?array $value): self { @@ -4866,6 +4942,25 @@ public function maxCachedCloudImageSize(int $value): self return $this; } + /** + * The maximum allowed GraphQL queries that can be executed in a single batched request. Set to `0` to allow any number of queries. + * + * ```php + * ->maxGraphqlBatchSize(500) + * ``` + * + * @group GraphQL + * @param int $value + * @return self + * @see $maxGraphqlBatchSize + * @since 4.5.5 + */ + public function maxGraphqlBatchSize(int $value): self + { + $this->maxGraphqlBatchSize = $value; + return $this; + } + /** * The maximum allowed complexity a GraphQL query is allowed to have. Set to `0` to allow any complexity. * @@ -5432,7 +5527,7 @@ public function previewIframeResizerOptions(array $value): self * * ```php * // 1 hour - * ->previewTokenDuration(3600), + * ->previewTokenDuration(3600) * ``` * * @group Security @@ -5485,7 +5580,7 @@ public function privateTemplateTrigger(string $value): self * * ```php * // 2 weeks - * ->purgePendingUsersDuration(1209600), + * ->purgePendingUsersDuration(1209600) * ``` * * @group Garbage Collection @@ -5509,7 +5604,7 @@ public function purgePendingUsersDuration(mixed $value): self * * ```php * // 1 week - * ->purgeStaleUserSessionDuration(604800), + * ->purgeStaleUserSessionDuration(604800) * ``` * * @group Garbage Collection @@ -6090,7 +6185,7 @@ public function secureHeaders(?array $value): self * 'CF-Visitor' => [ * '{\"scheme\":\"https\"}', * ], - * ], + * ]) * ``` * * @group Security @@ -6105,6 +6200,25 @@ public function secureProtocolHeaders(?array $value): self return $this; } + /** + * Whether “First Name” and “Last Name” fields should be shown in place of “Full Name” fields. + * + * ```php + * ->showFirstAndLastNameFields() + * ``` + * + * @group Users + * @param bool $value + * @return self + * @see $showFirstAndLastNameFields + * @since 4.6.0 + */ + public function showFirstAndLastNameFields(bool $value = true): self + { + $this->showFirstAndLastNameFields = $value; + return $this; + } + /** * The amount of time before a soft-deleted item will be up for hard-deletion by garbage collection. * @@ -6545,8 +6659,8 @@ public function verifyEmailPath(mixed $value): self * @param mixed $value * @return self * @see $verifyEmailSuccessPath - * @since 4.2.0 * @see getVerifyEmailSuccessPath() + * @since 4.2.0 */ public function verifyEmailSuccessPath(mixed $value): self { diff --git a/src/config/app.php b/src/config/app.php index 21e7ee6cad1..ef6b3c7b83a 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -3,8 +3,8 @@ return [ 'id' => 'CraftCMS', 'name' => 'Craft CMS', - 'version' => '4.5.0', - 'schemaVersion' => '4.5.0.3', + 'version' => '4.6.1', + 'schemaVersion' => '4.5.3.0', 'minVersionRequired' => '3.7.11', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias @@ -119,8 +119,11 @@ 'key', 'pass', 'password', + 'pgpassword', 'pw', + 'pwd', 'secret', + 'sk', 'tok', 'token', ], diff --git a/src/config/build-composer-classes.php b/src/config/build-composer-classes.php deleted file mode 100644 index 2c34e7d78d3..00000000000 --- a/src/config/build-composer-classes.php +++ /dev/null @@ -1,66 +0,0 @@ -getClassMap() as $class => $file) { - if ( - str_starts_with($class, 'Composer\\') && - !str_starts_with($class, 'Composer\\Command\\') && - !str_starts_with($class, 'Composer\\Console\\') && - !str_starts_with($class, 'Composer\\XdebugHandler\\') - ) { - $classes[] = $class; - } -} - -sort($classes); - - -$content = <<getIsInstalled(true) && $this->_requireInfoTable($route, $params)) { + if ($this->_requireInfoTable($route, $params) && !$this->getIsInstalled(true)) { // Is the connection valid at least? if (!$this->getIsDbConnectionValid()) { Console::outputWarning('Craft can’t connect to the database. Check your connection settings.'); @@ -179,9 +180,11 @@ public function get($id, $throwException = true): ?object return $component; } - private function _requireInfoTable(string $route, array $params): bool + private function _requireInfoTable(string $route, array &$params): bool { - if (isset($params['help'])) { + $skipCheck = App::env('CRAFT_NO_DB') ?? false; + + if ($skipCheck || isset($params['help'])) { return false; } diff --git a/src/console/Controller.php b/src/console/Controller.php index a8ca8ff8cc2..4b6d40b9d14 100644 --- a/src/console/Controller.php +++ b/src/console/Controller.php @@ -9,7 +9,6 @@ use craft\console\controllers\ResaveController; use craft\events\DefineConsoleActionsEvent; -use craft\helpers\ArrayHelper; use craft\helpers\Console; use craft\helpers\FileHelper; use craft\helpers\Json; @@ -74,7 +73,7 @@ class Controller extends YiiController public const EVENT_DEFINE_ACTIONS = 'defineActions'; /** - * @var array Custom actions that should be available. + * @var array[] Custom actions that should be available. * @see defineActions() */ private array $_actions; @@ -175,7 +174,7 @@ public function init(): void */ public function actions(): array { - return ArrayHelper::getColumn($this->_actions, 'action'); + return array_map(fn(array $action) => $action['action'], $this->_actions); } /** @@ -458,7 +457,7 @@ public function do(string $description, callable $action, bool $withDuration = f $this->stdout('✓', Console::FG_GREEN, Console::BOLD); if ($withDuration) { - $this->stdout(sprintf(' (time: %.3fs', microtime(true) - $time), Console::FG_GREY); + $this->stdout(sprintf(' (time: %.3fs)', microtime(true) - $time), Console::FG_GREY); } $this->stdout(PHP_EOL); } diff --git a/src/console/controllers/DbController.php b/src/console/controllers/DbController.php index 7487b00525c..ac9e2cb00bc 100644 --- a/src/console/controllers/DbController.php +++ b/src/console/controllers/DbController.php @@ -9,6 +9,8 @@ use Craft; use craft\console\Controller; +use craft\db\Connection; +use craft\db\Table; use craft\helpers\Console; use craft\helpers\Db; use craft\helpers\FileHelper; @@ -368,4 +370,120 @@ public function actionConvertCharset(?string $charset = null, ?string $collation $this->stdout("Finished converting tables to $charset/$collation." . PHP_EOL, Console::FG_GREEN); return ExitCode::OK; } + + /** + * Drops the database table prefix from all tables. + * + * @param string|null $prefix The current table prefix. If omitted, the prefix will be detected automatically. + * @return int + * @since 4.5.10 + */ + public function actionDropTablePrefix(?string $prefix = null): int + { + $db = Craft::$app->getDb(); + $prefix ??= $this->detectPrefix($db); + + if ($prefix === false) { + return ExitCode::UNSPECIFIED_ERROR; + } + + $prefix = StringHelper::ensureRight($prefix, '_'); + + $this->warning(<<confirm('Are you sure you want to proceed?')) { + return ExitCode::OK; + } + + $tablePrefixBak = $db->tablePrefix; + $db->tablePrefix = $prefix; + $schema = $db->getSchema(); + $quotedPrefix = preg_quote($prefix, '/'); + + foreach ($schema->getTableNames() as $oldName) { + if (!str_starts_with($oldName, $prefix)) { + continue; + } + + $newName = preg_replace("/^$quotedPrefix/", '', $oldName); + + $this->do( + $this->markdownToAnsi("Renaming `$oldName` to `$newName`"), + function() use ($oldName, $newName, $db) { + Db::renameTable($oldName, $newName, $db); + }, + ); + } + + $db->tablePrefix = $tablePrefixBak; + + $this->success('Database tables renamed.'); + + if (Craft::$app->getConfig()->getDb()->tablePrefix) { + $this->tip('Don’t forget to clear out your `tablePrefix` database setting.'); + } + + return ExitCode::OK; + } + + private function detectPrefix(Connection $db): string|false + { + $this->stdout("Detecting the current table prefix …\n"); + + $patterns = array_map( + function(string $name) { + // based on Schema::getRawTableName() + $name = preg_replace('/\\{\\{(.*?)}}/', '\1', $name); + $name = preg_replace_callback('/[^%]+/', fn($match) => preg_quote($match[0], '/'), $name); + return sprintf('/^%s$/', str_replace('%', '(\w+)_', $name)); + }, + [Table::ELEMENTS, Table::ENTRIES, Table::INFO, Table::SECTIONS], + ); + + $foundPrefixes = []; + + foreach ($db->getSchema()->getTableNames() as $name) { + foreach ($patterns as $pattern) { + if (preg_match($pattern, $name, $match)) { + if (!isset($foundPrefixes[$match[1]])) { + $foundPrefixes[$match[1]] = 1; + } else { + $foundPrefixes[$match[1]]++; + } + break; + } + } + } + + $possiblePrefixes = []; + foreach ($foundPrefixes as $prefix => $count) { + if ($count === count($patterns)) { + $possiblePrefixes[] = $prefix; + } + } + + if (empty($possiblePrefixes)) { + $this->stdout("No current table prefix appears to be in use.\n", Console::FG_RED); + return false; + } + + if (count($possiblePrefixes) === 1) { + $prefix = reset($possiblePrefixes); + $this->stdout($this->markdownToAnsi("`$prefix` detected.") . PHP_EOL); + return $prefix; + } + + if (!$this->interactive) { + $this->stdout("Multiple table prefixes were detected. Run the command again with the current prefix specified.\n", Console::FG_RED); + return false; + } + + return $this->select( + 'Multiple table prefixes were detected. Which one should be removed?', + array_combine($possiblePrefixes, $possiblePrefixes), + ); + } } diff --git a/src/console/controllers/IndexAssetsController.php b/src/console/controllers/IndexAssetsController.php index e628df2866e..54409325754 100644 --- a/src/console/controllers/IndexAssetsController.php +++ b/src/console/controllers/IndexAssetsController.php @@ -53,17 +53,31 @@ class IndexAssetsController extends Controller */ public bool $deleteMissingAssets = false; + /** + * @var bool Whether empty folders should be deleted. + * @since 4.6.0 + */ + public bool $deleteEmptyFolders = false; + /** * @inheritdoc */ public function options($actionID): array { $options = parent::options($actionID); - if (!App::isEphemeral()) { - $options[] = 'cacheRemoteImages'; + + switch ($actionID) { + case 'all': + case 'one': + if (!App::isEphemeral()) { + $options[] = 'cacheRemoteImages'; + } + $options[] = 'createMissingAssets'; + $options[] = 'deleteMissingAssets'; + $options[] = 'deleteEmptyFolders'; + break; } - $options[] = 'createMissingAssets'; - $options[] = 'deleteMissingAssets'; + return $options; } @@ -145,7 +159,7 @@ private function _indexAssets(array $volumes, string $path = '', int $startAt = $this->stdout(PHP_EOL); - $session = $assetIndexer->createIndexingSession($volumes, $this->cacheRemoteImages, true); + $session = $assetIndexer->createIndexingSession($volumes, $this->cacheRemoteImages, true, $this->deleteEmptyFolders); foreach ($volumes as $volume) { $this->stdout('Indexing assets in ', Console::FG_YELLOW); @@ -212,7 +226,7 @@ private function _indexAssets(array $volumes, string $path = '', int $startAt = // Manually close the indexing session. $session->actionRequired = true; - $missingEntries = $assetIndexer->getMissingEntriesForSession($session); + $missingEntries = $assetIndexer->getMissingEntriesForSession($session, $path); $missingFiles = $missingEntries['files']; $missingFolders = $missingEntries['folders']; @@ -240,7 +254,7 @@ private function _indexAssets(array $volumes, string $path = '', int $startAt = if (!empty($missingFolders)) { $totalMissing = count($missingFolders); - $this->stdout(($totalMissing === 1 ? 'One missing folder:' : "$totalMissing missing folders:") . PHP_EOL, Console::FG_YELLOW); + $this->stdout(($totalMissing === 1 ? 'One missing or empty folder:' : "$totalMissing missing or empty folders:") . PHP_EOL, Console::FG_YELLOW); foreach ($missingFolders as $folderId => $folderPath) { $this->stdout("- $folderPath ($folderId)"); $this->stdout(PHP_EOL); @@ -292,11 +306,11 @@ private function _indexAssets(array $volumes, string $path = '', int $startAt = $this->stdout('done' . PHP_EOL, Console::FG_GREEN); } - if (!empty($missingFolders) && $this->deleteMissingAssets) { + if (!empty($missingFolders) && $this->deleteEmptyFolders) { $totalMissingFolders = count($missingFolders); - $this->stdout('Deleting the' . ($totalMissingFolders > 1 ? ' ' . $totalMissingFolders : '') . ' missing folder record' . ($totalMissingFolders > 1 ? 's' : '') . ' ... '); + $this->stdout('Deleting the' . ($totalMissingFolders > 1 ? ' ' . $totalMissingFolders : '') . ' missing and empty folders' . ($totalMissingFolders > 1 ? 's' : '') . ' ... '); - Craft::$app->getAssets()->deleteFoldersByIds(array_keys($missingFolders), false); + Craft::$app->getAssets()->deleteFoldersByIds(array_keys($missingFolders)); $this->stdout('done' . PHP_EOL, Console::FG_GREEN); } diff --git a/src/console/controllers/ResaveController.php b/src/console/controllers/ResaveController.php index 5585d753fe3..c30e6095669 100644 --- a/src/console/controllers/ResaveController.php +++ b/src/console/controllers/ResaveController.php @@ -10,6 +10,7 @@ use Craft; use craft\base\ElementInterface; use craft\console\Controller; +use craft\elements\Address; use craft\elements\Asset; use craft\elements\Category; use craft\elements\db\ElementQuery; @@ -53,7 +54,7 @@ final public static function normalizeTo(?string $to): callable // empty if ($to === ':empty:') { return function() { - return null; + return ''; }; } @@ -184,6 +185,18 @@ final public static function normalizeTo(?string $to): callable */ public ?string $field = null; + /** + * @var string|int[]|null Comma-separated list of owner element IDs. + * @since 4.5.6 + */ + public string|array|null $ownerId = null; + + /** + * @var string|null Comma-separated list of country codes. + * @since 4.5.6 + */ + public ?string $countryCode = null; + /** * @var string|null An attribute name that should be set for each of the elements. The value will be determined by --to. * @since 3.7.29 @@ -233,6 +246,10 @@ public function options($actionID): array $options[] = 'touch'; switch ($actionID) { + case 'addresses': + $options[] = 'ownerId'; + $options[] = 'countryCode'; + break; case 'assets': $options[] = 'volume'; break; @@ -252,6 +269,7 @@ public function options($actionID): array break; case 'matrix-blocks': $options[] = 'field'; + $options[] = 'ownerId'; $options[] = 'type'; break; } @@ -299,6 +317,24 @@ public function beforeAction($action): bool return true; } + /** + * Re-saves user addresses. + * + * @return int + * @since 4.5.6 + */ + public function actionAddresses(): int + { + $criteria = []; + if (isset($this->ownerId)) { + $criteria['ownerId'] = array_map(fn(string $id) => (int)$id, explode(',', (string)$this->ownerId)); + } + if (isset($this->countryCode)) { + $criteria['countryCode'] = explode(',', (string)$this->countryCode); + } + return $this->resaveElements(Address::class, $criteria); + } + /** * Re-saves assets. * @@ -358,6 +394,9 @@ public function actionMatrixBlocks(): int if (isset($this->field)) { $criteria['field'] = explode(',', $this->field); } + if (isset($this->ownerId)) { + $criteria['ownerId'] = array_map(fn(string $id) => (int)$id, explode(',', (string)$this->ownerId)); + } if (isset($this->type)) { $criteria['type'] = explode(',', $this->type); } diff --git a/src/console/controllers/SectionsController.php b/src/console/controllers/SectionsController.php index 08b63ef99f0..f3a6c42a181 100644 --- a/src/console/controllers/SectionsController.php +++ b/src/console/controllers/SectionsController.php @@ -29,6 +29,42 @@ */ class SectionsController extends Controller { + /** + * @var string|null The section name. + * @since 4.6.0 + */ + public ?string $name = null; + + /** + * @var string|null The section handle. + * @since 4.6.0 + */ + public ?string $handle = null; + + /** + * @var string|null The section type (single, channel, or structure). + * @since 4.6.0 + */ + public ?string $type = null; + + /** + * @var bool|null Whether to disable versioning for the section. + * @since 4.6.0 + */ + public ?bool $noVersioning = null; + + /** + * @var string|null The entry URI format to set for each site. + * @since 4.6.0 + */ + public ?string $uriFormat = null; + + /** + * @var string|null The template to load when an entry’s URL is requested. + * @since 4.6.0 + */ + public ?string $template = null; + /** * @var string|null The category group handle to model the section from. */ @@ -71,6 +107,12 @@ public function options($actionID): array switch ($actionID) { case 'create': $options = array_merge($options, [ + 'name', + 'handle', + 'type', + 'noVersioning', + 'uriFormat', + 'template', 'fromCategoryGroup', 'fromTagGroup', 'fromGlobalSet', @@ -87,11 +129,6 @@ public function options($actionID): array */ public function actionCreate(): int { - if (!$this->interactive) { - $this->stderr("This command must be run interactively.\n", Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; - } - $section = new Section([ // Avoid the default preview target 'previewTargets' => [], @@ -162,32 +199,45 @@ public function actionCreate(): int $saveEntryType = true; } - $section->name = $this->prompt('Section name:', [ + $section->name = $this->name ?? $this->prompt('Section name:', [ 'required' => true, 'validator' => fn(string $name, ?string & $error = null) => $validateAttribute(compact('name'), $error), 'default' => $section->name, ]); - $section->handle = $this->prompt('Section handle:', [ + $section->handle = $this->handle ?? $this->prompt('Section handle:', [ 'required' => true, 'validator' => fn(string $handle, ?string & $error = null) => $validateAttribute(compact('handle'), $error), 'default' => $section->handle ?? StringHelper::toHandle($section->name), ]); - if (!$section->type) { - $section->type = $this->select('Section type:', [ - Section::TYPE_SINGLE => 'for one-off content', - Section::TYPE_CHANNEL => 'for repeating content', - Section::TYPE_STRUCTURE => 'for ordered/hierarchical content', - ]); + if (isset($this->type)) { + $section->type = $this->type; + } elseif (!$section->type) { + if ($this->interactive) { + $section->type = $this->select('Section type:', [ + Section::TYPE_SINGLE => 'for one-off content', + Section::TYPE_CHANNEL => 'for repeating content', + Section::TYPE_STRUCTURE => 'for ordered/hierarchical content', + ]); + } else { + $section->type = Section::TYPE_CHANNEL; + } } - $section->enableVersioning = $this->confirm('Enable entry versioning for the section?', true); + if (isset($this->noVersioning)) { + $section->enableVersioning = !$this->noVersioning; + } else { + $section->enableVersioning = $this->confirm('Enable entry versioning for the section?', true); + } if (empty($section->getSiteSettings())) { $section->setSiteSettings(array_map( fn(Site $site) => new Section_SiteSettings([ 'siteId' => $site->id, + 'hasUrls' => $this->uriFormat !== null, + 'uriFormat' => $this->uriFormat, + 'template' => $this->template, ]), Craft::$app->getSites()->getAllSites(true), )); @@ -205,46 +255,52 @@ public function actionCreate(): int ]; } - $this->do('Saving the section', function() use ($section) { - if (!Craft::$app->getSections()->saveSection($section)) { - $message = ArrayHelper::firstValue($section->getFirstErrors()) ?? 'Unable to save the section'; - throw new InvalidConfigException($message); - } - }); - - $entryType = $section->getEntryTypes()[0]; - $entryTypeName = $this->prompt('Initial entry type name:', [ - 'default' => $entryType->name, - ]); - $entryTypeHandle = $this->prompt('Initial entry type handle:', [ - 'default' => $entryTypeName !== $entryType->name ? StringHelper::toHandle($entryTypeName) : $entryType->handle, - ]); - - if ($entryTypeName !== $entryType->name || $entryTypeHandle !== $entryType->handle) { - $entryType->name = $entryTypeName; - $entryType->handle = $entryTypeHandle; - $saveEntryType = true; + try { + $this->do('Saving the section', function() use ($section) { + if (!Craft::$app->getSections()->saveSection($section)) { + $message = ArrayHelper::firstValue($section->getFirstErrors()) ?? 'Unable to save the section'; + throw new InvalidConfigException($message); + } + }); + } catch (InvalidConfigException) { + return ExitCode::UNSPECIFIED_ERROR; } - if ($saveEntryType) { - if ($this->fromGlobalSet) { - $entryType->showStatusField = false; + if ($this->interactive) { + $entryType = $section->getEntryTypes()[0]; + $entryTypeName = $this->prompt('Initial entry type name:', [ + 'default' => $entryType->name, + ]); + $entryTypeHandle = $this->prompt('Initial entry type handle:', [ + 'default' => $entryTypeName !== $entryType->name ? StringHelper::toHandle($entryTypeName) : $entryType->handle, + ]); + + if ($entryTypeName !== $entryType->name || $entryTypeHandle !== $entryType->handle) { + $entryType->name = $entryTypeName; + $entryType->handle = $entryTypeHandle; + $saveEntryType = true; } - $this->do('Saving the entry type', function() use ($entryType, $sourceFieldLayout) { - if ($sourceFieldLayout) { - $fieldLayout = FieldLayout::createFromConfig($sourceFieldLayout->getConfig() ?? []); - foreach ($fieldLayout->getTabs() as $tab) { - $tab->uid = StringHelper::UUID(); - foreach ($tab->getElements() as $element) { - $element->uid = StringHelper::UUID(); + if ($saveEntryType) { + if ($this->fromGlobalSet) { + $entryType->showStatusField = false; + } + + $this->do('Saving the entry type', function() use ($entryType, $sourceFieldLayout) { + if ($sourceFieldLayout) { + $fieldLayout = FieldLayout::createFromConfig($sourceFieldLayout->getConfig() ?? []); + foreach ($fieldLayout->getTabs() as $tab) { + $tab->uid = StringHelper::UUID(); + foreach ($tab->getElements() as $element) { + $element->uid = StringHelper::UUID(); + } } + $entryType->setFieldLayout($fieldLayout); } - $entryType->setFieldLayout($fieldLayout); - } - Craft::$app->getSections()->saveEntryType($entryType); - }); + Craft::$app->getSections()->saveEntryType($entryType); + }); + } } $this->success('Section created.'); diff --git a/src/console/controllers/SetupController.php b/src/console/controllers/SetupController.php index f9745274f27..cfdcd896ea8 100644 --- a/src/console/controllers/SetupController.php +++ b/src/console/controllers/SetupController.php @@ -29,7 +29,6 @@ use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\StreamOutput; -use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; use Throwable; use yii\base\InvalidConfigException; @@ -184,8 +183,8 @@ public function actionWelcome(): int /** * Generates an application ID and security key (if they don’t exist), and saves them in the `.env` file. * - * @since 4.2.7 * @return int + * @since 4.2.7 */ public function actionKeys(): int { @@ -627,7 +626,7 @@ public function actionCloud(): int } $process = new Process([ - (new PhpExecutableFinder())->find() ?: 'php', + App::phpExecutable() ?? 'php', $script, 'cloud/setup', ]); diff --git a/src/console/controllers/UpController.php b/src/console/controllers/UpController.php index 80dfcc8af1f..e8c5dca79ca 100644 --- a/src/console/controllers/UpController.php +++ b/src/console/controllers/UpController.php @@ -23,6 +23,12 @@ */ class UpController extends Controller { + /** + * @var bool Skip backing up the database. + * @since 4.5.8 + */ + public bool $noBackup = false; + /** * @var bool Whether to perform the action even if a mutex lock could not be acquired. */ @@ -39,6 +45,7 @@ class UpController extends Controller public function options($actionID): array { return array_merge(parent::options($actionID), [ + 'noBackup', 'force', ]); } @@ -54,7 +61,10 @@ public function actionIndex(): int $pendingChanges = Craft::$app->getProjectConfig()->areChangesPending(); // Craft + plugin migrations - $res = $this->run('migrate/all', ['noContent' => true]); + $res = $this->run('migrate/all', [ + 'noContent' => true, + 'noBackup' => $this->noBackup, + ]); if ($res !== ExitCode::OK) { $this->stderr("\nAborting remaining tasks.\n", Console::FG_YELLOW); return $res; @@ -71,7 +81,9 @@ public function actionIndex(): int } // Content migration - $res = $this->run('migrate/up', ['track' => MigrationManager::TRACK_CONTENT]); + $res = $this->run('migrate/up', [ + 'track' => MigrationManager::TRACK_CONTENT, + ]); if ($res !== ExitCode::OK) { return $res; } diff --git a/src/console/controllers/UpdateController.php b/src/console/controllers/UpdateController.php index 25658c550b8..3eb5ee9d6f7 100644 --- a/src/console/controllers/UpdateController.php +++ b/src/console/controllers/UpdateController.php @@ -21,7 +21,6 @@ use craft\models\Updates; use craft\models\Updates as UpdatesModel; use Symfony\Component\Process\Exception\ProcessFailedException; -use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; use Throwable; use yii\base\InvalidConfigException; @@ -416,7 +415,7 @@ private function _migrate(): bool $this->stdout('Applying new migrations ... ', Console::FG_YELLOW); - $php = (new PhpExecutableFinder())->find() ?: 'php'; + $php = App::phpExecutable() ?? 'php'; $process = new Process([$php, $script, 'migrate/all', '--no-backup', '--no-content']); $process->setTimeout(null); try { @@ -480,7 +479,7 @@ private function _revertComposerChanges(): void $this->stdout('Reverting Composer changes ... ', Console::FG_YELLOW); - $php = (new PhpExecutableFinder())->find() ?: 'php'; + $php = App::phpExecutable() ?? 'php'; $process = new Process([$php, $script, 'update/composer-install']); $process->setTimeout(null); try { diff --git a/src/console/controllers/utils/PruneOrphanedMatrixBlocksController.php b/src/console/controllers/utils/PruneOrphanedMatrixBlocksController.php new file mode 100644 index 00000000000..60cc9b04895 --- /dev/null +++ b/src/console/controllers/utils/PruneOrphanedMatrixBlocksController.php @@ -0,0 +1,79 @@ + + * @since 4.7.0 + */ +class PruneOrphanedMatrixBlocksController extends Controller +{ + /** + * Prunes orphaned matrix blocks for each site. + * + * @return int + */ + public function actionIndex(): int + { + if (!Craft::$app->getIsMultiSite()) { + $this->stdout("This command should only be run for multi-site installs.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + $elementsService = Craft::$app->getElements(); + + // get all sites + $sites = Craft::$app->getSites()->getAllSites(); + + // for each site get all matrix blocks with owner that doesn't exist for this site + foreach ($sites as $site) { + $this->stdout(sprintf('Finding orphaned matrix blocks for site "%s" ... ', $site->getName())); + + $esSubQuery = (new Query()) + ->from(['es' => Table::ELEMENTS_SITES]) + ->where([ + 'and', + '[[es.elementId]] = [[matrixblocks.primaryOwnerId]]', + ['es.siteId' => $site->id], + ]); + + $matrixBlocks = MatrixBlock::find() + ->status(null) + ->siteId($site->id) + ->where(['not exists', $esSubQuery]) + ->all(); + + if (empty($matrixBlocks)) { + $this->stdout("none found\n", Console::FG_GREEN); + continue; + } + + $this->stdout(sprintf("%s found\n", count($matrixBlocks)), Console::FG_RED); + + // delete the ones we found + foreach ($matrixBlocks as $block) { + $this->do(sprintf('Deleting block %s in %s', $block->id, $site->getName()), function() use ($block, $elementsService) { + $elementsService->deleteElementForSite($block); + }); + } + } + + $this->stdout("\nFinished pruning orphaned Matrix blocks.\n", Console::FG_GREEN); + return ExitCode::OK; + } +} diff --git a/src/console/controllers/utils/RepairController.php b/src/console/controllers/utils/RepairController.php index 4b9c62e4d1a..2501a6753a3 100644 --- a/src/console/controllers/utils/RepairController.php +++ b/src/console/controllers/utils/RepairController.php @@ -15,7 +15,6 @@ use craft\elements\Category; use craft\elements\db\ElementQuery; use craft\elements\Entry; -use craft\helpers\ArrayHelper; use craft\helpers\Console; use craft\helpers\ElementHelper; use craft\models\Section; @@ -202,8 +201,14 @@ protected function repairStructure(int $structureId, ElementQuery $query): int } else { // Make sure that the element has at least one site in common with the parent $parentElement = $ancestors[$newLevel - 2]; - $elementSites = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($element), 'siteId'); - $parentSites = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($parentElement), 'siteId'); + $elementSites = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($element), + ); + $parentSites = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($parentElement), + ); if (!array_intersect($elementSites, $parentSites)) { $issue = 'no supported sites in common with parent'; diff --git a/src/controllers/AppController.php b/src/controllers/AppController.php index 478dc48480d..1ae087de53e 100644 --- a/src/controllers/AppController.php +++ b/src/controllers/AppController.php @@ -79,6 +79,26 @@ public function actionHealthCheck(): Response return $this->response; } + /** + * Loads the given JavaScript resource URL and returns it. + * + * @param string $url + * @return Response + */ + public function actionResourceJs(string $url): Response + { + $this->requireCpRequest(); + + if (!str_starts_with($url, Craft::$app->getAssetManager()->baseUrl)) { + throw new BadRequestHttpException("$url does not appear to be a resource URL"); + } + + $response = Craft::createGuzzleClient()->get($url); + $this->response->setCacheHeaders(); + $this->response->getHeaders()->set('content-type', 'application/javascript'); + return $this->asRaw($response->getBody()); + } + /** * Returns the latest Craftnet API headers. * diff --git a/src/controllers/ElementIndexesController.php b/src/controllers/ElementIndexesController.php index 2ffe3a0c5ba..370fb750984 100644 --- a/src/controllers/ElementIndexesController.php +++ b/src/controllers/ElementIndexesController.php @@ -609,7 +609,7 @@ protected function elementQuery(): ElementQueryInterface $criteria['draftOf'] = filter_var($criteria['draftOf'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); } } - Craft::configure($query, $criteria); + Craft::configure($query, Component::cleanseConfig($criteria)); } // Override with the custom filters diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index 4bc2f34776e..598f048239a 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -108,7 +108,7 @@ public function beforeAction($action): bool $this->_draftId = $this->_param('draftId'); $this->_revisionId = $this->_param('revisionId'); $this->_siteId = $this->_param('siteId'); - $this->_enabled = $this->_param('enabled') ?? true; + $this->_enabled = $this->_param('enabled', true); $this->_enabledForSite = $this->_param('enabledForSite'); $this->_slug = $this->_param('slug'); $this->_fresh = (bool)$this->_param('fresh'); @@ -135,11 +135,16 @@ public function beforeAction($action): bool /** * @param string $name + * @param mixed $default * @return mixed */ - private function _param(string $name): mixed + private function _param(string $name, mixed $default = null): mixed { - return ArrayHelper::remove($this->_attributes, $name) ?? $this->request->getQueryParam($name); + $value = ArrayHelper::remove($this->_attributes, $name) ?? $this->request->getQueryParam($name); + if ($value === null && $default !== null && $this->request->getIsPost()) { + return $default; + } + return $value; } /** @@ -371,6 +376,12 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): } $security = Craft::$app->getSecurity(); + $notice = null; + if ($element->isProvisionalDraft) { + $notice = fn() => $this->_draftNotice(); + } elseif ($element->getIsRevision()) { + $notice = fn() => $this->_revisionNotice($element::lowerDisplayName()); + } $response = $this->asCpScreen() ->editUrl($element->getCpEditUrl()) @@ -380,7 +391,10 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): $element, $isMultiSiteElement, $isUnpublishedDraft, - $propSiteIds + $canCreateDrafts, + $propSiteIds, + $elementsService, + $user, )) ->additionalButtons(fn() => $this->_additionalButtons( $element, @@ -395,7 +409,7 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): $isUnpublishedDraft, $isDraft )) - ->notice($element->isProvisionalDraft ? fn() => $this->_draftNotice() : null) + ->notice($notice) ->errorSummary(fn() => $this->_errorSummary($element)) ->prepareScreen( fn(Response $response, string $containerId) => $this->_prepareEditor( @@ -411,6 +425,7 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): 'canCreateDrafts' => $canCreateDrafts, 'canEditMultipleSites' => $canEditMultipleSites, 'canSaveCanonical' => $canSaveCanonical, + 'elementId' => $element->id, 'canonicalId' => $canonical->id, 'draftId' => $element->draftId, 'draftName' => $isDraft ? $element->draftName : null, @@ -667,18 +682,69 @@ private function _contextMenu( ElementInterface $element, bool $isMultiSiteElement, bool $isUnpublishedDraft, + bool $canCreateDrafts, array $propSiteIds, + Elements $elementsService, + User $user, ): ?string { - $showDrafts = !$isUnpublishedDraft; + if ($isUnpublishedDraft || !$element->id) { + $drafts = []; + $showDrafts = false; + $revisions = []; + $revisionsPageUrl = null; + $hasMoreRevisions = false; + } else { + $drafts = $element::find() + ->draftOf($element) + ->siteId($element->siteId) + ->status(null) + ->orderBy(['dateUpdated' => SORT_DESC]) + ->with(['draftCreator']) + ->collect() + ->filter(fn(ElementInterface $draft) => $elementsService->canView($draft, $user)) + ->all(); + $showDrafts = !empty($drafts) || $canCreateDrafts; + + $generalConfig = Craft::$app->getConfig()->getGeneral(); + if ($element->hasRevisions() && (!$generalConfig->maxRevisions || $generalConfig->maxRevisions > 1)) { + $revisionQuery = $element::find() + ->revisionOf($element) + ->siteId($element->siteId) + ->status(null) + ->offset(1) + ->limit($generalConfig->maxRevisions ? min($generalConfig->maxRevisions - 1, 10) : 10) + ->orderBy(['dateCreated' => SORT_DESC]) + ->with(['revisionCreator']); + $revisions = $revisionQuery->all(); + $revisionsPageUrl = $element->getCpRevisionsUrl(); + if ($revisionsPageUrl) { + $hasMoreRevisions = ( + count($revisions) === $revisionQuery->limit && + $revisionQuery->limit < ($generalConfig->maxRevisions - 1) && + ($revisionQuery->count() - 1) > $revisionQuery->limit + ); + } else { + $hasMoreRevisions = false; + } + } else { + $revisions = []; + $revisionsPageUrl = null; + $hasMoreRevisions = false; + } + } if ( $isMultiSiteElement || $showDrafts || - ($element->hasRevisions() && $element::find()->revisionOf($element)->status(null)->exists()) + !empty($revisions) ) { return Craft::$app->getView()->renderTemplate('_includes/revisionmenu.twig', [ 'element' => $element, + 'drafts' => $drafts, 'showDrafts' => $showDrafts, + 'revisions' => $revisions, + 'revisionsPageUrl' => $revisionsPageUrl, + 'hasMoreRevisions' => $hasMoreRevisions, 'supportedSiteIds' => $propSiteIds, 'showSiteLabel' => $isMultiSiteElement, ], View::TEMPLATE_MODE_CP); @@ -995,6 +1061,27 @@ private function _draftNotice(): string Html::endTag('div'); } + private function _revisionNotice($elementType): string + { + return + Html::beginTag('div', [ + 'class' => 'revision-notice', + ]) . + Html::tag('div', '', [ + 'class' => ['revision-icon'], + 'aria' => ['hidden' => 'true'], + 'data' => ['icon' => 'lightbulb'], + ]) . + Html::tag('p', Craft::t( + 'app', + 'You’re viewing a revision. None of the {type}’s fields are editable.', + [ + 'type' => $elementType, + ] + )) . + Html::endTag('div'); + } + /** * Saves an element. * @@ -1016,11 +1103,17 @@ public function actionSave(): ?Response } $this->element = $element; - - $this->_applyParamsToElement($element); $elementsService = Craft::$app->getElements(); $user = static::currentUser(); + // Check save permissions before and after applying POST params to the element + // in case the request was tampered with. + if (!$elementsService->canSave($element, $user)) { + throw new ForbiddenHttpException('User not authorized to save this element.'); + } + + $this->_applyParamsToElement($element); + if (!$elementsService->canSave($element, $user)) { throw new ForbiddenHttpException('User not authorized to save this element.'); } @@ -1085,7 +1178,7 @@ public function actionSave(): ?Response return $this->_asSuccess(Craft::t('app', '{type} saved.', [ 'type' => $element::displayName(), - ]), $element, addAnother: true); + ]), $element, supportsAddAnother: true); } /** @@ -1120,12 +1213,10 @@ public function actionDuplicate(): ?Response throw new ForbiddenHttpException('User not authorized to duplicate this element.'); } - $clonedElement = clone $element; - $clonedElement->draftId = null; - $clonedElement->isProvisionalDraft = false; - try { - $newElement = $elementsService->duplicateElement($clonedElement); + $newElement = $elementsService->duplicateElement($element, [ + 'isProvisionalDraft' => false, + ]); } catch (InvalidElementException $e) { return $this->_asFailure($e->element, Craft::t('app', 'Couldn’t duplicate {type}.', [ 'type' => $element::lowerDisplayName(), @@ -1218,6 +1309,15 @@ public function actionDeleteForSite(): Response $elementsService->deleteElementForSite($element); + if ($element->isProvisionalDraft) { + // see if the canonical element exists for this site + $canonical = $element->getCanonical(); + if ($canonical->id !== $element->id) { + $element = $canonical; + $elementsService->deleteElementForSite($element); + } + } + return $this->_asSuccess(Craft::t('app', '{type} deleted for site.', [ 'type' => $element->getIsDraft() && !$element->isProvisionalDraft ? Craft::t('app', 'Draft') : $element::displayName(), ]), $element); @@ -1305,6 +1405,7 @@ public function actionSaveDraft(): ?Response $data = [ 'canonicalId' => $element->getCanonicalId(), + 'elementId' => $element->id, 'draftId' => $element->draftId, 'timestamp' => Craft::$app->getFormatter()->asTimestamp($element->dateUpdated, 'short', true), 'creator' => $creator?->getName(), @@ -1316,63 +1417,12 @@ public function actionSaveDraft(): ?Response if ($this->request->getIsCpRequest()) { [$docTitle, $title] = $this->_editElementTitles($element); - - $view = Craft::$app->getView(); - - $namespace = $this->request->getHeaders()->get('X-Craft-Namespace'); - $fieldLayout = $element->getFieldLayout(); - $form = $fieldLayout->createForm($element, false, [ - 'namespace' => $namespace, - 'registerDeltas' => false, - 'visibleElements' => $this->_visibleLayoutElements, - ]); - $missingElements = []; - foreach ($form->tabs as $tab) { - if (!$tab->getUid()) { - continue; - } - - $elementInfo = []; - - foreach ($tab->elements as [$layoutElement, $isConditional, $elementHtml]) { - /** @var FieldLayoutComponent $layoutElement */ - /** @var bool $isConditional */ - /** @var string|bool $elementHtml */ - if ($isConditional) { - $elementInfo[] = [ - 'uid' => $layoutElement->uid, - 'html' => $elementHtml, - ]; - } - } - - $missingElements[] = [ - 'uid' => $tab->getUid(), - 'id' => $tab->getId(), - 'elements' => $elementInfo, - ]; - } - - $tabs = $form->getTabMenu(); - if (count($tabs) > 1) { - $selectedTab = isset($tabs[$this->_selectedTab]) ? $this->_selectedTab : null; - $tabHtml = $view->namespaceInputs(fn() => $view->renderTemplate('_includes/tabs.twig', [ - 'tabs' => $tabs, - 'selectedTab' => $selectedTab, - ], View::TEMPLATE_MODE_CP), $namespace); - } else { - $tabHtml = null; - } - + $data += $this->_fieldLayoutData($element); $data += [ 'docTitle' => $docTitle, 'title' => $title, - 'tabs' => $tabHtml, 'previewTargets' => $element->getPreviewTargets(), - 'missingElements' => $missingElements, - 'initialDeltaValues' => $view->getInitialDeltaValues(), - 'headHtml' => $view->getHeadHtml(), - 'bodyHtml' => $view->getBodyHtml(), + 'initialDeltaValues' => Craft::$app->getView()->getInitialDeltaValues(), 'updatedTimestamp' => $element->dateUpdated->getTimestamp(), 'canonicalUpdatedTimestamp' => $element->getCanonical()->dateUpdated->getTimestamp(), ]; @@ -1383,7 +1433,7 @@ public function actionSaveDraft(): ?Response return $this->_asSuccess(Craft::t('app', '{type} saved.', [ 'type' => Craft::t('app', 'Draft'), - ]), $element, $data); + ]), $element, $data, true); }); } @@ -1485,7 +1535,7 @@ public function actionApplyDraft(): ?Response $message = Craft::t('app', 'Draft applied.'); } - return $this->_asSuccess($message, $canonical, addAnother: true); + return $this->_asSuccess($message, $canonical, supportsAddAnother: true); } private function _asAppyDraftFailure(ElementInterface $element): ?Response @@ -1594,6 +1644,109 @@ public function actionRevert(): Response ]), $canonical); } + /** + * Returns an element’s missing field layout components. + * + * @return Response|null + * @throws BadRequestHttpException + * @throws ForbiddenHttpException + * @throws ServerErrorHttpException + * @since 4.6.0 + */ + public function actionUpdateFieldLayout(): ?Response + { + $this->requirePostRequest(); + $this->requireCpRequest(); + + /** @var Element|DraftBehavior|null $element */ + $element = $this->_element(); + + if (!$element || $element->getIsRevision()) { + throw new BadRequestHttpException('No element was identified by the request.'); + } + + $elementsService = Craft::$app->getElements(); + $user = static::currentUser(); + + if (!$elementsService->canView($element, $user)) { + throw new ForbiddenHttpException('User not authorized to view this element.'); + } + + $this->element = $element; + $this->_applyParamsToElement($element); + + // Make sure nothing just changed that would prevent the user from saving + if (!$elementsService->canView($element, $user)) { + throw new ForbiddenHttpException('User not authorized to view this element.'); + } + + $data = $this->_fieldLayoutData($this->element); + + $data += [ + 'initialDeltaValues' => Craft::$app->getView()->getInitialDeltaValues(), + ]; + + return $this->_asSuccess(Craft::t('app', '{type} saved.', [ + 'type' => Craft::t('app', 'Draft'), + ]), $element, $data, true); + } + + private function _fieldLayoutData(ElementInterface $element): array + { + $view = Craft::$app->getView(); + $namespace = $this->request->getHeaders()->get('X-Craft-Namespace'); + $fieldLayout = $element->getFieldLayout(); + $form = $fieldLayout->createForm($element, false, [ + 'namespace' => $namespace, + 'registerDeltas' => false, + 'visibleElements' => $this->_visibleLayoutElements, + ]); + $missingElements = []; + foreach ($form->tabs as $tab) { + if (!$tab->getUid()) { + continue; + } + + $elementInfo = []; + + foreach ($tab->elements as [$layoutElement, $isConditional, $elementHtml]) { + /** @var FieldLayoutComponent $layoutElement */ + /** @var bool $isConditional */ + /** @var string|bool $elementHtml */ + if ($isConditional) { + $elementInfo[] = [ + 'uid' => $layoutElement->uid, + 'html' => $elementHtml, + ]; + } + } + + $missingElements[] = [ + 'uid' => $tab->getUid(), + 'id' => $tab->getId(), + 'elements' => $elementInfo, + ]; + } + + $tabs = $form->getTabMenu(); + if (count($tabs) > 1) { + $selectedTab = isset($tabs[$this->_selectedTab]) ? $this->_selectedTab : null; + $tabHtml = $view->namespaceInputs(fn() => $view->renderTemplate('_includes/tabs.twig', [ + 'tabs' => $tabs, + 'selectedTab' => $selectedTab, + ], View::TEMPLATE_MODE_CP), $namespace); + } else { + $tabHtml = null; + } + + return [ + 'tabs' => $tabHtml, + 'missingElements' => $missingElements, + 'headHtml' => $view->getHeadHtml(), + 'bodyHtml' => $view->getBodyHtml(), + ]; + } + /** * Returns the HTML for a single element * @@ -1801,7 +1954,7 @@ private function _element(?int $elementId = null, ?string $elementUid = null, ?b return null; } - if (!$element->canView(static::currentUser())) { + if (!$elementsService->canView($element, static::currentUser())) { throw new ForbiddenHttpException('User not authorized to edit this element.'); } @@ -1848,7 +2001,7 @@ private function _applyParamsToElement(ElementInterface $element): void } $element->setEnabledForSite($this->_enabledForSite); - } else { + } elseif (isset($this->_enabled)) { $element->enabled = $this->_enabled; } @@ -1931,8 +2084,12 @@ private function _canApplyUnpublishedDraft(ElementInterface $element, User $user * @throws Throwable * @throws ServerErrorHttpException */ - private function _asSuccess(string $message, ElementInterface $element, array $data = [], bool $addAnother = false): Response - { + private function _asSuccess( + string $message, + ElementInterface $element, + array $data = [], + bool $supportsAddAnother = false, + ): Response { /** @var Element $element */ // Don't call asModelSuccess() here so we can avoid including custom fields in the element data $data += [ @@ -1943,7 +2100,7 @@ private function _asSuccess(string $message, ElementInterface $element, array $d 'details' => !$element->dateDeleted ? Cp::elementHtml($element) : null, ]); - if ($addAnother && $this->_addAnother) { + if ($supportsAddAnother && $this->_addAnother) { $user = static::currentUser(); $newElement = $element->createAnother(); diff --git a/src/controllers/EntriesController.php b/src/controllers/EntriesController.php index 050d561c773..75836baa9e9 100644 --- a/src/controllers/EntriesController.php +++ b/src/controllers/EntriesController.php @@ -11,6 +11,7 @@ use craft\base\Element; use craft\elements\Entry; use craft\errors\InvalidElementException; +use craft\errors\MutexException; use craft\errors\UnsupportedSiteException; use craft\helpers\ArrayHelper; use craft\helpers\Cp; @@ -20,7 +21,6 @@ use craft\models\Section; use craft\models\Section_SiteSettings; use Throwable; -use yii\base\Exception; use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; use yii\web\NotFoundHttpException; @@ -310,7 +310,7 @@ public function actionSaveEntry(bool $duplicate = false): ?Response $lockKey = "entry:$entry->id"; $mutex = Craft::$app->getMutex(); if (!$mutex->acquire($lockKey, 15)) { - throw new Exception('Could not acquire a lock to save the entry.'); + throw new MutexException($lockKey, 'Could not acquire a lock to save the entry.'); } } diff --git a/src/controllers/FieldsController.php b/src/controllers/FieldsController.php index 5d8f4faa554..9abfc3f95bc 100644 --- a/src/controllers/FieldsController.php +++ b/src/controllers/FieldsController.php @@ -110,12 +110,17 @@ public function actionDeleteGroup(): Response * @param int|null $fieldId The field’s ID, if editing an existing field * @param FieldInterface|null $field The field being edited, if there were any validation errors * @param int|null $groupId The default group ID that the field should be saved in + * @param string|null $type The field type to use by default * @return Response * @throws NotFoundHttpException if the requested field/field group cannot be found * @throws ServerErrorHttpException if no field groups exist */ - public function actionEditField(?int $fieldId = null, ?FieldInterface $field = null, ?int $groupId = null): Response - { + public function actionEditField( + ?int $fieldId = null, + ?FieldInterface $field = null, + ?int $groupId = null, + ?string $type = null, + ): Response { $this->requireAdmin(); $fieldsService = Craft::$app->getFields(); @@ -132,7 +137,7 @@ public function actionEditField(?int $fieldId = null, ?FieldInterface $field = n } if ($field === null) { - $field = $fieldsService->createField(PlainText::class); + $field = $fieldsService->createField($type ?? PlainText::class); } // Supported translation methods @@ -354,6 +359,14 @@ public function actionSaveField(): ?Response } $this->setSuccessFlash(Craft::t('app', 'Field saved.')); + + if ($this->request->getParam('addAnother')) { + return $this->redirect(UrlHelper::cpUrl('settings/fields/new', [ + 'groupId' => $field->groupId, + 'type' => $field::class, + ])); + } + return $this->redirectToPostedUrl($field); } diff --git a/src/controllers/FsController.php b/src/controllers/FsController.php index 73976fb46e5..e0faa107871 100644 --- a/src/controllers/FsController.php +++ b/src/controllers/FsController.php @@ -116,6 +116,11 @@ public function actionEdit(?string $handle = null, ?Fs $filesystem = null): Resp ->addCrumb(Craft::t('app', 'Filesystems'), 'settings/filesystems') ->action('fs/save') ->redirectUrl('settings/filesystems') + ->addAltAction(Craft::t('app', 'Save and continue editing'), [ + 'redirect' => 'settings/filesystems/{handle}', + 'shortcut' => true, + 'retainScroll' => true, + ]) ->contentTemplate('settings/filesystems/_edit.twig', [ 'oldHandle' => $handle, 'filesystem' => $filesystem, diff --git a/src/controllers/GraphqlController.php b/src/controllers/GraphqlController.php index 3fdf37a5a79..fa6913a3f59 100644 --- a/src/controllers/GraphqlController.php +++ b/src/controllers/GraphqlController.php @@ -166,6 +166,15 @@ public function actionApi(): Response } } + if ($generalConfig->maxGraphqlBatchSize && count($queries) > $generalConfig->maxGraphqlBatchSize) { + throw new BadRequestHttpException(sprintf( + 'No more than %s GraphQL %s can be executed in a single batch.', + $generalConfig->maxGraphqlBatchSize, + $generalConfig->maxGraphqlBatchSize === 1 ? 'query' : 'queries' + )); + } + + // Generate all transforms immediately $generalConfig->generateTransformsBeforePageLoad = true; @@ -177,6 +186,7 @@ public function actionApi(): Response } $result = []; + foreach ($queries as $key => [$query, $variables, $operationName]) { try { if (empty($query)) { diff --git a/src/controllers/SystemSettingsController.php b/src/controllers/SystemSettingsController.php index 6aa7446e883..d800c71e74e 100644 --- a/src/controllers/SystemSettingsController.php +++ b/src/controllers/SystemSettingsController.php @@ -12,6 +12,7 @@ use craft\errors\MissingComponentException; use craft\helpers\App; use craft\helpers\ArrayHelper; +use craft\helpers\Component; use craft\helpers\MailerHelper; use craft\helpers\UrlHelper; use craft\mail\Mailer; @@ -299,7 +300,7 @@ private function _createMailSettingsFromPost(): MailSettings $settings->fromName = $this->request->getBodyParam('fromName'); $settings->template = $this->request->getBodyParam('template'); $settings->transportType = $this->request->getBodyParam('transportType'); - $settings->transportSettings = $this->request->getBodyParam('transportTypes.' . $settings->transportType); + $settings->transportSettings = Component::cleanseConfig($this->request->getBodyParam('transportTypes.' . $settings->transportType) ?? []); return $settings; } diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php index 16169c0a316..b2a74a89464 100644 --- a/src/controllers/UsersController.php +++ b/src/controllers/UsersController.php @@ -1123,7 +1123,7 @@ public function actionEditUser(mixed $userId = null, ?User $user = null, ?array 'value' => $locale->id, 'data' => [ 'data' => [ - 'hint' => $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : false, + 'hint' => $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : '', 'hintLang' => $locale->id, ], ], @@ -1389,7 +1389,7 @@ public function actionSaveUser(): ?Response // Is the site set to use email addresses as usernames? if ($generalConfig->useEmailAsUsername) { $user->username = $user->email; - } else { + } elseif ($isNewUser || $currentUser->admin || $isCurrentUser) { $user->username = $this->request->getBodyParam('username', ($user->username ?: $user->email)); } @@ -1423,12 +1423,9 @@ public function actionSaveUser(): ?Response // set the default group on the user, so that any content // based on user group condition can be validated and saved against them if ($isPublicRegistration) { - $defaultGroupUid = Craft::$app->getProjectConfig()->get('users.defaultGroup'); - if ($defaultGroupUid) { - $group = Craft::$app->userGroups->getGroupByUid($defaultGroupUid); - if ($group) { - $user->setGroups([$group]); - } + $groups = Craft::$app->getUsers()->getDefaultUserGroups($user); + if (!empty($groups)) { + $user->setGroups($groups); } } @@ -1448,6 +1445,8 @@ public function actionSaveUser(): ?Response // Don't validate required custom fields if it's public registration if (!$isPublicRegistration || ($userSettings['validateOnPublicRegistration'] ?? false)) { $user->setScenario(Element::SCENARIO_LIVE); + } elseif ($isPublicRegistration) { + $user->setScenario(User::SCENARIO_REGISTRATION); } // Manually validate the user so we can pass $clearErrors=false @@ -1483,30 +1482,29 @@ public function actionSaveUser(): ?Response Craft::$app->getUsers()->activateUser($user); } - // Save their preferences too - $preferences = [ - 'language' => $this->request->getBodyParam('preferredLanguage', $user->getPreference('language')), - 'locale' => $this->request->getBodyParam('preferredLocale', $user->getPreference('locale')) ?: null, - 'weekStartDay' => $this->request->getBodyParam('weekStartDay', $user->getPreference('weekStartDay')), - 'alwaysShowFocusRings' => (bool)$this->request->getBodyParam('alwaysShowFocusRings', $user->getPreference('alwaysShowFocusRings')), - 'useShapes' => (bool)$this->request->getBodyParam('useShapes', $user->getPreference('useShapes')), - 'underlineLinks' => (bool)$this->request->getBodyParam('underlineLinks', $user->getPreference('underlineLinks')), - 'notificationDuration' => $this->request->getBodyParam('notificationDuration', $user->getPreference('notificationDuration')), - ]; - - if ($user->admin) { - $preferences = array_merge($preferences, [ - 'showFieldHandles' => (bool)$this->request->getBodyParam('showFieldHandles', $user->getPreference('showFieldHandles')), - 'enableDebugToolbarForSite' => (bool)$this->request->getBodyParam('enableDebugToolbarForSite', $user->getPreference('enableDebugToolbarForSite')), - 'enableDebugToolbarForCp' => (bool)$this->request->getBodyParam('enableDebugToolbarForCp', $user->getPreference('enableDebugToolbarForCp')), - 'showExceptionView' => (bool)$this->request->getBodyParam('showExceptionView', $user->getPreference('showExceptionView')), - 'profileTemplates' => (bool)$this->request->getBodyParam('profileTemplates', $user->getPreference('profileTemplates')), - ]); - } + if ($isCurrentUser) { + // Save their preferences too + $preferences = [ + 'language' => $this->request->getBodyParam('preferredLanguage', $user->getPreference('language')), + 'locale' => $this->request->getBodyParam('preferredLocale', $user->getPreference('locale')) ?: null, + 'weekStartDay' => $this->request->getBodyParam('weekStartDay', $user->getPreference('weekStartDay')), + 'alwaysShowFocusRings' => (bool)$this->request->getBodyParam('alwaysShowFocusRings', $user->getPreference('alwaysShowFocusRings')), + 'useShapes' => (bool)$this->request->getBodyParam('useShapes', $user->getPreference('useShapes')), + 'underlineLinks' => (bool)$this->request->getBodyParam('underlineLinks', $user->getPreference('underlineLinks')), + 'notificationDuration' => $this->request->getBodyParam('notificationDuration', $user->getPreference('notificationDuration')), + ]; - Craft::$app->getUsers()->saveUserPreferences($user, $preferences); + if ($user->admin) { + $preferences = array_merge($preferences, [ + 'showFieldHandles' => (bool)$this->request->getBodyParam('showFieldHandles', $user->getPreference('showFieldHandles')), + 'enableDebugToolbarForSite' => (bool)$this->request->getBodyParam('enableDebugToolbarForSite', $user->getPreference('enableDebugToolbarForSite')), + 'enableDebugToolbarForCp' => (bool)$this->request->getBodyParam('enableDebugToolbarForCp', $user->getPreference('enableDebugToolbarForCp')), + 'showExceptionView' => (bool)$this->request->getBodyParam('showExceptionView', $user->getPreference('showExceptionView')), + 'profileTemplates' => (bool)$this->request->getBodyParam('profileTemplates', $user->getPreference('profileTemplates')), + ]); + } - if ($isCurrentUser) { + Craft::$app->getUsers()->saveUserPreferences($user, $preferences); Craft::$app->updateTargetLanguage(); } @@ -2436,7 +2434,7 @@ private function _saveUserGroups(User $user, User $currentUser): void $allGroups = ArrayHelper::index(Craft::$app->getUserGroups()->getAllGroups(), 'id'); // See if there are any new groups in here - $oldGroupIds = ArrayHelper::getColumn($user->getGroups(), 'id'); + $oldGroupIds = array_map(fn(UserGroup $group) => $group->id, $user->getGroups()); $hasNewGroups = false; $newGroups = []; diff --git a/src/controllers/VolumesController.php b/src/controllers/VolumesController.php index ecdf7ceaf3c..ba4aa19cd98 100644 --- a/src/controllers/VolumesController.php +++ b/src/controllers/VolumesController.php @@ -99,13 +99,14 @@ public function actionEditVolume(?int $volumeId = null, ?Volume $volume = null): $takenFsHandles = Collection::make($allVolumes) ->map(fn(Volume $volume) => $volume->getFsHandle()); $fsOptions = Collection::make(Craft::$app->getFs()->getAllFilesystems()) - ->filter(fn(FsInterface $fs) => $fs->handle === $fsHandle || !$takenFsHandles->contains($fs->handle)) ->sortBy(fn(FsInterface $fs) => $fs->name) ->map(fn(FsInterface $fs) => [ 'label' => $fs->name, 'value' => $fs->handle, + 'disabled' => $takenFsHandles->contains($fs->handle) && $fs->handle !== $fsHandle, ]) ->all(); + array_unshift($fsOptions, ['label' => Craft::t('app', 'Select a filesystem'), 'value' => '']); return $this->asCpScreen() ->title($title) @@ -115,6 +116,11 @@ public function actionEditVolume(?int $volumeId = null, ?Volume $volume = null): ->action('volumes/save-volume') ->redirectUrl('settings/assets') ->saveShortcutRedirectUrl('settings/assets/volumes/{id}') + ->addAltAction(Craft::t('app', 'Save and continue editing'), [ + 'redirect' => 'settings/assets/volumes/{id}', + 'shortcut' => true, + 'retainScroll' => true, + ]) ->editUrl($volume->id ? "settings/assets/volumes/$volume->id" : null) ->contentTemplate('settings/assets/volumes/_edit.twig', [ 'volumeId' => $volumeId, diff --git a/src/db/Connection.php b/src/db/Connection.php index 7dffdaf29b1..c0d0df8ba43 100644 --- a/src/db/Connection.php +++ b/src/db/Connection.php @@ -17,11 +17,13 @@ use craft\errors\ShellCommandException; use craft\events\BackupEvent; use craft\events\RestoreEvent; +use craft\helpers\App; use craft\helpers\Db; use craft\helpers\FileHelper; use craft\helpers\StringHelper; use mikehaertl\shellcommand\Command as ShellCommand; use Throwable; +use yii\base\Event; use yii\base\Exception; use yii\base\InvalidArgumentException; use yii\base\NotSupportedException; @@ -66,6 +68,12 @@ class Connection extends \yii\db\Connection */ public const EVENT_AFTER_RESTORE_BACKUP = 'afterRestoreBackup'; + /** + * @var callable[] + * @see onAfterTransaction() + */ + private array $afterTransactionCallbacks = []; + /** * @var bool|null whether the database supports 4+ byte characters * @see getSupportsMb4() @@ -143,6 +151,10 @@ public function setSupportsMb4(bool $supportsMb4): void */ public function open(): void { + if (App::env('CRAFT_NO_DB')) { + throw new DbConnectException('Craft CMS can’t connect to the database.'); + } + try { parent::open(); } catch (DbException $e) { @@ -424,6 +436,40 @@ public function getIndexName(): string return $this->_objectName('idx'); } + /** + * Invokes a callback function once the connection is no longer in a transaction. + * + * If no transaction is currently active, the callback will be invoked immediately. + * + * @param callable $callback + * @since 4.5.12 + */ + public function onAfterTransaction(callable $callback): void + { + if ($this->getTransaction() === null) { + $callback(); + } else { + $this->afterTransactionCallbacks[] = $callback; + } + } + + /** + * @inheritdoc + */ + public function trigger($name, Event $event = null) + { + if ( + in_array($name, [self::EVENT_COMMIT_TRANSACTION, self::EVENT_ROLLBACK_TRANSACTION]) && + !$this->getTransaction() + ) { + while ($callback = array_shift($this->afterTransactionCallbacks)) { + $callback(); + } + } + + parent::trigger($name, $event); + } + /** * Generates a FK, index, or PK name. * diff --git a/src/db/Migration.php b/src/db/Migration.php index ba2301c4914..1dc7464560e 100644 --- a/src/db/Migration.php +++ b/src/db/Migration.php @@ -388,7 +388,7 @@ public function dropAllForeignKeysToTable(string $table): void public function renameTable($table, $newName) { $time = $this->beginCommand("rename table $table to $newName"); - Db::renameTable($table, $newName); + Db::renameTable($table, $newName, $this->db); $this->endCommand($time); } diff --git a/src/db/pgsql/Schema.php b/src/db/pgsql/Schema.php index c8762d37703..51b055de81e 100644 --- a/src/db/pgsql/Schema.php +++ b/src/db/pgsql/Schema.php @@ -138,7 +138,6 @@ public function getDefaultBackupCommand(?array $ignoreTables = null): string ' --no-acl' . ' --file="{file}"' . ' --schema={schema}' . - ' --column-inserts' . ' ' . implode(' ', $ignoredTableArgs); } diff --git a/src/elements/Address.php b/src/elements/Address.php index a33e654269a..469bd885f42 100644 --- a/src/elements/Address.php +++ b/src/elements/Address.php @@ -263,6 +263,10 @@ public function setAttributes($values, $safeOnly = true): void unset($values['countryCode']); } + if (array_key_exists('firstName', $values) || array_key_exists('lastName', $values)) { + $this->fullName = null; + } + parent::setAttributes($values, $safeOnly); } @@ -320,6 +324,18 @@ public function setOwner(?ElementInterface $owner): void $this->ownerId = $owner?->id; } + /** + * Returns whether the address belongs to the currently logged-in user. + * + * @return bool + * @since 4.5.13 + */ + public function getBelongsToCurrentUser(): bool + { + $owner = $this->getOwner(); + return $owner instanceof User && $owner->getIsCurrent(); + } + /** * @inheritdoc */ @@ -546,6 +562,7 @@ public function defineRules(): array LatLongField::class, ]; + $generalConfig = Craft::$app->getConfig()->getGeneral(); $fieldLayout = $this->getFieldLayout(); foreach ($requirableNativeFields as $class) { @@ -553,15 +570,28 @@ public function defineRules(): array $field = $fieldLayout->getFirstVisibleElementByType($class, $this); if ($field && $field->required) { $attribute = $field->attribute(); - if ($attribute === 'latLong') { - $attribute = ['latitude', 'longitude']; + switch ($attribute) { + case 'latLong': + $attribute = ['latitude', 'longitude']; + break; + case 'fullName': + if ($generalConfig->showFirstAndLastNameFields) { + $attribute = ['firstName', 'lastName']; + } + break; } + $rules[] = [$attribute, 'required', 'on' => self::SCENARIO_LIVE]; } } $rules[] = [['longitude', 'latitude'], 'safe']; $rules[] = [self::_addressAttributes(), 'safe']; + + if ($generalConfig->showFirstAndLastNameFields) { + $rules[] = [['firstName', 'lastName'], 'safe']; + } + return $rules; } diff --git a/src/elements/Asset.php b/src/elements/Asset.php index 9db79ba6967..6aac6001e35 100644 --- a/src/elements/Asset.php +++ b/src/elements/Asset.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Element; +use craft\base\ElementInterface; use craft\base\Field; use craft\base\Fs; use craft\base\FsInterface; @@ -20,6 +21,7 @@ use craft\db\Table; use craft\elements\actions\CopyReferenceTag; use craft\elements\actions\CopyUrl; +use craft\elements\actions\DeleteAssets; use craft\elements\actions\DownloadAssetFile; use craft\elements\actions\EditImage; use craft\elements\actions\MoveAssets; @@ -300,7 +302,7 @@ public static function eagerLoadingMap(array $sourceElements, string $handle): a { if ($handle === 'uploader') { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); $map = (new Query()) ->select(['id as source', 'uploaderId as target']) @@ -504,6 +506,11 @@ protected static function defineActions(string $source): array 'type' => Restore::class, 'restorableElementsOnly' => true, ]; + + // Delete + if ($userSession->checkPermission("deletePeerAssets:$volume->uid")) { + $actions[] = DeleteAssets::class; + } } return $actions; @@ -1493,7 +1500,7 @@ public function getAdditionalButtons(): string fileuploadstart: () => { $('#thumb-container').addClass('loading'); }, - fileuploaddone: (event, data) => { + fileuploaddone: (event, data = null) => { const result = event instanceof CustomEvent ? event.detail : data.result; $('#new-filename').val(result.filename); @@ -1524,13 +1531,15 @@ public function getAdditionalButtons(): string } }, - fileuploadfail: (event, data) => { + fileuploadfail: (event, data = null) => { const response = event instanceof Event ? event.detail : data?.jqXHR?.responseJSON; let {message, filename} = response || {}; + filename = filename || data?.files?.[0].name; + if (!message) { message = filename ? Craft.t('app', 'Replace file failed for “{filename}”.', {filename}) @@ -1539,7 +1548,7 @@ public function getAdditionalButtons(): string Craft.cp.displayError(message); }, - fileuploadalways: (event, data) => { + fileuploadalways: (event, data = null) => { $('#thumb-container').removeClass('loading'); }, } @@ -1675,6 +1684,10 @@ public function getUrlsBySize(array $sizes, mixed $transform = null): array return []; } + if (!$this->allowTransforms()) { + return []; + } + $urls = []; if ( @@ -1708,6 +1721,7 @@ public function getUrlsBySize(array $sizes, mixed $transform = null): array 'position', 'quality', 'width', + 'fill', ]) : []; if ($unit === 'w') { @@ -1857,7 +1871,9 @@ public function setUploader(?User $uploader = null): void */ public function setTransform(mixed $transform): Asset { - $this->_transform = ImageTransforms::normalizeTransform($transform); + if ($this->allowTransforms()) { + $this->_transform = ImageTransforms::normalizeTransform($transform); + } return $this; } @@ -1917,8 +1933,14 @@ private function _url(mixed $transform = null, ?bool $immediately = null): ?stri $volume = $this->getVolume(); $transform = $transform ?? $this->_transform; - if ($transform && - !Image::canManipulateAsImage(pathinfo($this->getFilename(), PATHINFO_EXTENSION)) + if ( + $transform && ( + // if it's a site request - check the mime type and general settings and decide whether to nullify the transform + // otherwise - we can proceed and rely on the FallbackTransformer (e.g. for thumbs in the CP) + // see https://github.com/craftcms/cms/issues/13306 and https://github.com/craftcms/cms/issues/13624 for more info + (Craft::$app->getRequest()->getIsSiteRequest() && !$this->allowTransforms()) || + !Image::canManipulateAsImage(pathinfo($this->getFilename(), PATHINFO_EXTENSION)) + ) ) { $transform = null; } @@ -2852,6 +2874,7 @@ public function afterSave(bool $isNew): void $sanitizeCpImageUploads = Craft::$app->getConfig()->getGeneral()->sanitizeCpImageUploads; if ( + isset($this->tempFilePath) && in_array($this->getScenario(), [self::SCENARIO_REPLACE, self::SCENARIO_CREATE], true) && Assets::getFileKindByExtension($this->tempFilePath) === static::KIND_IMAGE && !($isCpRequest && !$sanitizeCpImageUploads) @@ -2859,6 +2882,24 @@ public function afterSave(bool $isNew): void Image::cleanImageByPath($this->tempFilePath); } + // if we're creating or replacing and image, get the width or height via getimagesize + // in case loadImage is not able to get them properly (e.g. imagick runs out of memory) + $fallbackWidth = null; + $fallbackHeight = null; + if ( + isset($this->tempFilePath) && + in_array($this->getScenario(), [self::SCENARIO_REPLACE, self::SCENARIO_CREATE], true) && + Assets::getFileKindByExtension($this->tempFilePath) === static::KIND_IMAGE + ) { + $imageSize = getimagesize($this->tempFilePath); + if (isset($imageSize[0])) { + $fallbackWidth = (int)$imageSize[0]; + } + if (isset($imageSize[1])) { + $fallbackHeight = (int)$imageSize[1]; + } + } + // Relocate the file? if (isset($this->newLocation) || isset($this->tempFilePath)) { $this->_relocateFile(); @@ -2883,8 +2924,8 @@ public function afterSave(bool $isNew): void $record->kind = $this->kind; $record->alt = $this->alt; $record->size = (int)$this->size ?: null; - $record->width = (int)$this->_width ?: null; - $record->height = (int)$this->_height ?: null; + $record->width = (int)$this->_width ?: $fallbackWidth; + $record->height = (int)$this->_height ?: $fallbackHeight; $record->dateModified = Db::prepareDateForDb($this->dateModified); if ($this->getHasFocalPoint()) { @@ -3274,4 +3315,20 @@ private function _normalizeTempPath(string|false $path): string|false return FileHelper::normalizePath($path) . DIRECTORY_SEPARATOR; } + + /** + * Returns whether transforming given asset is allowed + * based on its mime type and general settings. + * + * @return bool + * @throws ImageTransformException + */ + private function allowTransforms(): bool + { + return match ($this->getMimeType()) { + 'image/gif' => Craft::$app->getConfig()->getGeneral()->transformGifs, + 'image/svg+xml' => Craft::$app->getConfig()->getGeneral()->transformSvgs, + default => true, + }; + } } diff --git a/src/elements/Category.php b/src/elements/Category.php index 73715f3291e..2a6e67f2ab9 100644 --- a/src/elements/Category.php +++ b/src/elements/Category.php @@ -435,16 +435,15 @@ protected function previewTargets(): array protected function route(): array|string|null { // Make sure the category group is set to have URLs for this site - $siteId = Craft::$app->getSites()->getCurrentSite()->id; - $categoryGroupSiteSettings = $this->getGroup()->getSiteSettings(); + $categoryGroupSiteSettings = $this->getGroup()->getSiteSettings()[$this->siteId] ?? null; - if (!isset($categoryGroupSiteSettings[$siteId]) || !$categoryGroupSiteSettings[$siteId]->hasUrls) { + if (!$categoryGroupSiteSettings?->hasUrls) { return null; } return [ 'templates/render', [ - 'template' => (string)$categoryGroupSiteSettings[$siteId]->template, + 'template' => (string)$categoryGroupSiteSettings->template, 'variables' => [ 'category' => $this, ], diff --git a/src/elements/ElementCollection.php b/src/elements/ElementCollection.php index 9ee2e162a37..2562672dedc 100644 --- a/src/elements/ElementCollection.php +++ b/src/elements/ElementCollection.php @@ -16,6 +16,7 @@ * * @template TKey of array-key * @template TValue of ElementInterface + * @extends Collection * * @method TValue one(callable|null $callback, mixed $default) * @author Pixel & Tonic, Inc. diff --git a/src/elements/Entry.php b/src/elements/Entry.php index 05e23f061c4..88558e0e1ad 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -967,16 +967,15 @@ protected function route(): array|string|null } // Make sure the section is set to have URLs for this site - $siteId = Craft::$app->getSites()->getCurrentSite()->id; - $sectionSiteSettings = $this->getSection()->getSiteSettings(); + $sectionSiteSettings = $this->getSection()->getSiteSettings()[$this->siteId] ?? null; - if (!isset($sectionSiteSettings[$siteId]) || !$sectionSiteSettings[$siteId]->hasUrls) { + if (!$sectionSiteSettings?->hasUrls) { return null; } return [ 'templates/render', [ - 'template' => (string)$sectionSiteSettings[$siteId]->template, + 'template' => (string)$sectionSiteSettings->template, 'variables' => [ 'entry' => $this, ], @@ -1649,6 +1648,7 @@ public function metaFieldsHtml(bool $static): string } return Cp::selectFieldHtml([ + 'status' => $this->getAttributeStatus('typeId'), 'label' => Craft::t('app', 'Entry Type'), 'id' => 'entryType', 'name' => 'typeId', @@ -1708,6 +1708,7 @@ public function metaFieldsHtml(bool $static): string $fields[] = (function() use ($static, $section) { $author = $this->getAuthor(); return Cp::elementSelectFieldHtml([ + 'status' => $this->getAttributeStatus('authorId'), 'label' => Craft::t('app', 'Author'), 'id' => 'authorId', 'name' => 'authorId', @@ -1732,6 +1733,7 @@ public function metaFieldsHtml(bool $static): string // Post Date $fields[] = Cp::dateTimeFieldHtml([ + 'status' => $this->getAttributeStatus('postDate'), 'label' => Craft::t('app', 'Post Date'), 'id' => 'postDate', 'name' => 'postDate', @@ -1742,6 +1744,7 @@ public function metaFieldsHtml(bool $static): string // Expiry Date $fields[] = Cp::dateTimeFieldHtml([ + 'status' => $this->getAttributeStatus('expiryDate'), 'label' => Craft::t('app', 'Expiry Date'), 'id' => 'expiryDate', 'name' => 'expiryDate', diff --git a/src/elements/User.php b/src/elements/User.php index 99713b7c325..fd01ff4c28f 100644 --- a/src/elements/User.php +++ b/src/elements/User.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Element; +use craft\base\ElementInterface; use craft\base\NameTrait; use craft\db\Query; use craft\db\Table; @@ -480,7 +481,7 @@ protected static function prepElementQueryForTableAttribute(ElementQueryInterfac public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false { // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); if ($handle == 'addresses') { $map = (new Query()) @@ -895,6 +896,22 @@ protected function defineRules(): array return $rules; } + /** + * @inheritdoc + */ + public function setAttributes($values, $safeOnly = true): void + { + if ($safeOnly) { + unset($values['email'], $values['unverifiedEmail']); + } + + if (array_key_exists('firstName', $values) || array_key_exists('lastName', $values)) { + $this->fullName = null; + } + + parent::setAttributes($values, $safeOnly); + } + /** * Returns whether the user account can be logged into. * @@ -974,7 +991,7 @@ public function getAddresses(): array /** @var Address[] $addresses */ $addresses = Address::find() - ->ownerId($this->id) + ->owner($this) ->orderBy(['id' => SORT_ASC]) ->all(); $this->_addresses = $addresses; @@ -1140,10 +1157,10 @@ public function isInGroup(UserGroup|int|string $group): bool } if (is_numeric($group)) { - return in_array($group, ArrayHelper::getColumn($this->getGroups(), 'id'), false); + return ArrayHelper::contains($this->getGroups(), fn(UserGroup $g) => $g->id == $group); } - return in_array($group, ArrayHelper::getColumn($this->getGroups(), 'handle'), true); + return ArrayHelper::contains($this->getGroups(), fn(UserGroup $g) => $g->handle === $group); } /** @@ -1306,17 +1323,10 @@ protected function thumbSvg(): ?string - - + + - - $initials $initials @@ -1682,6 +1692,7 @@ protected function metaFieldsHtml(bool $static): string 'user' => $this, 'isNewUser' => !$this->id, 'static' => $static, + 'meta' => true, ]), parent::metaFieldsHtml($static), ]); @@ -1921,6 +1932,10 @@ private function _validateUserAgent(string $userAgent): bool $requestUserAgent = Craft::$app->getRequest()->getUserAgent(); + if (!$requestUserAgent) { + return false; + } + if (!hash_equals($userAgent, md5($requestUserAgent))) { Craft::warning('Tried to restore session from the the identity cookie, but the saved user agent (' . $userAgent . ') does not match the current request’s (' . $requestUserAgent . ').', __METHOD__); return false; diff --git a/src/elements/actions/DeleteAssets.php b/src/elements/actions/DeleteAssets.php index aab3edbccb8..be20a44a85e 100644 --- a/src/elements/actions/DeleteAssets.php +++ b/src/elements/actions/DeleteAssets.php @@ -8,46 +8,15 @@ namespace craft\elements\actions; use Craft; -use craft\base\ElementAction; -use craft\elements\Asset; -use craft\elements\db\ElementQueryInterface; -use yii\base\Exception; /** - * DeleteAssets represents a Delete Assets element action. + * DeleteAssets represents a Delete element action, tuned for assets. * * @author Pixel & Tonic, Inc. * @since 3.0.0 - * @deprecated in 4.1.0. [[Delete]] should be used instead. */ -class DeleteAssets extends ElementAction +class DeleteAssets extends Delete { - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return Craft::t('app', 'Delete'); - } - - /** - * @inheritdoc - */ - public static function isDestructive(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getConfirmationMessage(): ?string - { - return Craft::t('app', 'Are you sure you want to delete the selected {type}?', [ - 'type' => Asset::pluralLowerDisplayName(), - ]); - } - /** * @inheritdoc * @since 3.5.15 @@ -57,46 +26,47 @@ public function getTriggerHtml(): ?string // Only enable for deletable elements, per canDelete() Craft::$app->getView()->registerJsWithVars(fn($type) => << { - new Craft.ElementActionTrigger({ - type: $type, - validateSelection: \$selectedItems => { - for (let i = 0; i < \$selectedItems.length; i++) { - if (!Garnish.hasAttr(\$selectedItems.eq(i).find('.element'), 'data-deletable')) { - return false; - } - } - return true; - }, - }); -})(); -JS, [static::class]); - - return null; - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - $elementsService = Craft::$app->getElements(); - $user = Craft::$app->getUser()->getIdentity(); - - try { - foreach ($query->all() as $asset) { - if ($elementsService->canView($asset, $user) && $elementsService->canDelete($asset, $user)) { - $elementsService->deleteElement($asset); - } - } - } catch (Exception $exception) { - $this->setMessage($exception->getMessage()); + const trigger = new Craft.ElementActionTrigger({ + type: $type, + requireId: false, + validateSelection: \$selectedItems => { + for (let i = 0; i < \$selectedItems.length; i++) { + const \$element = \$selectedItems.eq(i).find('.element'); + if (Garnish.hasAttr(\$element, 'data-is-folder')) { + if (\$selectedItems.length !== 1) { + // only one folder at a time + return false; + } + const sourcePath = \$element.data('source-path') || []; + if (!sourcePath.length || !sourcePath[sourcePath.length - 1].canDelete) { return false; + } + } else { + if (!Garnish.hasAttr(\$element, 'data-deletable')) { + return false; + } } + } - $this->setMessage(Craft::t('app', '{type} deleted.', [ - 'type' => Asset::pluralDisplayName(), - ])); + return true; + }, + + activate: function(\$selectedItems) { + const \$element = \$selectedItems.find('.element:first'); + if (Garnish.hasAttr(\$element, 'data-is-folder')) { + const sourcePath = \$element.data('source-path'); + Craft.elementIndex.deleteFolder(sourcePath[sourcePath.length - 1]) + .then(() => { + Craft.elementIndex.updateElements(); + }); + } else { + Craft.elementIndex.submitAction(trigger.\$trigger.data('action'), Garnish.getPostData(trigger.\$trigger)); + } + }, + }); +})(); +JS, [static::class]); - return true; + return null; } } diff --git a/src/elements/actions/SetStatus.php b/src/elements/actions/SetStatus.php index 8b7b05dc291..984b3488bf8 100644 --- a/src/elements/actions/SetStatus.php +++ b/src/elements/actions/SetStatus.php @@ -61,11 +61,13 @@ public function getTriggerHtml(): ?string new Craft.ElementActionTrigger({ type: $type, validateSelection: (selectedItems) => { - const element = selectedItems.find('.element'); - return ( - Garnish.hasAttr(element, 'data-savable') && - !Garnish.hasAttr(element, 'data-disallow-status') - ); + for (let i = 0; i < selectedItems.length; i++) { + const element = selectedItems.eq(i).find('.element'); + if (!Garnish.hasAttr(element, 'data-savable') || Garnish.hasAttr(element, 'data-disallow-status')) { + return false; + } + } + return true; }, }); })(); diff --git a/src/elements/db/AddressQuery.php b/src/elements/db/AddressQuery.php index df8c8090ec1..8b25bac88ee 100644 --- a/src/elements/db/AddressQuery.php +++ b/src/elements/db/AddressQuery.php @@ -39,6 +39,12 @@ class AddressQuery extends ElementQuery */ public mixed $ownerId = null; + /** + * @var ElementInterface|null The owner element specified by [[owner()]]. + * @used-by owner() + */ + private ?ElementInterface $_owner = null; + /** * @var mixed The address countryCode(s) that the resulting address must be in. * --- @@ -144,6 +150,7 @@ public function administrativeArea(array|string|null $value): self public function owner(ElementInterface $owner): self { $this->ownerId = [$owner->id]; + $this->_owner = $owner; return $this; } @@ -180,6 +187,7 @@ public function owner(ElementInterface $owner): self public function ownerId(array|int|null $value): self { $this->ownerId = $value; + $this->_owner = null; return $this; } @@ -269,6 +277,15 @@ protected function beforePrepare(): bool return parent::beforePrepare(); } + public function createElement(array $row): ElementInterface + { + if (isset($this->_owner)) { + $row['owner'] = $this->_owner; + } + + return parent::createElement($row); // TODO: Change the autogenerated stub + } + /** * Normalizes the ownerId param to an array of IDs or null * diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index e8cb36882e8..c12c7502ae4 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -16,6 +16,7 @@ use craft\behaviors\DraftBehavior; use craft\behaviors\RevisionBehavior; use craft\cache\ElementQueryTagDependency; +use craft\db\Connection; use craft\db\FixedOrderExpression; use craft\db\Query; use craft\db\QueryAbortedException; @@ -41,7 +42,7 @@ use yii\base\Exception; use yii\base\InvalidArgumentException; use yii\base\NotSupportedException; -use yii\db\Connection; +use yii\db\Connection as YiiConnection; use yii\db\Expression; use yii\db\ExpressionInterface; use yii\db\QueryBuilder; @@ -974,9 +975,9 @@ public function siteId($value): self /** * @inheritdoc + * @return static * @uses $unique * @since 3.2.0 - * @return static */ public function unique(bool $value = true): self { @@ -1351,6 +1352,14 @@ public function prepare($builder): Query $this->orderBy = $this->normalizeOrderBy($this->orderBy); } + // Normalize `offset` and `limit` for _applySearchParam() + if (is_numeric($this->offset)) { + $this->offset = (int)$this->offset; + } + if (is_numeric($this->limit)) { + $this->limit = (int)$this->limit; + } + // Build the query // --------------------------------------------------------------------- @@ -1486,7 +1495,7 @@ public function prepare($builder): Query $this->_applyRelatedToParam(); $this->_applyStructureParams($class); $this->_applyRevisionParams(); - $this->_applySearchParam($builder->db); + $this->_applySearchParam(); $this->_applyOrderByParams($builder->db); $this->_applySelectParam(); $this->_applyJoinParams(); @@ -1582,10 +1591,10 @@ public function all($db = null): array } /** - * @param Connection|null $db + * @param YiiConnection|null $db * @return Collection */ - public function collect(?Connection $db = null): Collection + public function collect(?YiiConnection $db = null): Collection { return ElementCollection::make($this->all($db)); } @@ -1642,7 +1651,7 @@ public function exists($db = null): bool * @inheritdoc * @return ElementInterface|array|null */ - public function nth(int $n, ?Connection $db = null): mixed + public function nth(int $n, ?YiiConnection $db = null): mixed { // Cached? if (($cachedResult = $this->getCachedResult()) !== null) { @@ -1655,7 +1664,7 @@ public function nth(int $n, ?Connection $db = null): mixed /** * @inheritdoc */ - public function ids(?Connection $db = null): array + public function ids(?YiiConnection $db = null): array { $select = $this->select; $this->select = ['elements.id' => 'elements.id']; @@ -2757,36 +2766,64 @@ private function _normalizeStructureParamValue(string $property, string $class): /** * Applies the 'search' param to the query being prepared. * - * @param Connection $db - * @throws Exception if the DB connection doesn't support fixed ordering * @throws QueryAbortedException */ - private function _applySearchParam(Connection $db): void + private function _applySearchParam(): void { $this->_searchResults = null; - if ($this->search) { + if (!$this->search) { + return; + } + + if (isset($this->orderBy['score'])) { + // Get the scored results up front $searchResults = Craft::$app->getSearch()->searchElements($this); - // No results? + if ($this->orderBy['score'] === SORT_ASC) { + $searchResults = array_reverse($searchResults, true); + } + + if (array_key_first($this->orderBy) === 'score') { + // Only use the portion we're actually querying for + if (is_int($this->offset) && $this->offset !== 0) { + $searchResults = array_slice($searchResults, $this->offset, null, true); + $this->subQuery->offset(null); + } + if (is_int($this->limit) && $this->limit !== 0) { + $searchResults = array_slice($searchResults, 0, $this->limit, true); + $this->subQuery->limit(null); + } + } + if (empty($searchResults)) { throw new QueryAbortedException(); } $this->_searchResults = $searchResults; - $this->subQuery->andWhere(['elements.id' => array_keys($searchResults)]); + } else { + // Just filter the main query by the search query + $searchQuery = Craft::$app->getSearch()->createDbQuery($this->search, $this); + + if ($searchQuery === false) { + throw new QueryAbortedException(); + } + + $this->subQuery->andWhere([ + 'elements.id' => $searchQuery->select(['elementId']), + ]); } } /** * Applies the 'fixedOrder' and 'orderBy' params to the query being prepared. * - * @param Connection $db + * @param YiiConnection $db * @throws Exception if the DB connection doesn't support fixed ordering * @throws QueryAbortedException */ - private function _applyOrderByParams(Connection $db): void + private function _applyOrderByParams(YiiConnection $db): void { if (!isset($this->orderBy) || !empty($this->query->orderBy)) { return; @@ -2804,7 +2841,7 @@ private function _applyOrderByParams(Connection $db): void $ids = is_string($ids) ? StringHelper::split($ids) : [$ids]; } - if (!$db instanceof \craft\db\Connection) { + if (!$db instanceof Connection) { throw new Exception('The database connection doesn’t support fixed ordering.'); } $this->orderBy = [new FixedOrderExpression('elements.id', $ids, $db)]; @@ -2836,6 +2873,16 @@ private function _applyOrderByParams(Connection $db): void } } + // swap `score` direction value with a fixed order expression + if (isset($this->_searchResults)) { + if (!$db instanceof Connection) { + throw new Exception('The database connection doesn’t support fixed ordering.'); + } + $orderBy['score'] = new FixedOrderExpression('elements.id', array_keys($this->_searchResults), $db); + } else { + unset($orderBy['score']); + } + if ($this->inReverse) { foreach ($orderBy as &$direction) { if ($direction instanceof FixedOrderExpression) { @@ -2849,41 +2896,6 @@ private function _applyOrderByParams(Connection $db): void unset($direction); } - // swap `score` direction value with a case expression - if ( - !empty($this->_searchResults) && - isset($orderBy['score']) && - in_array($orderBy['score'], [SORT_ASC, SORT_DESC], true) - ) { - $elementIdsByScore = []; - foreach ($this->_searchResults as $elementId => $score) { - if ($score !== 0) { - $elementIdsByScore[$score][] = $elementId; - } - } - if (!empty($elementIdsByScore)) { - $caseSql = 'CASE'; - foreach ($elementIdsByScore as $score => $elementIds) { - $caseSql .= ' WHEN ('; - if (count($elementIds) === 1) { - $caseSql .= "[[elements.id]] = $elementIds[0]"; - } else { - $caseSql .= '[[elements.id]] IN (' . implode(',', $elementIds) . ')'; - } - $caseSql .= ") THEN $score"; - } - $caseSql .= ' ELSE 0 END'; - if ($orderBy['score'] === SORT_DESC) { - $caseSql .= ' DESC'; - } - $orderBy['score'] = new Expression($caseSql); - } else { - unset($orderBy['score']); - } - } else { - unset($orderBy['score']); - } - $this->query->orderBy($orderBy); $this->subQuery->orderBy($orderBy); } @@ -2965,9 +2977,9 @@ private function _applyJoinParams(): void /** * Applies the 'unique' param to the query being prepared * - * @param Connection $db + * @param YiiConnection $db */ - private function _applyUniqueParam(Connection $db): void + private function _applyUniqueParam(YiiConnection $db): void { if ( !$this->unique || diff --git a/src/elements/exporters/Expanded.php b/src/elements/exporters/Expanded.php index 2a664066f05..3b759f65416 100644 --- a/src/elements/exporters/Expanded.php +++ b/src/elements/exporters/Expanded.php @@ -40,7 +40,12 @@ public function export(ElementQueryInterface $query): mixed $eagerLoadableFields = []; foreach (Craft::$app->getFields()->getAllFields() as $field) { if ($field instanceof EagerLoadingFieldInterface) { - $eagerLoadableFields[] = $field->handle; + $eagerLoadableFields[] = [ + 'path' => $field->handle, + 'criteria' => [ + 'status' => null, + ], + ]; } } diff --git a/src/errors/ExitException.php b/src/errors/ExitException.php new file mode 100644 index 00000000000..61d1c70f7bf --- /dev/null +++ b/src/errors/ExitException.php @@ -0,0 +1,30 @@ + + * @since 4.5.8 + */ +class ExitException extends YiiExitException +{ + public function __construct( + int $status = 0, + ?string $message = null, + int $code = 0, + ?Throwable $previous = null, + public ?string $output = null, + ) { + parent::__construct($status, $message, $code, $previous); + } +} diff --git a/src/errors/MutexException.php b/src/errors/MutexException.php new file mode 100644 index 00000000000..b21c1ab5d71 --- /dev/null +++ b/src/errors/MutexException.php @@ -0,0 +1,40 @@ + + * @since 4.5.12 + */ +class MutexException extends Exception +{ + /** + * Constructor + * + * @param string $name + * @param string $message + * @param int $code + * @param Throwable|null $previous + */ + public function __construct( + public string $name, + string $message = '', + int $code = 0, + ?Throwable $previous = null, + ) { + parent::__construct($message, $code, $previous); + } + + /** + * @inheritdoc + */ + public function getName() + { + return 'Mutex Exception'; + } +} diff --git a/src/events/DefineCompatibleFieldTypesEvent.php b/src/events/DefineCompatibleFieldTypesEvent.php new file mode 100644 index 00000000000..c8954294374 --- /dev/null +++ b/src/events/DefineCompatibleFieldTypesEvent.php @@ -0,0 +1,22 @@ + + * @since 4.5.7 + */ +class DefineCompatibleFieldTypesEvent extends FieldEvent +{ + /** + * @var string[] The field type classes that are considered compatible with [[$field]]. + */ + public array $compatibleTypes; +} diff --git a/src/events/DefineUserGroupsEvent.php b/src/events/DefineUserGroupsEvent.php new file mode 100644 index 00000000000..9118b90ff28 --- /dev/null +++ b/src/events/DefineUserGroupsEvent.php @@ -0,0 +1,24 @@ + + * @since 4.5.4 + */ +class DefineUserGroupsEvent extends UserEvent +{ + /** + * @var UserGroup[] The user groups to assign to the user + */ + public array $userGroups; +} diff --git a/src/events/UserAssignGroupEvent.php b/src/events/UserAssignGroupEvent.php index 9296c795c8e..725106b8258 100644 --- a/src/events/UserAssignGroupEvent.php +++ b/src/events/UserAssignGroupEvent.php @@ -14,11 +14,8 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated in 4.5.4. [[DefineUserGroupsEvent]] should be used instead. */ -class UserAssignGroupEvent extends CancelableEvent +class UserAssignGroupEvent extends DefineUserGroupsEvent { - /** - * @var User The user model associated with this event - */ - public User $user; } diff --git a/src/fieldlayoutelements/BaseField.php b/src/fieldlayoutelements/BaseField.php index 1db50795259..8ecda2d1071 100644 --- a/src/fieldlayoutelements/BaseField.php +++ b/src/fieldlayoutelements/BaseField.php @@ -66,6 +66,17 @@ public function __construct($config = []) */ abstract public function attribute(): string; + /** + * Returns whether the attribute should be shown for admin users with “Show field handles in edit forms” enabled. + * + * @return bool + * @since 4.5.4 + */ + public function showAttribute(): bool + { + return false; + } + /** * Returns the field’s value. * @@ -247,6 +258,7 @@ public function formHtml(?ElementInterface $element = null, bool $static = false 'status' => $statusClass ? [$statusClass, $this->statusLabel($element, $static) ?? ucfirst($statusClass)] : null, 'label' => $label !== null ? Html::encode($label) : null, 'attribute' => $this->attribute(), + 'showAttribute' => $this->showAttribute(), 'required' => !$static && $this->required, 'instructions' => $instructions !== null ? Html::encode($instructions) : null, 'tip' => $tip !== null ? Html::encode($tip) : null, diff --git a/src/fieldlayoutelements/CustomField.php b/src/fieldlayoutelements/CustomField.php index 6f15ff94432..6083c415c3d 100644 --- a/src/fieldlayoutelements/CustomField.php +++ b/src/fieldlayoutelements/CustomField.php @@ -47,6 +47,14 @@ public function attribute(): string return $this->_field->handle; } + /** + * @inheritdoc + */ + public function showAttribute(): bool + { + return true; + } + /** * @inheritdoc * @since 3.5.2 diff --git a/src/fieldlayoutelements/FullNameField.php b/src/fieldlayoutelements/FullNameField.php index f4f2677857d..1002f0bd996 100644 --- a/src/fieldlayoutelements/FullNameField.php +++ b/src/fieldlayoutelements/FullNameField.php @@ -9,6 +9,8 @@ use Craft; use craft\base\ElementInterface; +use craft\helpers\Cp; +use craft\helpers\Html as HtmlHelper; /** * Class FullNameField. @@ -58,6 +60,72 @@ public function fields(): array return $fields; } + /** + * @inheritdoc + */ + public function formHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if ( + $element && + Craft::$app->getConfig()->getGeneral()->showFirstAndLastNameFields && + count(array_intersect($element->safeAttributes(), ['firstName', 'lastName'])) === 2 + ) { + return $this->firstAndLastNameFields($element, $static); + } + + return parent::formHtml($element, $static); + } + + private function firstAndLastNameFields(?ElementInterface $element, bool $static): string + { + $statusClass = $this->statusClass($element); + $status = $statusClass ? [$statusClass, $this->statusLabel($element, $static) ?? ucfirst($statusClass)] : null; + $required = !$static && $this->required; + + return HtmlHelper::beginTag('div', ['class' => ['flex', 'flex-nowrap', 'fullwidth']]) . + Cp::textFieldHtml([ + 'id' => 'firstName', + 'status' => $status, + 'fieldClass' => 'flex-grow', + 'label' => Craft::t('app', 'First Name'), + 'attribute' => 'firstName', + 'showAttribute' => $this->showAttribute(), + 'required' => $required, + 'autocomplete' => false, + 'name' => 'firstName', + 'value' => $element->firstName ?? null, + 'errors' => !$static ? $this->errors($element) : [], + 'disabled' => $static, + ]) . + Cp::textFieldHtml([ + 'id' => 'lastName', + 'status' => $status, + 'fieldClass' => 'flex-grow', + 'label' => Craft::t('app', 'Last Name'), + 'attribute' => 'lastName', + 'showAttribute' => $this->showAttribute(), + 'required' => $required, + 'autocomplete' => false, + 'name' => 'lastName', + 'value' => $element->lastName ?? null, + 'disabled' => $static, + ]) . + HtmlHelper::endTag('div'); + } + + /** + * @inheritdoc + */ + protected function settingsHtml(): ?string + { + if (Craft::$app->getConfig()->getGeneral()->showFirstAndLastNameFields) { + // can't know for sure if the element will support firstName and lastName, but probably? + return null; + } + + return parent::settingsHtml(); + } + /** * @inheritdoc */ diff --git a/src/fieldlayoutelements/TextField.php b/src/fieldlayoutelements/TextField.php index de366d9e75c..3889c7a024d 100644 --- a/src/fieldlayoutelements/TextField.php +++ b/src/fieldlayoutelements/TextField.php @@ -18,8 +18,15 @@ */ class TextField extends BaseNativeField { + /** + * @var string|null The input type + * @since 4.5.12 + */ + public ?string $inputType = null; + /** * @var string The input type + * @deprecated in 4.5.12. [[$inputType]] should be used instead. */ public string $type = 'text'; @@ -123,7 +130,7 @@ public function fields(): array protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string { return Craft::$app->getView()->renderTemplate('_includes/forms/text.twig', [ - 'type' => $this->type, + 'type' => $this->inputType ?? $this->type, 'autocomplete' => $this->autocomplete, 'class' => $this->class, 'id' => $this->id(), diff --git a/src/fieldlayoutelements/Tip.php b/src/fieldlayoutelements/Tip.php index b8763a7e2b5..7070e73ada0 100644 --- a/src/fieldlayoutelements/Tip.php +++ b/src/fieldlayoutelements/Tip.php @@ -101,7 +101,7 @@ public function formHtml(?ElementInterface $element = null, bool $static = false $classes[] = 'dismissible'; } - $tip = Markdown::process(Html::encode(Craft::t('site', $this->tip))); + $tip = Markdown::process(Html::encode(Craft::t('site', $this->tip)), 'pre-encoded'); $closeBtn = $this->dismissible ? Html::button('', [ 'class' => 'tip-dismiss-btn', diff --git a/src/fieldlayoutelements/addresses/CountryCodeField.php b/src/fieldlayoutelements/addresses/CountryCodeField.php index a700f980d7c..9842e24ba0a 100644 --- a/src/fieldlayoutelements/addresses/CountryCodeField.php +++ b/src/fieldlayoutelements/addresses/CountryCodeField.php @@ -96,6 +96,7 @@ protected function inputHtml(?ElementInterface $element = null, bool $static = f 'name' => 'countryCode', 'options' => Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language), 'value' => $element->countryCode, + 'autocomplete' => $element->getBelongsToCurrentUser() ? 'country' : 'off', ]) . Html::tag('div', '', [ 'id' => 'countryCode-spinner', diff --git a/src/fieldlayoutelements/entries/EntryTitleField.php b/src/fieldlayoutelements/entries/EntryTitleField.php index 05ef41a181f..f8f7e0a803a 100644 --- a/src/fieldlayoutelements/entries/EntryTitleField.php +++ b/src/fieldlayoutelements/entries/EntryTitleField.php @@ -68,7 +68,7 @@ public function inputHtml(?ElementInterface $element = null, bool $static = fals throw new InvalidArgumentException('EntryTitleField can only be used in entry field layouts.'); } - if (!$element->getType()->hasTitleField && !$element->hasErrors('title')) { + if (!$element->getType()->hasTitleField) { return null; } diff --git a/src/fields/Assets.php b/src/fields/Assets.php index c6888d381b2..ece9d049756 100644 --- a/src/fields/Assets.php +++ b/src/fields/Assets.php @@ -238,19 +238,65 @@ protected function defineRules(): array }, ]; + $rules[] = [ + ['sources', 'defaultUploadLocationSource', 'restrictedLocationSource'], 'validateNotTempVolume', + ]; + $rules[] = [['previewMode'], 'in', 'range' => [self::PREVIEW_MODE_FULL, self::PREVIEW_MODE_THUMBS], 'skipOnEmpty' => false]; return $rules; } + /** + * Ensure that you can't select tempUploadsLocation volume as a source or default uploads location or restricted location for an Assets field. + * + * @param string $attribute + * @since 4.7.0 + */ + public function validateNotTempVolume(string $attribute): void + { + [$tempVolume] = Craft::$app->getAssets()->getTempVolumeAndSubpath(); + if ($tempVolume !== null) { + $tempVolumeKey = "volume:$tempVolume->uid"; + $inputSources = $this->getInputSources(); + + if ( + (in_array($attribute, ['source', 'sources']) && in_array($tempVolumeKey, $inputSources)) || + ($attribute == 'defaultUploadLocationSource' && $this->defaultUploadLocationSource === $tempVolumeKey) || + ($attribute == 'restrictedLocationSource' && $this->restrictedLocationSource === $tempVolumeKey) + ) { + // intentionally not translating this since it's short-lived (>= 4.7, < 5.0) and dev-facing only. + $this->addError($attribute, "Volume “{$tempVolume->name}” is used to store temporary asset uploads, so it cannot be used in an Assets field."); + } + } + } + /** * @inheritdoc */ public function getSourceOptions(): array { $sourceOptions = []; + /** @var Volume|null $tempVolume */ + [$tempVolume] = Craft::$app->getAssets()->getTempVolumeAndSubpath(); + if ($tempVolume) { + $tempVolumeKey = 'volume:' . $tempVolume->uid; + } else { + $tempVolumeKey = null; + } foreach (Asset::sources('settings') as $volume) { + if ($tempVolumeKey !== null && $volume['key'] === $tempVolumeKey) { + // only allow it if already selected + if ( + (!is_array($this->sources) || !in_array($tempVolumeKey, $this->sources)) && + $this->defaultUploadLocationSource !== $tempVolumeKey && + $this->restrictedLocationSource !== $tempVolumeKey + ) { + continue; + } + } + if (!isset($volume['heading'])) { $sourceOptions[] = [ 'label' => $volume['label'], @@ -585,7 +631,7 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void // Find the files with temp sources and just move those. /** @var Asset[] $assetsToMove */ $assetsToMove = $assetsService->createTempAssetQuery() - ->id(ArrayHelper::getColumn($assets, 'id')) + ->id(array_map(fn(Asset $asset) => $asset->id, $assets)) ->all(); } @@ -731,7 +777,6 @@ protected function inputTemplateVariables(array|ElementQueryInterface $value = n $variables['defaultSourcePath'] = array_map(function(VolumeFolder $folder) { return $folder->getSourcePathInfo(); }, $folders); - $variables['preferStoredSource'] = true; } } diff --git a/src/fields/BaseOptionsField.php b/src/fields/BaseOptionsField.php index 39c733d56cb..1e3f56c3661 100644 --- a/src/fields/BaseOptionsField.php +++ b/src/fields/BaseOptionsField.php @@ -323,6 +323,8 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): $value = Json::decodeIfJson($value); } elseif ($value === '' && $this->multi) { $value = []; + } elseif ($value === '__BLANK__') { + $value = ''; } elseif ($value === null && $this->isFresh($element)) { $value = $this->defaultValue(); } @@ -343,19 +345,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): $optionLabels = []; foreach ($this->options() as $option) { if (!isset($option['optgroup'])) { - // special case for blank options, when $value is null - if ($value === null && $option['value'] === '') { - if (!$selectedBlankOption) { - $selectedValues[] = ''; - $selectedBlankOption = true; - $selected = true; - } else { - $selected = false; - } - } else { - $selected = in_array($option['value'], $selectedValues, true); - } - + $selected = $this->isOptionSelected($option, $value, $selectedValues, $selectedBlankOption); $options[] = new OptionData($option['label'], $option['value'], $selected, true); $optionValues[] = (string)$option['value']; $optionLabels[] = (string)$option['label']; @@ -388,6 +378,20 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): return $value; } + /** + * Check if given option should be marked as selected. + * + * @param array $option + * @param mixed $value + * @param array $selectedValues + * @param bool $selectedBlankOption + * @return bool + */ + protected function isOptionSelected(array $option, mixed $value, array &$selectedValues, bool &$selectedBlankOption): bool + { + return in_array($option['value'], $selectedValues, true); + } + /** * @inheritdoc */ @@ -492,12 +496,11 @@ public function getElementValidationRules(): array */ public function isValueEmpty(mixed $value, ElementInterface $element): bool { - /** @var MultiOptionsFieldData|SingleOptionFieldData $value */ - if ($value instanceof SingleOptionFieldData) { - return $value->value === null || $value->value === ''; + if ($value instanceof MultiOptionsFieldData) { + return count($value) === 0; } - return count($value) === 0; + return $value->value === null || $value->value === ''; } /** @@ -511,7 +514,7 @@ public function getTableAttributeHtml(mixed $value, ElementInterface $element): foreach ($value as $option) { /** @var OptionData $option */ - if ($option->value) { + if (!$this->isValueEmpty($option, $element)) { $labels[] = Craft::t('site', $option->label); } } @@ -520,7 +523,7 @@ public function getTableAttributeHtml(mixed $value, ElementInterface $element): } /** @var SingleOptionFieldData $value */ - return $value->value ? Craft::t('site', (string)$value->label) : ''; + return !$this->isValueEmpty($value, $element) ? Craft::t('site', (string)$value->label) : ''; } /** diff --git a/src/fields/BaseRelationField.php b/src/fields/BaseRelationField.php index 37415bd936c..ac0092780b8 100644 --- a/src/fields/BaseRelationField.php +++ b/src/fields/BaseRelationField.php @@ -26,7 +26,6 @@ use craft\elements\ElementCollection; use craft\errors\SiteNotFoundException; use craft\events\ElementCriteriaEvent; -use craft\events\ElementEvent; use craft\fields\conditions\RelationalFieldConditionRule; use craft\helpers\ArrayHelper; use craft\helpers\Cp; @@ -59,6 +58,8 @@ abstract class BaseRelationField extends Field implements PreviewableFieldInterf */ public const EVENT_DEFINE_SELECTION_CRITERIA = 'defineSelectionCriteria'; + private static bool $validatingRelatedElements = false; + /** * @inheritdoc */ @@ -104,18 +105,6 @@ public static function valueType(): string return sprintf('\\%s|\\%s<\\%s>', ElementQueryInterface::class, ElementCollection::class, ElementInterface::class); } - /** - * @var array Related elements that have been validated - * @see _validateRelatedElement() - */ - private static array $_relatedElementValidates = []; - - /** - * @var bool Whether we're listening for related element saves yet - * @see _validateRelatedElement() - */ - private static bool $_listeningForRelatedElementSave = false; - /** * @var string|string[]|null The source keys that this field can relate elements from (used if [[allowMultipleSources]] is set to true) */ @@ -439,6 +428,10 @@ public function validateRelationCount(ElementInterface $element): void /** @var ElementQueryInterface|Collection $value */ $value = $element->getFieldValue($this->handle); + if ($value instanceof ElementQueryInterface) { + $value = $this->_all($value, $element); + } + $arrayValidator = new NumberValidator([ 'min' => $this->minRelations, 'max' => $this->maxRelations, @@ -466,30 +459,28 @@ public function validateRelationCount(ElementInterface $element): void */ public function validateRelatedElements(ElementInterface $element): void { - // Prevent circular relations from worrying about this element - $sourceId = $element->getCanonicalId(); - $sourceValidates = self::$_relatedElementValidates[$sourceId][$element->siteId] ?? null; - self::$_relatedElementValidates[$sourceId][$element->siteId] = true; + // No recursive related element validation + if (self::$validatingRelatedElements) { + return; + } /** @var ElementQueryInterface|Collection $value */ $value = $element->getFieldValue($this->handle); - $errorCount = 0; - foreach ($value->all() as $i => $related) { - /** @var Element $related */ - if ($related->enabled && $related->getEnabledForSite()) { - if (!self::_validateRelatedElement($related)) { - $element->addModelErrors($related, "$this->handle[$i]"); - $errorCount++; - } - } + if ($value instanceof ElementQueryInterface) { + $value + ->site('*') + ->unique() + ->preferSites([$this->targetSiteId($element)]); } - // Reset self::$_relatedElementValidates[$sourceId][$element->siteId] to its original value - if ($sourceValidates !== null) { - self::$_relatedElementValidates[$sourceId][$element->siteId] = $sourceValidates; - } else { - unset(self::$_relatedElementValidates[$sourceId][$element->siteId]); + $errorCount = 0; + + foreach ($value->all() as $i => $target) { + if (!self::_validateRelatedElement($element, $target)) { + $element->addModelErrors($target, "$this->handle[$i]"); + $errorCount++; + } } if ($errorCount) { @@ -497,7 +488,7 @@ public function validateRelatedElements(ElementInterface $element): void $elementType = static::elementType(); $element->addError($this->handle, Craft::t('app', 'Validation errors found in {attribute} {type}; please fix them.', [ 'type' => $errorCount === 1 ? $elementType::lowerDisplayName() : $elementType::pluralLowerDisplayName(), - 'attribute' => $this->getAttributeLabel($this->handle), + 'attribute' => Craft::t('site', $this->name), ])); } } @@ -505,30 +496,29 @@ public function validateRelatedElements(ElementInterface $element): void /** * Returns whether a related element validates. * - * @param ElementInterface $element + * @param ElementInterface $source + * @param ElementInterface $target * @return bool */ - private static function _validateRelatedElement(ElementInterface $element): bool + private static function _validateRelatedElement(ElementInterface $source, ElementInterface $target): bool { - if (isset(self::$_relatedElementValidates[$element->id][$element->siteId])) { - return self::$_relatedElementValidates[$element->id][$element->siteId]; + if ( + self::$validatingRelatedElements || + !$target->enabled || + !$target->getEnabledForSite() || + $target->getCanonicalId() === $source->getCanonicalId() + ) { + return true; } - // If this is the first time we are validating a related element, - // listen for future element saves so we can clear our cache - if (!self::$_listeningForRelatedElementSave) { - Event::on(Elements::class, Elements::EVENT_AFTER_SAVE_ELEMENT, function(ElementEvent $e) { - $element = $e->element; - unset(self::$_relatedElementValidates[$element->id][$element->siteId]); - }); - self::$_listeningForRelatedElementSave = true; - } + // Prevent relational fields on this element from enforcing related element validation + self::$validatingRelatedElements = true; - // Prevent an infinite loop if there are circular relations - self::$_relatedElementValidates[$element->id][$element->siteId] = true; + $target->setScenario(Element::SCENARIO_LIVE); + $validates = $target->validate(); - $element->setScenario(Element::SCENARIO_LIVE); - return self::$_relatedElementValidates[$element->id][$element->siteId] = $element->validate(); + self::$validatingRelatedElements = false; + return $validates; } /** @@ -549,7 +539,7 @@ public function isValueEmpty(mixed $value, ElementInterface $element): bool */ public function normalizeValue(mixed $value, ?ElementInterface $element = null): mixed { - if ($value instanceof ElementQueryInterface) { + if ($value instanceof ElementQueryInterface || $value instanceof Collection) { return $value; } @@ -612,7 +602,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): $structuresService->applyBranchLimitToElements($structureElements, $this->branchLimit); } - $query->id(ArrayHelper::getColumn($structureElements, 'id')); + $query->id(array_map(fn(ElementInterface $element) => $element->id, $structureElements)); } } else { $query->id(false); @@ -812,7 +802,7 @@ public function getStaticHtml(mixed $value, ElementInterface $element): string $view = Craft::$app->getView(); $id = $this->getInputId(); - $html = "
" . + $html = "
" . "
"; foreach ($value as $relatedElement) { @@ -856,7 +846,7 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false $sourceSiteId = $sourceElements[0]->siteId; // Get the source element IDs - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id', false); + $sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements); // Return any relation data on these elements, defined with this field $map = (new Query()) @@ -940,7 +930,7 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void { // Skip if nothing changed, or the element is just propagating and we're not localizing relations if ( - ($element->isFieldDirty($this->handle) || $this->maintainHierarchy) && + ($element->duplicateOf || $element->isFieldDirty($this->handle) || $this->maintainHierarchy) && (!$element->propagating || $this->localizeRelations) ) { /** @var ElementQueryInterface|Collection $value */ @@ -983,7 +973,7 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void $structuresService->applyBranchLimitToElements($structureElements, $this->branchLimit); } - $targetIds = ArrayHelper::getColumn($structureElements, 'id'); + $targetIds = array_map(fn(ElementInterface $element) => $element->id, $structureElements); } /** @var int|int[]|false|null $targetIds */ @@ -997,7 +987,10 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void if (!$this->localizeRelations && ElementHelper::shouldTrackChanges($element)) { // Mark the field as dirty across all of the element’s sites // (this is a little hacky but there’s not really a non-hacky alternative unfortunately.) - $siteIds = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($element), 'siteId'); + $siteIds = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($element), + ); $siteIds = ArrayHelper::withoutValue($siteIds, $element->siteId); if (!empty($siteIds)) { $userId = Craft::$app->getUser()->getId(); @@ -1186,13 +1179,10 @@ protected function inputTemplateVariables(array|ElementQueryInterface $value = n $value = []; } - if ($this->validateRelatedElements) { + if ($this->validateRelatedElements && $element !== null) { // Pre-validate related elements - foreach ($value as $related) { - if ($related->enabled && $related->getEnabledForSite()) { - $related->setScenario(Element::SCENARIO_LIVE); - $related->validate(); - } + foreach ($value as $target) { + self::_validateRelatedElement($element, $target); } } @@ -1294,7 +1284,12 @@ public function getInputSelectionCriteria(): array public function getSelectionCondition(): ?ElementConditionInterface { if ($this->_selectionCondition !== null && !$this->_selectionCondition instanceof ConditionInterface) { - $this->_selectionCondition = Craft::$app->getConditions()->createCondition($this->_selectionCondition); + $condition = Craft::$app->getConditions()->createCondition($this->_selectionCondition); + if (!empty($condition->getConditionRules())) { + $this->_selectionCondition = $condition; + } else { + $this->_selectionCondition = null; + } } return $this->_selectionCondition; diff --git a/src/fields/Categories.php b/src/fields/Categories.php index 42471b86d6f..e4ea25b961f 100644 --- a/src/fields/Categories.php +++ b/src/fields/Categories.php @@ -15,7 +15,6 @@ use craft\gql\arguments\elements\Category as CategoryArguments; use craft\gql\interfaces\elements\Category as CategoryInterface; use craft\gql\resolvers\elements\Category as CategoryResolver; -use craft\helpers\ArrayHelper; use craft\helpers\ElementHelper; use craft\helpers\Gql; use craft\helpers\Gql as GqlHelper; @@ -107,7 +106,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): $structuresService->applyBranchLimitToElements($categories, $this->branchLimit); } - $value = ArrayHelper::getColumn($categories, 'id'); + $value = array_map(fn(Category $category) => $category->id, $categories); } return parent::normalizeValue($value, $element); diff --git a/src/fields/Color.php b/src/fields/Color.php index 132b8fd8936..0d67c626b6b 100644 --- a/src/fields/Color.php +++ b/src/fields/Color.php @@ -143,7 +143,7 @@ public function getStaticHtml(mixed $value, ElementInterface $element): string } return Html::encodeParams( - '
{bgColor}
', + '
{bgColor}
', [ 'bgColor' => $value->getHex(), ]); diff --git a/src/fields/Country.php b/src/fields/Country.php new file mode 100644 index 00000000000..6a10ddd3be0 --- /dev/null +++ b/src/fields/Country.php @@ -0,0 +1,84 @@ + + * @since 4.6.0 + */ +class Country extends Field implements PreviewableFieldInterface +{ + /** + * @inheritdoc + */ + public static function displayName(): string + { + return Craft::t('app', 'Country'); + } + + /** + * @inheritdoc + */ + public static function valueType(): string + { + return 'string|null'; + } + + /** + * @inheritdoc + */ + public function normalizeValue(mixed $value, ElementInterface $element = null): mixed + { + return !in_array($value, ['', '__BLANK__']) ? $value : null; + } + + /** + * @inheritdoc + */ + protected function inputHtml(mixed $value, ?ElementInterface $element = null): string + { + $options = Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language); + array_unshift($options, ['label' => '', 'value' => '__BLANK__']); + + return Cp::selectizeHtml([ + 'id' => $this->getInputId(), + 'name' => $this->handle, + 'options' => $options, + 'value' => $value, + ]); + } + + /** + * @inheritdoc + */ + public function getElementConditionRuleType(): array|string|null + { + return CountryFieldConditionRule::class; + } + + /** + * @inheritdoc + */ + public function getPreviewHtml(mixed $value, ElementInterface $element): string + { + if (!$value) { + return ''; + } + $list = Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language); + return $list[$value] ?? $value; + } +} diff --git a/src/fields/Date.php b/src/fields/Date.php index 4ba20d0920f..30ce31429e4 100644 --- a/src/fields/Date.php +++ b/src/fields/Date.php @@ -404,9 +404,16 @@ public function serializeValue(mixed $value, ?ElementInterface $element = null): return Db::prepareDateForDb($value); } + // Only store the time zone if it was created in the IANA format + if ($value->getTimezone()->getLocation()) { + $timeZone = $value->getTimezone()->getName(); + } else { + $timeZone = null; + } + return [ 'date' => Db::prepareDateForDb($value), - 'tz' => $value->getTimezone()->getName(), + 'tz' => $timeZone, ]; } @@ -458,9 +465,12 @@ public function getContentGqlType(): Type|array */ public function getContentGqlMutationArgumentType(): Type|array { + $type = DateTimeType::getType(); + $type->setToSystemTimeZone = !$this->showTimeZone; + return [ 'name' => $this->handle, - 'type' => DateTimeType::getType(), + 'type' => $type, 'description' => $this->instructions, ]; } diff --git a/src/fields/Dropdown.php b/src/fields/Dropdown.php index ffdb55ab1b6..dbffa36d571 100644 --- a/src/fields/Dropdown.php +++ b/src/fields/Dropdown.php @@ -12,7 +12,6 @@ use craft\base\ElementInterface; use craft\base\SortableFieldInterface; use craft\fields\data\SingleOptionFieldData; -use craft\helpers\ArrayHelper; use craft\helpers\Cp; /** @@ -65,16 +64,35 @@ public function getStatus(ElementInterface $element): ?array * @inheritdoc */ protected function inputHtml(mixed $value, ?ElementInterface $element = null): string + { + return $this->inputHtmlInternal($value, $element, false); + } + + /** + * @inheritdoc + */ + public function getStaticHtml(mixed $value, ?ElementInterface $element = null): string + { + return $this->inputHtmlInternal($value, $element, true); + } + + private function inputHtmlInternal(mixed $value, ?ElementInterface $element, bool $static): string { /** @var SingleOptionFieldData $value */ $options = $this->translatedOptions(true, $value, $element); - $hasBlankOption = ArrayHelper::contains($options, function($option) { - return isset($option['value']) && $option['value'] === ''; - }); + $hasBlankOption = false; + foreach ($options as &$option) { + if (isset($option['value']) && $option['value'] === '') { + $option['value'] = '__BLANK__'; + $hasBlankOption = true; + } + } if (!$value->valid) { - Craft::$app->getView()->setInitialDeltaValue($this->handle, $this->encodeValue($value->value)); + if (!$static) { + Craft::$app->getView()->setInitialDeltaValue($this->handle, $this->encodeValue($value->value)); + } $default = $this->defaultValue(); if ($default !== null) { @@ -84,20 +102,23 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s // Add a blank option to the beginning if one doesn't already exist if (!$hasBlankOption) { - array_unshift($options, ['label' => '', 'value' => '']); + array_unshift($options, ['label' => '', 'value' => '__BLANK__']); } } } + $encValue = $this->encodeValue($value); + if ($encValue === null || $encValue === '') { + $encValue = '__BLANK__'; + } + return Cp::selectizeHtml([ 'id' => $this->getInputId(), 'describedBy' => $this->describedBy, 'name' => $this->handle, - 'value' => $this->encodeValue($value), + 'value' => $encValue, 'options' => $options, - 'selectizeOptions' => [ - 'allowEmptyOption' => $hasBlankOption, - ], + 'disabled' => $static, ]); } @@ -108,4 +129,23 @@ protected function optionsSettingLabel(): string { return Craft::t('app', 'Dropdown Options'); } + + /** + * @inheritdoc + */ + protected function isOptionSelected(array $option, mixed $value, array &$selectedValues, bool &$selectedBlankOption): bool + { + // special case for blank options, when $value is null + if ($value === null && $option['value'] === '') { + if (!$selectedBlankOption) { + $selectedValues[] = ''; + $selectedBlankOption = true; + return true; + } + + return false; + } + + return in_array($option['value'], $selectedValues, true); + } } diff --git a/src/fields/Lightswitch.php b/src/fields/Lightswitch.php index 300437b30bc..80e23aaeac8 100644 --- a/src/fields/Lightswitch.php +++ b/src/fields/Lightswitch.php @@ -125,6 +125,27 @@ public function getSettingsHtml(): ?string * @inheritdoc */ protected function inputHtml(mixed $value, ?ElementInterface $element = null): string + { + return $this->_inputHtmlInternal($value, $element, false); + } + + /** + * @inheritdoc + */ + public function getStaticHtml(mixed $value, ?ElementInterface $element = null): string + { + return $this->_inputHtmlInternal($value, $element, true); + } + + /** + * Render html for both static and interactive lightswitch field + * + * @param mixed $value + * @param ElementInterface|null $element + * @param bool $static + * @return string + */ + private function _inputHtmlInternal(mixed $value, ?ElementInterface $element, bool $static): string { $id = $this->getInputId(); return Craft::$app->getView()->renderTemplate('_includes/forms/lightswitch.twig', [ @@ -135,6 +156,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s 'on' => (bool)$value, 'onLabel' => Craft::t('site', $this->onLabel), 'offLabel' => Craft::t('site', $this->offLabel), + 'disabled' => $static, ]); } diff --git a/src/fields/Matrix.php b/src/fields/Matrix.php index 1f3cf238d22..4403b6bc66e 100644 --- a/src/fields/Matrix.php +++ b/src/fields/Matrix.php @@ -575,7 +575,7 @@ public function serializeValue(mixed $value, ?ElementInterface $element = null): 'type' => $block->getType()->handle, 'enabled' => $block->enabled, 'collapsed' => $block->collapsed, - 'fields' => fn() => $block->getSerializedFieldValues(), + 'fields' => $block->getSerializedFieldValues(), ]; } @@ -1145,6 +1145,27 @@ public function beforeElementDelete(ElementInterface $element): bool return true; } + /** + * @inheritdoc + */ + public function beforeElementDeleteForSite(ElementInterface $element): bool + { + $elementsService = Craft::$app->getElements(); + + /** @var MatrixBlock[] $matrixBlocks */ + $matrixBlocks = MatrixBlock::find() + ->primaryOwnerId($element->id) + ->status(null) + ->siteId($element->siteId) + ->all(); + + foreach ($matrixBlocks as $matrixBlock) { + $elementsService->deleteElementForSite($matrixBlock); + } + + return true; + } + /** * @inheritdoc */ diff --git a/src/fields/Money.php b/src/fields/Money.php index 1da3291fdd1..0c04003455d 100644 --- a/src/fields/Money.php +++ b/src/fields/Money.php @@ -177,15 +177,14 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): } if (is_array($value)) { - // Was this submitted with a locale ID? - $value['locale'] = $value['locale'] ?? Craft::$app->getFormattingLocale()->id; - $value['value'] = $value['value'] !== '' ? $value['value'] ?? null : null; - - if ($value['value'] === null) { + if (!isset($value['value']) || $value['value'] === '') { return null; } - $value['currency'] = $this->currency; + $value += [ + 'locale' => Craft::$app->getFormattingLocale()->id, + 'currency' => $this->currency, + ]; return MoneyHelper::toMoney($value); } diff --git a/src/fields/MultiSelect.php b/src/fields/MultiSelect.php index 9673cc44364..ff38c585bd6 100644 --- a/src/fields/MultiSelect.php +++ b/src/fields/MultiSelect.php @@ -68,6 +68,23 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s ]); } + /** + * @inheritdoc + */ + public function getStaticHtml(mixed $value, ?ElementInterface $element = null): string + { + return Cp::selectizeHtml([ + 'id' => $this->getInputId(), + 'describedBy' => $this->describedBy, + 'class' => 'selectize', + 'name' => $this->handle, + 'values' => $this->encodeValue($value), + 'options' => $this->translatedOptions(true, $value, $element), + 'multi' => true, + 'disabled' => true, + ]); + } + /** * @inheritdoc */ diff --git a/src/fields/PlainText.php b/src/fields/PlainText.php index 6e76b968cf0..b43ffd56ae6 100644 --- a/src/fields/PlainText.php +++ b/src/fields/PlainText.php @@ -186,11 +186,25 @@ public function getContentColumnType(): string $bytes = $this->byteLimit; } elseif ($this->charLimit) { $bytes = $this->charLimit * 4; - } else { - return Schema::TYPE_TEXT; } - return Schema::TYPE_STRING . "($bytes)"; + if (Craft::$app->getDb()->getIsPgsql()) { + if (isset($bytes)) { + return Schema::TYPE_STRING . "($bytes)"; + } else { + return Schema::TYPE_TEXT; + } + } else { + if (!isset($bytes)) { + return Schema::TYPE_TEXT; + } + + if ($bytes <= 1020) { + return sprintf('%s(%s)', Schema::TYPE_STRING, $bytes); + } + + return Db::getTextualColumnTypeByContentLength($bytes); + } } /** diff --git a/src/fields/Table.php b/src/fields/Table.php index 206ea0b7d48..414e3751534 100644 --- a/src/fields/Table.php +++ b/src/fields/Table.php @@ -101,7 +101,7 @@ public static function valueType(): string */ public function __construct($config = []) { - // Config normalization} + // Config normalization if (array_key_exists('columns', $config)) { if (!is_array($config['columns'])) { unset($config['columns']); @@ -440,6 +440,15 @@ private function _normalizeValueInternal(mixed $value, ?ElementInterface $elemen $defaults = $this->defaults ?? []; + // Apply static translations + foreach ($defaults as &$row) { + foreach ($this->columns as $colId => $col) { + if ($col['type'] === 'heading' && isset($row[$colId])) { + $row[$colId] = Craft::t('site', $row[$colId]); + } + } + } + if (is_string($value) && !empty($value)) { $value = Json::decodeIfJson($value); } elseif ($value === null && $this->isFresh($element)) { @@ -676,11 +685,16 @@ private function _getInputHtml(mixed $value, ?ElementInterface $element, bool $s return ''; } - // Translate the column headings + // Translate the column headings and dropdown option labels foreach ($this->columns as &$column) { if (!empty($column['heading'])) { $column['heading'] = Craft::t('site', $column['heading']); } + if (!empty($column['options'])) { + array_walk($column['options'], function(&$option) { + $option['label'] = Craft::t('site', $option['label']); + }); + } } unset($column); diff --git a/src/fields/conditions/CountryFieldConditionRule.php b/src/fields/conditions/CountryFieldConditionRule.php new file mode 100644 index 00000000000..b0e55454605 --- /dev/null +++ b/src/fields/conditions/CountryFieldConditionRule.php @@ -0,0 +1,38 @@ + + * @since 4.6.0 + */ +class CountryFieldConditionRule extends BaseMultiSelectConditionRule implements FieldConditionRuleInterface +{ + use FieldConditionRuleTrait; + + protected function options(): array + { + return Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language); + } + + /** + * @inheritdoc + */ + protected function elementQueryParam(): ?array + { + return $this->paramValue(); + } + + /** + * @inheritdoc + */ + protected function matchFieldValue($value): bool + { + return $this->matchValue($value); + } +} diff --git a/src/fields/conditions/FieldConditionRuleTrait.php b/src/fields/conditions/FieldConditionRuleTrait.php index cfdcc54f5dc..857858a323f 100644 --- a/src/fields/conditions/FieldConditionRuleTrait.php +++ b/src/fields/conditions/FieldConditionRuleTrait.php @@ -92,6 +92,16 @@ public function getLabel(): string return $this->field()->name; } + /** + * @inheritdoc + */ + public function getLabelHint(): ?string + { + static $showHandles = null; + $showHandles ??= Craft::$app->getUser()->getIdentity()?->getPreference('showFieldHandles') ?? false; + return $showHandles ? $this->field()->handle : null; + } + /** * @inheritdoc */ diff --git a/src/fields/conditions/OptionsFieldConditionRule.php b/src/fields/conditions/OptionsFieldConditionRule.php index d3febd94896..32cd3e4b960 100644 --- a/src/fields/conditions/OptionsFieldConditionRule.php +++ b/src/fields/conditions/OptionsFieldConditionRule.php @@ -24,7 +24,7 @@ protected function options(): array /** @var BaseOptionsField $field */ $field = $this->field(); return Collection::make($field->options) - ->filter(fn(array $option) => !empty($option['value']) && !empty($option['label'])) + ->filter(fn(array $option) => $option['value'] !== null && $option['value'] !== '' && !empty($option['label'])) ->map(fn(array $option) => [ 'value' => $option['value'], 'label' => $option['label'], diff --git a/src/fs/Local.php b/src/fs/Local.php index 68c69e0e55c..42b2f254b5f 100644 --- a/src/fs/Local.php +++ b/src/fs/Local.php @@ -100,6 +100,16 @@ public function init(): void } } + /** + * @inheritdoc + */ + public function attributeLabels(): array + { + return array_merge(parent::attributeLabels(), [ + 'path' => Craft::t('app', 'Base Path'), + ]); + } + /** * @inheritdoc */ @@ -128,7 +138,11 @@ public function validatePath(string $attribute, ?array $params, InlineValidator foreach ($systemDirs as $dir) { $dir = FileHelper::absolutePath($dir, '/'); if (str_starts_with("$path/", "$dir/")) { - $validator->addError($this, $attribute, Craft::t('app', 'Local volumes cannot be located within system directories.')); + $validator->addError($this, $attribute, Craft::t('app', 'Local filesystems cannot be located within system directories.')); + break; + } + if (str_starts_with("$dir/", "$path/")) { + $validator->addError($this, $attribute, Craft::t('app', 'Local filesystems cannot be located above system directories.')); break; } } diff --git a/src/gql/arguments/mutations/Asset.php b/src/gql/arguments/mutations/Asset.php index 7c6d20740ec..737cd22cdc5 100644 --- a/src/gql/arguments/mutations/Asset.php +++ b/src/gql/arguments/mutations/Asset.php @@ -40,6 +40,11 @@ public static function getArguments(): array 'description' => 'The ID of the user who first added this asset (if known).', 'type' => Type::id(), ], + 'focalPoint' => [ + 'name' => 'focalPoint', + 'description' => 'The image focal point, in the format of "0.5;0.5".', + 'type' => Type::string(), + ], ]); } } diff --git a/src/gql/base/ElementMutationResolver.php b/src/gql/base/ElementMutationResolver.php index 602f2ac053a..ec604aff811 100644 --- a/src/gql/base/ElementMutationResolver.php +++ b/src/gql/base/ElementMutationResolver.php @@ -18,6 +18,7 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\WrappingType; +use yii\web\ServerErrorHttpException; /** * Class MutationResolver @@ -160,7 +161,22 @@ protected function saveElement(ElementInterface $element): ElementInterface $element->setScenario(Element::SCENARIO_LIVE); } - Craft::$app->getElements()->saveElement($element); + $isNotNew = $element->id; + if ($isNotNew) { + $lockKey = "element:$element->id"; + $mutex = Craft::$app->getMutex(); + if (!$mutex->acquire($lockKey, 15)) { + throw new ServerErrorHttpException('Could not acquire a lock to save the element.'); + } + } + + try { + Craft::$app->getElements()->saveElement($element); + } finally { + if ($isNotNew) { + $mutex->release($lockKey); + } + } if ($element->hasErrors()) { $validationErrors = []; diff --git a/src/gql/directives/StripTags.php b/src/gql/directives/StripTags.php new file mode 100644 index 00000000000..5ba063868b1 --- /dev/null +++ b/src/gql/directives/StripTags.php @@ -0,0 +1,65 @@ + + * @since 4.5.4 + */ +class StripTags extends Directive +{ + /** + * @inheritdoc + */ + public static function create(): GqlDirective + { + $typeName = static::name(); + + return GqlEntityRegistry::getOrCreate($typeName, fn() => new self([ + 'name' => $typeName, + 'locations' => [ + DirectiveLocation::FIELD, + ], + 'description' => 'Strips HTML tags from the field value.', + 'args' => [ + new FieldArgument([ + 'name' => 'allowed', + 'type' => Type::listOf(Type::string()), + 'defaultValue' => [], + 'description' => 'List of allowed tag names.', + ]), + ], + ])); + } + + /** + * @inheritdoc + */ + public static function name(): string + { + return 'stripTags'; + } + + /** + * @inheritdoc + */ + public static function apply(mixed $source, mixed $value, array $arguments, ResolveInfo $resolveInfo): mixed + { + return strip_tags((string)$value, $arguments['allowed'] ?? null); + } +} diff --git a/src/gql/directives/Transform.php b/src/gql/directives/Transform.php index 3ce2aa2eed3..1084e1a8c40 100644 --- a/src/gql/directives/Transform.php +++ b/src/gql/directives/Transform.php @@ -84,6 +84,16 @@ public static function apply(mixed $source, mixed $value, array $arguments, Reso } } } elseif ($source instanceof Asset) { + $generalConfig = Craft::$app->getConfig()->getGeneral(); + $allowTransform = match ($source->getMimeType()) { + 'image/gif' => $generalConfig->transformGifs, + 'image/svg+xml' => $generalConfig->transformSvgs, + default => true, + }; + if (!$allowTransform) { + $transform = null; + } + switch ($resolveInfo->fieldName) { case 'format': return $source->getFormat($transform); @@ -92,7 +102,7 @@ public static function apply(mixed $source, mixed $value, array $arguments, Reso case 'mimeType': return $source->getMimeType($transform); case 'url': - $generateNow = $arguments['immediately'] ?? Craft::$app->getConfig()->getGeneral()->generateTransformsBeforePageLoad; + $generateNow = $arguments['immediately'] ?? $generalConfig->generateTransformsBeforePageLoad; return $source->getUrl($transform, $generateNow); case 'width': return $source->getWidth($transform); diff --git a/src/gql/directives/Trim.php b/src/gql/directives/Trim.php new file mode 100644 index 00000000000..913ad450629 --- /dev/null +++ b/src/gql/directives/Trim.php @@ -0,0 +1,56 @@ + + * @since 4.5.4 + */ +class Trim extends Directive +{ + /** + * @inheritdoc + */ + public static function create(): GqlDirective + { + $typeName = static::name(); + + return GqlEntityRegistry::getOrCreate($typeName, fn() => new self([ + 'name' => $typeName, + 'locations' => [ + DirectiveLocation::FIELD, + ], + 'description' => 'Trims leading and trailing whitespace from the field value.', + 'args' => [], + ])); + } + + /** + * @inheritdoc + */ + public static function name(): string + { + return 'trim'; + } + + /** + * @inheritdoc + */ + public static function apply(mixed $source, mixed $value, array $arguments, ResolveInfo $resolveInfo): mixed + { + return trim((string)$value); + } +} diff --git a/src/gql/interfaces/elements/Address.php b/src/gql/interfaces/elements/Address.php index 7aa33fd1570..ee48da5514a 100644 --- a/src/gql/interfaces/elements/Address.php +++ b/src/gql/interfaces/elements/Address.php @@ -11,7 +11,6 @@ use craft\gql\GqlEntityRegistry; use craft\gql\interfaces\Element; use craft\gql\types\generators\AddressType; -use craft\helpers\Gql; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\Type; @@ -65,7 +64,7 @@ public static function getName(): string */ public static function getFieldDefinitions(): array { - return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), self::getConditionalFields(), [ + return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ 'fullName' => [ 'name' => 'fullName', 'type' => Type::string(), @@ -143,25 +142,4 @@ public static function getFieldDefinitions(): array ], ]), self::getName()); } - - /** - * @inheritdoc - */ - protected static function getConditionalFields(): array - { - $volumeUid = Craft::$app->getProjectConfig()->get('users.photoVolumeUid'); - - if (Gql::isSchemaAwareOf('volumes.' . $volumeUid)) { - return [ - 'photo' => [ - 'name' => 'photo', - 'type' => Asset::getType(), - 'description' => 'The user’s photo.', - 'complexity' => Gql::eagerLoadComplexity(), - ], - ]; - } - - return []; - } } diff --git a/src/gql/types/DateTime.php b/src/gql/types/DateTime.php index 40a46322fb5..4ef897e4a76 100644 --- a/src/gql/types/DateTime.php +++ b/src/gql/types/DateTime.php @@ -11,6 +11,8 @@ use craft\gql\base\SingularTypeInterface; use craft\gql\directives\FormatDateTime; use craft\gql\GqlEntityRegistry; +use craft\helpers\DateTimeHelper; +use craft\helpers\Json; use GraphQL\Language\AST\StringValueNode; use GraphQL\Type\Definition\ScalarType; @@ -32,6 +34,12 @@ class DateTime extends ScalarType implements SingularTypeInterface */ public $description = 'The `DateTime` scalar type represents a point in time.'; + /** + * @var bool Whether parsed dates should be set to the system time zone + * @since 4.5.11 + */ + public bool $setToSystemTimeZone = true; + /** * Returns a singleton instance to ensure one type per schema. * @@ -69,11 +77,14 @@ public function serialize($value) public function parseValue($value) { if (is_string($value)) { - return new \DateTime($value); + return DateTimeHelper::toDateTime( + Json::decodeIfJson($value), + setToSystemTimeZone: $this->setToSystemTimeZone, + ); } // This message will be lost by the wrapping exception, but it feels good to provide one. - throw new GqlException("DateTime must be a string"); + throw new GqlException('DateTime must be a string.'); } /** @@ -82,10 +93,13 @@ public function parseValue($value) public function parseLiteral($valueNode, ?array $variables = null) { if ($valueNode instanceof StringValueNode) { - return new \DateTime($valueNode->value); + return DateTimeHelper::toDateTime( + Json::decodeIfJson($valueNode->value), + setToSystemTimeZone: $this->setToSystemTimeZone, + ); } // This message will be lost by the wrapping exception, but it feels good to provide one. - throw new GqlException("DateTime must be a string"); + throw new GqlException('DateTime must be a string.'); } } diff --git a/src/helpers/AdminTable.php b/src/helpers/AdminTable.php index 1133869706f..16e861e5d8d 100644 --- a/src/helpers/AdminTable.php +++ b/src/helpers/AdminTable.php @@ -7,6 +7,11 @@ namespace craft\helpers; +use Craft; +use craft\db\Query; +use Exception; +use yii\db\Expression; + /** * Admin Table helper * @@ -40,4 +45,75 @@ public static function paginationLinks(int $page, int $total, int $limit): array 'to' => (int)$to, ]; } + + /** + * @param string $table + * @param int $id + * @param int $page + * @param int $perPage + * @param string $sortColumn + * @param array $criteria + * @return bool + * @since 4.6.0 + */ + public static function moveToPage(string $table, int $id, int $page, int $perPage, string $sortColumn = 'sortOrder', array $criteria = []): bool + { + $lastPage = ceil((new Query()) + ->from([$table]) + ->count() / $perPage); + + if ($page > $lastPage || $page < 1) { + return false; + } + + $criteria += [ + 'select' => [$sortColumn], + 'from' => [$table], + 'where' => ['id' => $id], + ]; + + $currentSortOrderQuery = new Query(); + $currentSortOrderQuery = Craft::configure($currentSortOrderQuery, $criteria); + + $currentSortOrder = $currentSortOrderQuery->scalar(); + + $newSortOrder = ($page - 1) * $perPage + 1; + + if ($currentSortOrder == $newSortOrder) { + return true; + } + + $isGoingUp = $newSortOrder > $currentSortOrder; + + $transaction = Craft::$app->getDb()->beginTransaction(); + + try { + if ($isGoingUp) { + Craft::$app->getDb()->createCommand() + ->update($table, + [$sortColumn => new Expression('[[' . $sortColumn . ']] - 1')], + ['and', ['>', $sortColumn, $currentSortOrder], ['<=', $sortColumn, $newSortOrder]] + ) + ->execute(); + } else { + Craft::$app->getDb()->createCommand() + ->update($table, + [$sortColumn => new Expression('[[' . $sortColumn . ']] + 1')], + ['and', ['<', $sortColumn, $currentSortOrder], ['>=', $sortColumn, $newSortOrder]] + ) + ->execute(); + } + + Craft::$app->getDb()->createCommand() + ->update($table, [$sortColumn => $newSortOrder], ['id' => $id]) + ->execute(); + + $transaction->commit(); + } catch (Exception) { + $transaction->rollBack(); + return false; + } + + return true; + } } diff --git a/src/helpers/Api.php b/src/helpers/Api.php index 49e2fd7eee6..cca24ed5de0 100644 --- a/src/helpers/Api.php +++ b/src/helpers/Api.php @@ -188,7 +188,7 @@ public static function processResponseHeaders(array $headers): void if (isset($headers['x-craft-license-info'])) { $oldLicenseInfo = $cache->get('licenseInfo') ?: []; $licenseInfo = []; - $allCombinedInfo = explode(',', reset($headers['x-craft-license-info'])); + $allCombinedInfo = array_filter(explode(',', reset($headers['x-craft-license-info']))); foreach ($allCombinedInfo as $combinedInfo) { [$handle, $combinedValues] = explode(':', $combinedInfo, 2); if ($combinedValues === LicenseKeyStatus::Invalid) { diff --git a/src/helpers/App.php b/src/helpers/App.php index ab5ffc526ee..0d17fab7104 100644 --- a/src/helpers/App.php +++ b/src/helpers/App.php @@ -34,12 +34,15 @@ use HTMLPurifier_Encoder; use ReflectionClass; use ReflectionProperty; +use Symfony\Component\Process\PhpExecutableFinder; use yii\base\Event; use yii\base\Exception; use yii\base\InvalidArgumentException; use yii\base\InvalidValueException; use yii\helpers\Inflector; use yii\mutex\FileMutex; +use yii\mutex\MysqlMutex; +use yii\mutex\PgsqlMutex; use yii\web\JsonParser; /** @@ -604,6 +607,35 @@ public static function isPathAllowed(string $path): bool return false; } + /** + * Returns the path to a PHP executable which should be used by sub processes. + * + * @return string|null The PHP executable path, or `null` if it can’t be determined. + * @since 4.5.6 + */ + public static function phpExecutable(): ?string + { + // If PHP_BINARY was set to $_SERVER, update the environment variable to match + if (isset($_SERVER['PHP_BINARY']) && $_SERVER['PHP_BINARY'] !== getenv('PHP_BINARY')) { + putenv(sprintf('PHP_BINARY=%s', $_SERVER['PHP_BINARY'])); + } + + if ( + getenv('PHP_BINARY') === false && + PHP_BINARY && + PHP_SAPI === 'cgi-fcgi' && + str_ends_with(PHP_BINARY, 'php-cgi') + ) { + // See if a `php` file exists alongside `php-cgi`, and if so, use that + $file = dirname(PHP_BINARY) . DIRECTORY_SEPARATOR . 'php'; + if (@is_executable($file) && !@is_dir($file)) { + return $file; + } + } + + return (new PhpExecutableFinder())->find() ?: null; + } + /** * Tests whether ini_set() works. * @@ -923,6 +955,32 @@ public static function mailerConfig(?MailSettings $settings = null): array ]; } + /** + * Returns a database-based mutex driver config. + * + * @return array + * @since 4.6.0 + */ + public static function dbMutexConfig(): array + { + // Use a dedicated connection, to avoid erratic behavior when locks are used during transactions + // https://makandracards.com/makandra/17437-mysql-careful-when-using-database-locks-in-transactions + $dbConfig = static::dbConfig(); + + if (Craft::$app->getDb()->getIsMysql()) { + return [ + 'class' => MysqlMutex::class, + 'db' => $dbConfig, + 'keyPrefix' => Craft::$app->id, + ]; + } + + return [ + 'class' => PgsqlMutex::class, + 'db' => $dbConfig, + ]; + } + /** * Returns a file-based mutex driver config. * @@ -934,6 +992,7 @@ public static function mailerConfig(?MailSettings $settings = null): array * * @return array * @since 3.0.18 + * @deprecated in 4.6.0 */ public static function mutexConfig(): array { diff --git a/src/helpers/ArrayHelper.php b/src/helpers/ArrayHelper.php index 9d44e6e24ce..40e8d7fcaf7 100644 --- a/src/helpers/ArrayHelper.php +++ b/src/helpers/ArrayHelper.php @@ -237,12 +237,18 @@ public static function whereMultiple(iterable $array, array $conditions, bool $s * @param callable|string $key the column name or anonymous function which must be set to $value * @param mixed $value the value that $key should be compared with * @param bool $strict whether a strict type comparison should be used when checking array element values against $value + * @param int|string|null $valueKey The key of the resulting value, or null if it can't be found * @return mixed the value, or null if it can't be found * @since 3.1.0 */ - public static function firstWhere(iterable $array, callable|string $key, mixed $value = true, bool $strict = false): mixed - { - foreach ($array as $element) { + public static function firstWhere( + iterable $array, + callable|string $key, + mixed $value = true, + bool $strict = false, + int|string|null &$valueKey = null, + ): mixed { + foreach ($array as $valueKey => $element) { $elementValue = static::getValue($element, $key); /** @noinspection TypeUnsafeComparisonInspection */ if (($strict && $elementValue === $value) || (!$strict && $elementValue == $value)) { @@ -250,6 +256,7 @@ public static function firstWhere(iterable $array, callable|string $key, mixed $ } } + $valueKey = null; return null; } diff --git a/src/helpers/Assets.php b/src/helpers/Assets.php index 99e7e69df06..75b3ab5cd31 100644 --- a/src/helpers/Assets.php +++ b/src/helpers/Assets.php @@ -180,7 +180,7 @@ public static function urlAppendix(Asset $asset, ?DateTime $dateUpdated = null): } $revParams = self::revParams($asset, $dateUpdated); - return sprintf('?%s', http_build_query($revParams)); + return sprintf('?%s', UrlHelper::buildQuery($revParams)); } /** diff --git a/src/helpers/Component.php b/src/helpers/Component.php index 454535bc0dc..d823776c7bb 100644 --- a/src/helpers/Component.php +++ b/src/helpers/Component.php @@ -140,7 +140,7 @@ public static function createComponent(string|array $config, ?string $instanceOf // Instantiate and return $config['class'] = $class; - return Craft::createObject($config); + return Craft::createObject(static::cleanseConfig($config)); } /** diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php index dc67f0163f7..4d2a0ab60c7 100644 --- a/src/helpers/Cp.php +++ b/src/helpers/Cp.php @@ -254,7 +254,11 @@ public static function alerts(?string $path = null, bool $fetch = false): array 'class' => 'errors', ]) . // can't use Html::a() because it's encoding &'s, which is causing issues - Html::tag('p', sprintf('%s', $cartUrl, Craft::t('app', 'Resolve now'))), + Html::beginTag('p', [ + 'class' => ['flex', 'flex-nowrap', 'resolvable-alert-buttons'], + ]) . + sprintf('%s', $cartUrl, Craft::t('app', 'Resolve now')) . + Html::endTag('p'), 'showIcon' => false, ]; } @@ -632,7 +636,7 @@ public static function elementPreviewHtml( $otherHtml .= static::elementHtml($other, 'index', $size, null, $showStatus, $showThumb, $showLabel, $showDraftName); } $html .= Html::tag('span', '+' . Craft::$app->getFormatter()->asInteger(count($elements)), [ - 'title' => implode(', ', ArrayHelper::getColumn($elements, 'title')), + 'title' => implode(', ', array_map(fn(ElementInterface $element) => $element->id, $elements)), 'class' => 'btn small', 'role' => 'button', 'onclick' => sprintf( @@ -721,7 +725,7 @@ public static function fieldHtml(string $input, array $config = []): string $errors ? 'has-errors' : null, ]), Html::explodeClass($config['fieldClass'] ?? [])); - if (isset($config['attribute']) && ($currentUser = Craft::$app->getUser()->getIdentity())) { + if (($config['showAttribute'] ?? false) && ($currentUser = Craft::$app->getUser()->getIdentity())) { $showAttribute = $currentUser->admin && $currentUser->getPreference('showFieldHandles'); } else { $showAttribute = false; @@ -775,7 +779,6 @@ public static function fieldHtml(string $input, array $config = []): string 'data' => [ 'attribute' => $attribute, ], - 'tabindex' => -1, ], $config['fieldAttributes'] ?? [] )) . @@ -1411,6 +1414,7 @@ public static function addressFieldsHtml(Address $address): string $address->setScenario(Element::SCENARIO_LIVE); $activeValidators = $address->getActiveValidators(); $address->setScenario($scenario); + $belongsToCurrentUser = $address->getBelongsToCurrentUser(); foreach ($activeValidators as $validator) { if ($validator instanceof RequiredValidator) { @@ -1435,9 +1439,9 @@ public static function addressFieldsHtml(Address $address): string 'id' => 'addressLine1', 'name' => 'addressLine1', 'value' => $address->addressLine1, + 'autocomplete' => $belongsToCurrentUser ? 'address-line1' : 'off', 'required' => isset($requiredFields['addressLine1']), 'errors' => $address->getErrors('addressLine1'), - 'autocomplete' => 'address-line1', ]) . static::textFieldHtml([ 'status' => $address->getAttributeStatus('addressLine2'), @@ -1445,13 +1449,14 @@ public static function addressFieldsHtml(Address $address): string 'id' => 'addressLine2', 'name' => 'addressLine2', 'value' => $address->addressLine2, + 'autocomplete' => $belongsToCurrentUser ? 'address-line2' : 'off', 'required' => isset($requiredFields['addressLine2']), 'errors' => $address->getErrors('addressLine2'), - 'autocomplete' => 'address-line2', ]) . self::_subdivisionField( $address, 'administrativeArea', + $belongsToCurrentUser ? 'address-level1' : 'off', isset($visibleFields['administrativeArea']), isset($requiredFields['administrativeArea']), [$address->countryCode], @@ -1460,6 +1465,7 @@ public static function addressFieldsHtml(Address $address): string self::_subdivisionField( $address, 'locality', + $belongsToCurrentUser ? 'address-level2' : 'off', isset($visibleFields['locality']), isset($requiredFields['locality']), [$address->countryCode, $address->administrativeArea], @@ -1468,6 +1474,7 @@ public static function addressFieldsHtml(Address $address): string self::_subdivisionField( $address, 'dependentLocality', + $belongsToCurrentUser ? 'address-level3' : 'off', isset($visibleFields['dependentLocality']), isset($requiredFields['dependentLocality']), [$address->countryCode, $address->administrativeArea, $address->locality], @@ -1483,9 +1490,9 @@ public static function addressFieldsHtml(Address $address): string 'id' => 'postalCode', 'name' => 'postalCode', 'value' => $address->postalCode, + 'autocomplete' => $belongsToCurrentUser ? 'postal-code' : 'off', 'required' => isset($requiredFields['postalCode']), 'errors' => $address->getErrors('postalCode'), - 'autocomplete' => 'postal-code', ]) . static::textFieldHtml([ 'fieldClass' => array_filter([ @@ -1505,6 +1512,7 @@ public static function addressFieldsHtml(Address $address): string private static function _subdivisionField( Address $address, string $name, + string $autocomplete, bool $visible, bool $required, ?array $parents, @@ -1531,6 +1539,7 @@ private static function _subdivisionField( 'value' => $value, 'options' => $options, 'errors' => $errors, + 'autocomplete' => $autocomplete, ]) . Html::tag('div', '', [ 'id' => "$name-spinner", @@ -1557,6 +1566,7 @@ private static function _subdivisionField( 'options' => $options, 'required' => $required, 'errors' => $address->getErrors($name), + 'autocomplete' => $autocomplete, ]); } @@ -1565,6 +1575,7 @@ private static function _subdivisionField( 'fieldClass' => !$visible ? 'hidden' : null, 'status' => $address->getAttributeStatus($name), 'label' => $address->getAttributeLabel($name), + 'autocomplete' => $autocomplete, 'id' => $name, 'name' => $name, 'value' => $value, diff --git a/src/helpers/DateTimeHelper.php b/src/helpers/DateTimeHelper.php index 449a526f923..a4ad2837f72 100644 --- a/src/helpers/DateTimeHelper.php +++ b/src/helpers/DateTimeHelper.php @@ -12,6 +12,7 @@ use DateInterval; use DateTime; use DateTimeImmutable; +use DateTimeInterface; use DateTimeZone; use Exception; use Throwable; @@ -73,13 +74,14 @@ class DateTimeHelper * - Unix timestamps * - `now`/`today`/`tomorrow`/`yesterday` (midnight of the specified relative date) * - An array with at least one of these keys defined: `datetime`, `date`, or `time`. Supported keys include: - * - `date` – a date string in `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS.MU` formats or the current locale’s short date format - * - `time` – a time string in `HH:MM` or `HH:MM:SS` (24-hour) format or the current locale’s short time format + * - `date` – A date string in `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS.MU` formats or the current locale’s short date format. + * - `time` – A time string in `HH:MM` or `HH:MM:SS` (24-hour) format or the current locale’s short time format. * - `datetime` – A timestamp in any of the non-array formats supported by this method + * - `locale` – The locale ID that the date and time were formatted in. Defaults to the app’s current formatting locale. * - `timezone` – A [valid PHP timezone](https://php.net/manual/en/timezones.php). If set, this will override * the assumed timezone per `$assumeSystemTimeZone`. * - * @param string|int|array|DateTime|null $value The value that should be converted to a DateTime object. + * @param string|int|array|DateTimeInterface|null $value The value that should be converted to a DateTime object. * @param bool $assumeSystemTimeZone Whether it should be assumed that the value was set in the system timezone if * the timezone was not specified. If this is `false`, UTC will be assumed. * @param bool $setToSystemTimeZone Whether to set the resulting DateTime object to the system timezone. @@ -92,6 +94,10 @@ public static function toDateTime(mixed $value, bool $assumeSystemTimeZone = fal return $value; } + if ($value instanceof DateTimeImmutable) { + return DateTime::createFromImmutable($value); + } + if (!$value) { return false; } @@ -103,6 +109,12 @@ public static function toDateTime(mixed $value, bool $assumeSystemTimeZone = fal return false; } + // Did they specify a locale? + $locale = Craft::$app->getFormattingLocale(); + if (!empty($value['locale']) && $value['locale'] !== $locale->id) { + $locale = Craft::$app->getI18n()->getLocaleById($value['locale']); + } + // Did they specify a timezone? if (!empty($value['timezone']) && ($normalizedTimeZone = static::normalizeTimeZone($value['timezone'])) !== false) { $timeZone = $normalizedTimeZone; @@ -119,7 +131,7 @@ public static function toDateTime(mixed $value, bool $assumeSystemTimeZone = fal } else { // Did they specify a date? if (!empty($value['date'])) { - [$date, $format] = self::_parseDate($value['date']); + [$date, $format] = self::_parseDate($value['date'], $locale); } else { // Default to the current date $format = 'Y-m-d'; @@ -128,7 +140,7 @@ public static function toDateTime(mixed $value, bool $assumeSystemTimeZone = fal // Did they specify a time? if (!empty($value['time'])) { - [$time, $timeFormat] = self::_parseTime($value['time']); + [$time, $timeFormat] = self::_parseTime($value['time'], $locale); $format .= ' ' . $timeFormat; $date .= ' ' . $time; } @@ -449,7 +461,7 @@ public static function thisYear(?DateTimeZone $timeZone = null): DateTime */ public static function nextYear(?DateTimeZone $timeZone = null): DateTime { - return static::thisMonth($timeZone)->modify('+1 year'); + return static::thisYear($timeZone)->modify('+1 year'); } /** @@ -461,7 +473,7 @@ public static function nextYear(?DateTimeZone $timeZone = null): DateTime */ public static function lastYear(?DateTimeZone $timeZone = null): DateTime { - return static::thisMonth($timeZone)->modify('-1 year'); + return static::thisYear($timeZone)->modify('-1 year'); } /** @@ -801,9 +813,10 @@ public static function humanDurationFromInterval(DateInterval $dateInterval, boo * Normalizes and returns a date string along with the format it was set in. * * @param string $value + * @param Locale $locale * @return array */ - private static function _parseDate(string $value): array + private static function _parseDate(string $value, Locale $locale): array { $value = trim($value); @@ -817,7 +830,7 @@ private static function _parseDate(string $value): array } // Get the locale's short date format - $format = Craft::$app->getFormattingLocale()->getDateFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); + $format = $locale->getDateFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); // Make sure it's a 4-digit year $format = StringHelper::replace($format, 'y', 'Y'); @@ -847,9 +860,10 @@ private static function _parseDate(string $value): array * Normalizes and returns a time string along with the format it was set in * * @param string $value + * @param Locale $locale * @return array */ - private static function _parseTime(string $value): array + private static function _parseTime(string $value, Locale $locale): array { $value = trim($value); @@ -859,17 +873,30 @@ private static function _parseTime(string $value): array } // Get the formatting locale's short time format - $formattingLocale = Craft::$app->getFormattingLocale(); - $format = $formattingLocale->getTimeFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); + $format = $locale->getTimeFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); // Replace the localized "AM" and "PM" - $am = $formattingLocale->getAMName(); - $pm = $formattingLocale->getPMName(); + $am = $locale->getAMName(); + $pm = $locale->getPMName(); + $m = [$am, $pm]; + + // account for AM/PM names that might be normalized for jQuery Timepicker + $amAlt = preg_replace('/[\s.]/', '', $am); + $pmAlt = preg_replace('/[\s.]/', '', $pm); + + if ($amAlt !== $am) { + $m[] = $amAlt; + } + if ($pmAlt !== $pm) { + $m[] = $pmAlt; + } + + $quoted = implode('|', array_map(fn($v) => preg_quote($v, '/'), $m)); - if (preg_match('/(.*)(' . preg_quote($am, '/') . '|' . preg_quote($pm, '/') . ')(.*)/iu', $value, $matches)) { + if (preg_match("/(.*)($quoted)(.*)/iu", $value, $matches)) { $value = $matches[1] . $matches[3]; - if (mb_strtolower($matches[2]) === mb_strtolower($am)) { + if (in_array(mb_strtolower($matches[2]), [mb_strtolower($am), mb_strtolower($amAlt)])) { $value .= 'AM'; } else { $value .= 'PM'; @@ -921,7 +948,7 @@ private static function _parseDateTime(string $value, string $defaultTimeZone): (?:\.\d+)? # .s (decimal fraction of a second -- not supported) )? (?:[ ]?(?P(AM|PM|am|pm))?)? # An optional space and AM or PM - (?PZ|(?P[+\-]\d\d\:?\d\d))? # Z or [+ or -]hh(:)ss (UTC or a timezone offset) + (?PZ|(?P[+\-]\d\d\:?\d\d)|([ ]?(?P[a-zA-Z]{1,5}))|([ ]?(?P(Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)\/[\w-]+(\/[\w-]+)?)))? # Z or [+ or -]hh(:)ss or timezone abbreviation or IANA notation timezone )? )? )?$/x', $value, $m)) { @@ -944,6 +971,12 @@ private static function _parseDateTime(string $value, string $defaultTimeZone): if (!empty($m['tzd'])) { $format .= str_contains($m['tzd'], ':') ? 'P' : 'O'; $date .= $m['tzd']; + } elseif (!empty($m['tz2'])) { + $format .= ' e'; + $date .= ' ' . static::normalizeTimeZone($m['tz2']); + } elseif (!empty($m['tz3'])) { + $format .= ' e'; + $date .= ' ' . $m['tz3']; } else { // "Z" = UTC $format .= 'e'; diff --git a/src/helpers/Db.php b/src/helpers/Db.php index 006e464052d..22ef16d4785 100644 --- a/src/helpers/Db.php +++ b/src/helpers/Db.php @@ -14,6 +14,7 @@ use craft\db\pgsql\Schema as PgsqlSchema; use craft\db\Query; use DateTime; +use DateTimeInterface; use DateTimeZone; use Money\Money; use PDO; @@ -142,10 +143,10 @@ public static function prepareValueForDb(mixed $value, ?string $columnType = nul } // If this isn’t a JSON column and the value is an object or array, JSON-encode it - if ( - !in_array($columnType, [Schema::TYPE_JSON, YiiPgqslSchema::TYPE_JSONB]) && - (is_object($value) || is_array($value)) - ) { + if (is_object($value) || is_array($value)) { + if (in_array($columnType, [Schema::TYPE_JSON, YiiPgqslSchema::TYPE_JSONB])) { + return ArrayHelper::toArray($value); + } return Json::encode($value); } @@ -696,7 +697,7 @@ public static function parseParam( * [[\yii\db\QueryInterface::where()]]-compatible condition. * * @param string $column The database column that the param is targeting. - * @param string|array|DateTime $value The param value + * @param string|array|DateTimeInterface $value The param value * @param string $defaultOperator The default operator to apply to the values * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) * @return string|array @@ -1583,7 +1584,7 @@ private static function _toArray(mixed $value): array return []; } - if ($value instanceof DateTime) { + if ($value instanceof DateTimeInterface) { return [$value]; } diff --git a/src/helpers/ElementHelper.php b/src/helpers/ElementHelper.php index 91943c9c90d..0fb5a6081ac 100644 --- a/src/helpers/ElementHelper.php +++ b/src/helpers/ElementHelper.php @@ -153,7 +153,7 @@ public static function setUniqueUri(ElementInterface $element): void $generalConfig = Craft::$app->getConfig()->getGeneral(); $maxSlugIncrement = Craft::$app->getConfig()->getGeneral()->maxSlugIncrement; - $originalSlug = $element->slug; + $originalSlug = $element->slug ?? ''; $originalSlugLen = mb_strlen($originalSlug); for ($i = 1; $i <= $maxSlugIncrement; $i++) { @@ -284,7 +284,7 @@ public static function doesUriFormatHaveSlugTag(string $uriFormat): bool * * @param ElementInterface $element The element to return supported site info for * @param bool $withUnpropagatedSites Whether to include sites the element is currently not being propagated to - * @return array + * @return array[] * @throws Exception if any of the element’s supported sites are invalid */ public static function supportedSitesForElement(ElementInterface $element, bool $withUnpropagatedSites = false): array @@ -374,7 +374,8 @@ public static function shouldTrackChanges(ElementInterface $element): bool $element->siteSettingsId && $element->duplicateOf === null && $element::trackChanges() && - !$element->mergingCanonicalChanges + !$element->mergingCanonicalChanges && + !$element->resaving ); } @@ -795,4 +796,21 @@ public static function attributeHtml(mixed $value): string return Html::encode(StringHelper::stripHtml($value)); } + + /** + * Returns the searchable attributes for a given element, ensuring that `slug` and `title` are included. + * + * @param ElementInterface $element + * @return string[] + * @since 4.6.0 + */ + public static function searchableAttributes(ElementInterface $element): array + { + $searchableAttributes = array_flip($element::searchableAttributes()); + $searchableAttributes['slug'] = true; + if ($element::hasTitles()) { + $searchableAttributes['title'] = true; + } + return array_keys($searchableAttributes); + } } diff --git a/src/helpers/FileHelper.php b/src/helpers/FileHelper.php index cf067ff3ae3..0ce8e03371f 100644 --- a/src/helpers/FileHelper.php +++ b/src/helpers/FileHelper.php @@ -8,6 +8,7 @@ namespace craft\helpers; use Craft; +use craft\errors\MutexException; use craft\errors\SiteNotFoundException; use FilesystemIterator; use RecursiveDirectoryIterator; @@ -16,7 +17,6 @@ use Symfony\Component\Filesystem\Filesystem; use Throwable; use UnexpectedValueException; -use yii\base\Application; use yii\base\ErrorException; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -726,10 +726,10 @@ public static function useFileLocks(): bool $mutex = Craft::$app->getMutex(); $name = uniqid('test_lock', true); if (!$mutex->acquire($name)) { - throw new Exception('Unable to acquire test lock.'); + throw new MutexException($name, 'Unable to acquire test lock.'); } if (!$mutex->release($name)) { - throw new Exception('Unable to release test lock.'); + throw new MutexException($name, 'Unable to release test lock.'); } self::$_useFileLocks = true; } catch (Throwable $e) { @@ -900,11 +900,11 @@ public static function getExtensionByMimeType($mimeType, $preferShort = false, $ */ public static function deleteFileAfterRequest(string $filename): void { - self::$_filesToBeDeleted[] = $filename; - - if (count(self::$_filesToBeDeleted) === 1) { - Craft::$app->on(Application::EVENT_AFTER_REQUEST, [static::class, 'deleteQueuedFiles']); + if (empty(self::$_filesToBeDeleted)) { + register_shutdown_function([static::class, 'deleteQueuedFiles']); } + + self::$_filesToBeDeleted[] = $filename; } /** @@ -919,6 +919,8 @@ public static function deleteQueuedFiles(): void self::unlink($source); } } + + self::$_filesToBeDeleted = []; } /** diff --git a/src/helpers/Html.php b/src/helpers/Html.php index d10b01b2008..b07c7b6864a 100644 --- a/src/helpers/Html.php +++ b/src/helpers/Html.php @@ -589,8 +589,8 @@ private static function _sortedDataAttributes(): array * @param string $content * @return array[] An array containing the HTML content, and the condition (if there is one). * @phpstan-return array{string,string|null} - * @since 4.0.0 * @see wrapIntoCondition() + * @since 4.0.0 */ public static function unwrapCondition(string $content): array { @@ -712,9 +712,9 @@ public static function namespaceHtml(string $html, string $namespace, bool $with * @param string $html The HTML code * @param string $namespace The namespace * @return string The HTML with namespaced input names - * @since 3.5.0 * @see namespaceHtml() * @see namespaceAttributes() + * @since 3.5.0 */ public static function namespaceInputs(string $html, string $namespace): string { @@ -756,9 +756,9 @@ private static function _namespaceInputs(string &$html, string $namespace): void * @param string $namespace The namespace * @param bool $withClasses Whether class names should be namespaced as well (affects both `class` attributes and class name CSS selectors) * @return string The HTML with namespaced attributes - * @since 3.5.0 * @see namespaceHtml() * @see namespaceInputs() + * @since 3.5.0 */ public static function namespaceAttributes(string $html, string $namespace, bool $withClasses = false): string { @@ -852,16 +852,34 @@ private static function _escapeTextareas(string &$html): array { $markers = []; $offset = 0; + $r = ''; - while (preg_match('/]*>/i', $html, $openMatch, PREG_OFFSET_CAPTURE, $offset)) { - $innerOffset = $openMatch[0][1] + strlen($openMatch[0][0]); - if (!preg_match('/<\/textarea>/', $html, $closeMatch, PREG_OFFSET_CAPTURE, $innerOffset)) { + while (($pos = stripos($html, '', $pos + 9); + if ($gtPos === false) { + break; + } + $innerHtmlPos = $gtPos + 1; + $closePos = stripos($html, '', $innerHtmlPos); + if ($closePos === false) { break; } - $marker = sprintf('{marker:%s}', StringHelper::randomString()); - $markers[$marker] = substr($html, $innerOffset, $closeMatch[0][1] - $innerOffset); - $html = substr($html, 0, $innerOffset) . $marker . substr($html, $closeMatch[0][1]); - $offset = $innerOffset + strlen($marker) + strlen($closeMatch[0][0]); + $outerPos = $closePos + 11; + $innerHtml = $closePos !== $innerHtmlPos ? substr($html, $innerHtmlPos, $closePos - $innerHtmlPos) : null; + + if ($innerHtml !== null && str_contains($innerHtml, '<')) { + $marker = sprintf('{marker:%s}', mt_rand()); + $r .= substr($html, $offset, $innerHtmlPos - $offset) . $marker . substr($html, $closePos, 11); + $markers[$marker] = $innerHtml; + } else { + $r .= substr($html, $offset, $outerPos - $offset); + } + + $offset = $outerPos; + } + + if ($offset !== 0) { + $html = $r . substr($html, $offset); } return $markers; @@ -876,7 +894,22 @@ private static function _escapeTextareas(string &$html): array */ private static function _restoreTextareas(string $html, array $markers): string { - return str_replace(array_keys($markers), array_values($markers), $html); + if (empty($markers)) { + return $html; + } + + $r = ''; + $offset = 0; + + foreach ($markers as $marker => $textarea) { + $pos = strpos($html, $marker, $offset); + if ($pos !== false) { + $r .= substr($html, $offset, $pos - $offset) . $textarea; + $offset = $pos + strlen($marker); + } + } + + return $r . substr($html, $offset); } /** diff --git a/src/helpers/Image.php b/src/helpers/Image.php index 8b1b0456979..f4ae3775365 100644 --- a/src/helpers/Image.php +++ b/src/helpers/Image.php @@ -54,10 +54,11 @@ public static function calculateMissingDimension(float|int|null $targetWidth, fl return [(int)$sourceWidth, (int)$sourceHeight]; } - // Fill in the blank + // Fill in the blank, + // ensure that the target width/height is at least 1 return [ - (int)($targetWidth ?: round($targetHeight * ($sourceWidth / $sourceHeight))), - (int)($targetHeight ?: round($targetWidth * ($sourceHeight / $sourceWidth))), + (int)($targetWidth ?: max(round($targetHeight * ($sourceWidth / $sourceHeight)), 1)), + (int)($targetHeight ?: max(round($targetWidth * ($sourceHeight / $sourceWidth)), 1)), ]; } @@ -273,7 +274,8 @@ public static function imageSize(string $filePath): array $image = Craft::$app->getImages()->loadImage($filePath); return [$image->getWidth(), $image->getHeight()]; - } catch (Throwable) { + } catch (Throwable $e) { + Craft::warning($e->getMessage(), __METHOD__); return [0, 0]; } } diff --git a/src/helpers/Sequence.php b/src/helpers/Sequence.php index 20c0dfe8590..6390ec9b014 100644 --- a/src/helpers/Sequence.php +++ b/src/helpers/Sequence.php @@ -10,6 +10,7 @@ use Craft; use craft\db\Query; use craft\db\Table; +use craft\errors\MutexException; use Throwable; use yii\db\Exception; @@ -50,7 +51,7 @@ public static function next(string $name, ?int $length = null): int|string $lockName = 'seq--' . str_replace(['/', '\\'], '-', $name); if (!$mutex->acquire($lockName, 3)) { - throw new Exception('Could not acquire a lock for the sequence "' . $name . '".'); + throw new MutexException($lockName, sprintf('Could not acquire a lock for the sequence "%s".', $name)); } try { diff --git a/src/helpers/StringHelper.php b/src/helpers/StringHelper.php index feaa9cade69..6ed96eddde4 100644 --- a/src/helpers/StringHelper.php +++ b/src/helpers/StringHelper.php @@ -1252,10 +1252,6 @@ public static function replaceLast(string $str, string $search, string $replacem */ public static function replaceMb4(string $str, callable|string $replace): string { - if (!static::containsMb4($str)) { - return $str; - } - return preg_replace_callback('/./u', function(array $match) use ($replace): string { if (strlen($match[0]) >= 4) { return is_callable($replace) ? $replace($match[0]) : $replace; diff --git a/src/helpers/UrlHelper.php b/src/helpers/UrlHelper.php index 561025842f6..28beceb3d0b 100644 --- a/src/helpers/UrlHelper.php +++ b/src/helpers/UrlHelper.php @@ -69,7 +69,6 @@ public static function isFullUrl(string $url): bool * @param array $params * @return string * @since 3.3.0 - * @deprecated in 4.5.0. `http_build_query()` should be used instead. */ public static function buildQuery(array $params): string { @@ -85,6 +84,7 @@ public static function buildQuery(array $params): string $params = []; foreach (explode('&', $query) as $param) { [$n, $v] = array_pad(explode('=', $param, 2), 2, ''); + $n = urldecode($n); $v = str_replace(['%2F', '%7B', '%7D'], ['/', '{', '}'], $v); $params[] = $v !== '' ? "$n=$v" : $n; } @@ -115,7 +115,7 @@ public static function urlWithParams(string $url, array|string $params): string $fragment = $fragment ?? $baseFragment; // Append to the base URL and return - if (($query = http_build_query($params)) !== '') { + if (($query = static::buildQuery($params)) !== '') { $url .= '?' . $query; } if ($fragment !== null) { @@ -141,7 +141,7 @@ public static function removeParam(string $url, string $param): string unset($params[$param]); // Rebuild - if (($query = http_build_query($params)) !== '') { + if (($query = static::buildQuery($params)) !== '') { $url .= '?' . $query; } if ($fragment !== null) { @@ -672,7 +672,7 @@ private static function _createUrl(string $path, array|string|null $params, ?str */ private static function _buildUrl(string $url, array $params, ?string $fragment): string { - if (($query = http_build_query($params)) !== '') { + if (($query = static::buildQuery($params)) !== '') { $url .= '?' . $query; } diff --git a/src/i18n/I18N.php b/src/i18n/I18N.php index 8f9e3c48f56..7eb130301c9 100644 --- a/src/i18n/I18N.php +++ b/src/i18n/I18N.php @@ -320,17 +320,6 @@ public function translate($category, $message, $params, $language): ?string return $translation; } - /** - * @inheritdoc - */ - public function format($message, $params, $language) - { - // wrap attribute value in an tag - array_walk($params, fn(&$val, $key) => ($key == 'attribute') ? $val = "*$val*" : $val); - - return parent::format($message, $params, $language); - } - /** * Returns whether [[translate()]] should wrap translations with `@` characters, * per the `translationDebugOutput` config setting. diff --git a/src/icons/c-debug.svg b/src/icons/c-debug.svg index 010b7ca028c..be94520453c 100644 --- a/src/icons/c-debug.svg +++ b/src/icons/c-debug.svg @@ -1,9 +1,6 @@ - - diff --git a/src/icons/craft-cms.svg b/src/icons/craft-cms.svg index 2c7af66e3fa..e0f6ff99300 100644 --- a/src/icons/craft-cms.svg +++ b/src/icons/craft-cms.svg @@ -2,10 +2,7 @@ Craft CMS - - diff --git a/src/icons/file.svg b/src/icons/file.svg index 75a76b3d42e..e7e4d0b3072 100644 --- a/src/icons/file.svg +++ b/src/icons/file.svg @@ -1,12 +1,7 @@ - - - - + + + diff --git a/src/icons/folder.svg b/src/icons/folder.svg index 53e95b4f688..e140cbb56a1 100644 --- a/src/icons/folder.svg +++ b/src/icons/folder.svg @@ -1,14 +1,10 @@ - - - diff --git a/src/icons/user.svg b/src/icons/user.svg index ba6187a1ad6..009336c8abc 100644 --- a/src/icons/user.svg +++ b/src/icons/user.svg @@ -2,10 +2,7 @@ - - diff --git a/src/image/Raster.php b/src/image/Raster.php index c25c8f0bf05..7cc5d7dff2d 100644 --- a/src/image/Raster.php +++ b/src/image/Raster.php @@ -177,6 +177,12 @@ public function loadImage(string $path): self try { $this->_image = $this->_instance->open($path); } catch (Throwable $e) { + // Imagick can throw all sorts of errors via the open() method + // we should log them to better know what's going on + Craft::warning($e->getMessage(), $e->getFile()); + if (($instanceException = $e->getPrevious()) !== null) { + Craft::warning($instanceException->getMessage(), $instanceException->getFile() . ':' . $instanceException->getLine()); + } throw new ImageException(Craft::t('app', 'The file “{name}” does not appear to be an image.', [ 'name' => basename($path), ]), 0, $e); @@ -798,9 +804,11 @@ private function _getSaveOptions(?int $quality, ?string $extension = null): arra return ['jpeg_quality' => $quality, 'flatten' => true]; case 'gif': - case 'webp': return ['animated' => $this->_isAnimated]; + case 'webp': + return ['animated' => $this->_isAnimated, 'webp_quality' => $quality]; + case 'png': // Valid PNG quality settings are 0-9, so normalize and flip, because we're talking about compression // levels, not quality, like jpg and gif. diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index 3bdd75df455..2672e1912f2 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -664,8 +664,14 @@ public function startImageEditing(Asset $asset): void { $imageCopy = $asset->getCopyOfFile(); - /** @var Raster $image */ - $image = Craft::$app->getImages()->loadImage($imageCopy, true, max($asset->height, $asset->width)); + if (FileHelper::isSvg($imageCopy)) { + $size = max($asset->width, $asset->height) ?? 1000; + /** @var Raster $image */ + $image = Craft::$app->getImages()->loadImage($imageCopy, true, $size); + } else { + /** @var Raster $image */ + $image = Craft::$app->getImages()->loadImage($imageCopy); + } // TODO Is this hacky? It seems hacky. // We're rasterizing SVG, we have to make sure that the filename change does not get lost diff --git a/src/log/ContextProcessor.php b/src/log/ContextProcessor.php index 941b6605f50..08e5779dd38 100644 --- a/src/log/ContextProcessor.php +++ b/src/log/ContextProcessor.php @@ -9,8 +9,10 @@ use Craft; use craft\helpers\ArrayHelper; +use craft\helpers\Json; use Illuminate\Support\Collection; use Monolog\Processor\ProcessorInterface; +use yii\base\InvalidArgumentException; use yii\helpers\VarDumper; use yii\web\Request; use yii\web\Session; @@ -40,6 +42,8 @@ public function __construct( */ public function __invoke(array $record): array { + $record[$this->key]['environment'] = Craft::$app->env; + if (Craft::$app->getConfig()->getGeneral()->storeUserIps) { $request = Craft::$app->getRequest(); @@ -68,6 +72,18 @@ public function __invoke(array $record): array // Log the raw request body instead $this->vars = array_merge($this->vars); array_splice($this->vars, $postPos, 1); + + // Redact sensitive bits + try { + $decoded = Json::decode($body); + if (is_array($decoded)) { + $decoded = Craft::$app->getSecurity()->redactIfSensitive('', $decoded); + } + $body = Json::encode($decoded); + } catch (InvalidArgumentException) { + // NBD + } + $record[$this->key]['body'] = $body; } @@ -93,16 +109,15 @@ protected function dumpVars(array $vars): string protected function filterVars(array $vars = []): array { - $filtered = Collection::make(ArrayHelper::filter($GLOBALS, $vars)); + $filtered = ArrayHelper::filter($GLOBALS, $vars); // Workaround for codeception testing until these gets addressed: // https://github.com/yiisoft/yii-core/issues/49 // https://github.com/yiisoft/yii2/issues/15847 if (Craft::$app) { - $security = Craft::$app->getSecurity(); - $filtered = $filtered->map(fn($value, $key) => $security->redactIfSensitive($key, $value)); + $filtered = Craft::$app->getSecurity()->redactIfSensitive('', $filtered); } - return $filtered->all(); + return $filtered; } } diff --git a/src/log/Dispatcher.php b/src/log/Dispatcher.php index 0ca875fdace..ceffb92227b 100644 --- a/src/log/Dispatcher.php +++ b/src/log/Dispatcher.php @@ -67,15 +67,18 @@ public function getTargets(): array static::TARGET_QUEUE, ])->mapWithKeys(function($name) { $allowLineBreaks = (bool) (App::env('CRAFT_LOG_ALLOW_LINE_BREAKS') ?? App::devMode()); - $config = $this->monologTargetConfig + [ - 'name' => $name, - 'enabled' => false, - 'extractExceptionTrace' => !App::devMode(), - 'allowLineBreaks' => $allowLineBreaks, - 'level' => App::devMode() ? LogLevel::INFO : LogLevel::WARNING, - ]; + $config = [ + 'class' => MonologTarget::class, + ] + $this->monologTargetConfig + [ + 'name' => $name, + 'enabled' => false, + 'extractExceptionTrace' => !App::devMode(), + 'allowLineBreaks' => $allowLineBreaks, + 'level' => App::devMode() ? LogLevel::INFO : LogLevel::WARNING, + ]; - return [$name => new MonologTarget($config)]; + $target = Craft::createObject($config); + return [$name => $target]; }); // Queue is enabled via QueueLogBehavior diff --git a/src/log/MonologTarget.php b/src/log/MonologTarget.php index 28cc79585e5..6ba31cd2388 100644 --- a/src/log/MonologTarget.php +++ b/src/log/MonologTarget.php @@ -4,6 +4,7 @@ use Craft; use craft\helpers\App; +use DateTimeZone; use Illuminate\Support\Collection; use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\LineFormatter; @@ -80,11 +81,6 @@ class MonologTarget extends PsrTarget */ protected ?ProcessorInterface $processor = null; - /** - * @var Logger|null $logger - */ - protected $logger; - /** * @inheritdoc */ @@ -104,6 +100,7 @@ public function init(): void */ public function getLogger(): Logger { + /** @var Logger */ return $this->logger; } @@ -123,20 +120,25 @@ public function setLogger(Logger|LoggerInterface $logger): void public function export(): void { $this->messages = $this->_filterMessagesByPsrLevel($this->messages, $this->level); + + /** @var Logger $logger */ + $logger = $this->logger; + $logger->setTimezone(new DateTimeZone(Craft::$app->getTimeZone())); + parent::export(); if (!$this->logContext || empty($this->messages)) { return; } - $this->logger->pushProcessor(new ContextProcessor( + $logger->pushProcessor(new ContextProcessor( vars: $this->logVars, dumpVars: $this->allowLineBreaks, )); // Log at default level, so it doesn't get filtered - $this->logger->log($this->level, 'Request context:'); - $this->logger->popProcessor(); + $logger->log($this->level, 'Request context:'); + $logger->popProcessor(); } /** diff --git a/src/markdown/PreEncodedMarkdown.php b/src/markdown/PreEncodedMarkdown.php new file mode 100644 index 00000000000..c9cf103c8d3 --- /dev/null +++ b/src/markdown/PreEncodedMarkdown.php @@ -0,0 +1,29 @@ + + * @since 4.5.13 + */ +class PreEncodedMarkdown extends Markdown +{ + protected function renderCode($block): string + { + $class = isset($block['language']) ? ' class="language-' . $block['language'] . '"' : ''; + return sprintf("
%s\n
\n", $class, $block['content']); + } + + protected function renderInlineCode($block): string + { + return sprintf('%s', $block[1]); + } +} diff --git a/src/migrations/CreatePhpSessionTable.php b/src/migrations/CreatePhpSessionTable.php index 1d6b0471602..49cffb1bb3b 100644 --- a/src/migrations/CreatePhpSessionTable.php +++ b/src/migrations/CreatePhpSessionTable.php @@ -24,7 +24,7 @@ class CreatePhpSessionTable extends Migration public function safeUp(): bool { $this->createTable(Table::PHPSESSIONS, [ - 'id' => $this->char(255)->notNull(), + 'id' => $this->string()->notNull(), 'expire' => $this->integer(), 'data' => $this->binary(), 'dateCreated' => $this->dateTime()->notNull(), diff --git a/src/migrations/m230710_162700_element_activity.php b/src/migrations/m230710_162700_element_activity.php index 79c25ac50f6..cc7ad7cf36b 100644 --- a/src/migrations/m230710_162700_element_activity.php +++ b/src/migrations/m230710_162700_element_activity.php @@ -15,6 +15,8 @@ class m230710_162700_element_activity extends Migration */ public function safeUp(): bool { + $this->dropTableIfExists(Table::ELEMENTACTIVITY); + $this->createTable(Table::ELEMENTACTIVITY, [ 'elementId' => $this->integer()->notNull(), 'userId' => $this->integer()->notNull(), diff --git a/src/migrations/m230826_094050_fix_session_id_type.php b/src/migrations/m230826_094050_fix_session_id_type.php new file mode 100644 index 00000000000..5576551bc44 --- /dev/null +++ b/src/migrations/m230826_094050_fix_session_id_type.php @@ -0,0 +1,34 @@ +getDb()->tableExists(Table::PHPSESSIONS)) { + $this->alterColumn(Table::PHPSESSIONS, 'id', $this->string()->notNull()); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m230826_094050_fix_session_id_type cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/SiteGroup.php b/src/models/SiteGroup.php index 2638139fd98..5d63df812b2 100644 --- a/src/models/SiteGroup.php +++ b/src/models/SiteGroup.php @@ -10,7 +10,6 @@ use Craft; use craft\base\Model; use craft\helpers\App; -use craft\helpers\ArrayHelper; use craft\records\SiteGroup as SiteGroupRecord; use craft\validators\UniqueValidator; @@ -121,7 +120,7 @@ public function getSites(): array */ public function getSiteIds(): array { - return ArrayHelper::getColumn($this->getSites(), 'id'); + return array_map(fn(Site $site) => $site->id, $this->getSites()); } /** diff --git a/src/models/Volume.php b/src/models/Volume.php index d29ed4d8317..53ffdf46b21 100644 --- a/src/models/Volume.php +++ b/src/models/Volume.php @@ -149,6 +149,9 @@ public function attributeLabels(): array 'handle' => Craft::t('app', 'Handle'), 'name' => Craft::t('app', 'Name'), 'url' => Craft::t('app', 'URL'), + 'fsHandle' => Craft::t('app', 'Asset Filesystem'), + 'transformFsHandle' => Craft::t('app', 'Transform Filesystem'), + 'transformSubpath' => Craft::t('app', 'Transform Subpath'), ]; } @@ -160,7 +163,7 @@ protected function defineRules(): array $rules = parent::defineRules(); $rules[] = [['id', 'fieldLayoutId'], 'number', 'integerOnly' => true]; $rules[] = [['name', 'handle'], UniqueValidator::class, 'targetClass' => VolumeRecord::class]; - $rules[] = [['name', 'handle'], 'required']; + $rules[] = [['name', 'handle', 'fsHandle'], 'required']; $rules[] = [ ['handle'], HandleValidator::class, diff --git a/src/mutex/Mutex.php b/src/mutex/Mutex.php index 956c7e1e20e..29a0f39ae72 100644 --- a/src/mutex/Mutex.php +++ b/src/mutex/Mutex.php @@ -7,6 +7,7 @@ namespace craft\mutex; +use Craft; use craft\helpers\App; use yii\di\Instance; use yii\mutex\Mutex as YiiMutex; @@ -48,12 +49,10 @@ public function init(): void $this->_init(); if (!isset($this->mutex)) { - if (App::devMode()) { - // Use NullMutex for Dev Mode, since they’re not really needed for development, - // and partially to avoid Windows/Linux filesystem conflicts - $this->mutex = NullMutex::class; + if (Craft::$app->id !== 'craft-test' && Craft::$app->getIsInstalled()) { + $this->mutex = App::dbMutexConfig(); } else { - $this->mutex = App::mutexConfig(); + $this->mutex = NullMutex::class; } } diff --git a/src/mutex/MutexTrait.php b/src/mutex/MutexTrait.php index 57201d167ec..81621d2f57b 100644 --- a/src/mutex/MutexTrait.php +++ b/src/mutex/MutexTrait.php @@ -8,7 +8,6 @@ namespace craft\mutex; use Craft; -use yii\base\Application; use yii\db\Connection; /** @@ -42,9 +41,15 @@ public function init(): void parent::init(); $this->_db = Craft::$app->getDb(); - $this->_db->on(Connection::EVENT_COMMIT_TRANSACTION, [$this, 'releaseQueuedLocks']); - $this->_db->on(Connection::EVENT_ROLLBACK_TRANSACTION, [$this, 'releaseQueuedLocks']); - Craft::$app->on(Application::EVENT_AFTER_REQUEST, [$this, 'releaseQueuedLocks']); + $this->_db->on(Connection::EVENT_COMMIT_TRANSACTION, function() { + $this->releaseQueuedLocks(); + }); + $this->_db->on(Connection::EVENT_ROLLBACK_TRANSACTION, function() { + $this->releaseQueuedLocks(); + }); + Craft::$app->onAfterRequest(function() { + $this->releaseQueuedLocks(); + }); } /** diff --git a/src/queue/Queue.php b/src/queue/Queue.php index de2eb932869..b13349dd0a9 100644 --- a/src/queue/Queue.php +++ b/src/queue/Queue.php @@ -10,6 +10,7 @@ use Craft; use craft\db\Connection; use craft\db\Table; +use craft\errors\MutexException; use craft\helpers\App; use craft\helpers\ArrayHelper; use craft\helpers\Db; @@ -388,7 +389,7 @@ public function setProgress(int $progress, ?string $label = null): void public function getHasWaitingJobs(): bool { // Move expired messages into waiting list - $this->_moveExpired(0, false); + $this->_moveExpired(); return $this->db->usePrimary(function() { return $this->_createWaitingJobQuery()->exists($this->db); @@ -401,7 +402,7 @@ public function getHasWaitingJobs(): bool public function getHasReservedJobs(): bool { // Move expired messages into waiting list - $this->_moveExpired(0, false); + $this->_moveExpired(); return $this->db->usePrimary(function() { return $this->_createReservedJobQuery()->exists($this->db); @@ -416,7 +417,7 @@ public function getHasReservedJobs(): bool public function getTotalWaiting(): int { // Move expired messages into waiting list - $this->_moveExpired(0, false); + $this->_moveExpired(); return $this->db->usePrimary(function() { return $this->_createWaitingJobQuery()->count('*', $this->db); @@ -431,7 +432,7 @@ public function getTotalWaiting(): int public function getTotalDelayed(): int { // Move expired messages into waiting list - $this->_moveExpired(0, false); + $this->_moveExpired(); return $this->db->usePrimary(function() { return $this->_createDelayedJobQuery()->count('*', $this->db); @@ -446,7 +447,7 @@ public function getTotalDelayed(): int public function getTotalReserved(): int { // Move expired messages into waiting list - $this->_moveExpired(0, false); + $this->_moveExpired(); return $this->db->usePrimary(function() { return $this->_createReservedJobQuery()->count('*', $this->db); @@ -461,7 +462,7 @@ public function getTotalReserved(): int public function getTotalFailed(): int { // Move expired messages into waiting list - $this->_moveExpired(0, false); + $this->_moveExpired(); return $this->db->usePrimary(function() { return $this->_createFailedJobQuery()->count('*', $this->db); @@ -520,7 +521,7 @@ public function getTotalJobs(): int public function getJobInfo(?int $limit = null): array { // Move expired messages into waiting list - $this->_moveExpired(0, false); + $this->_moveExpired(); $query = $this->_createJobQuery(); @@ -774,42 +775,37 @@ private function _jobData(mixed $job): string /** * Moves expired messages into waiting list. - * - * @param int|null $mutexTimeout - * @param bool $throwMutexException */ - private function _moveExpired(?int $mutexTimeout = null, bool $throwMutexException = true): void + private function _moveExpired(): void { if ($this->_reserveTime !== time()) { - $this->_lock(function() { - $this->_reserveTime = time(); - - $expiredIds = $this->db->usePrimary(function() { - return (new Query()) - ->select(['id']) - ->from([$this->tableName]) - ->where([ - 'and', - [ - 'channel' => $this->channel(), - 'fail' => false, - ], - '[[timeUpdated]] < :time - [[ttr]]', - ], [ - ':time' => $this->_reserveTime, - ]) - ->column($this->db); - }); - - if (!empty($expiredIds)) { - Db::update($this->tableName, [ - 'dateReserved' => null, - 'timeUpdated' => null, - 'progress' => 0, - 'progressLabel' => null, - ], ['id' => $expiredIds], [], false, $this->db); - } - }, $mutexTimeout, $throwMutexException); + $this->_reserveTime = time(); + + $expiredIds = $this->db->usePrimary(function() { + return (new Query()) + ->select(['id']) + ->from([$this->tableName]) + ->where([ + 'and', + [ + 'channel' => $this->channel(), + 'fail' => false, + ], + '[[timeUpdated]] < :time - [[ttr]]', + ], [ + ':time' => $this->_reserveTime, + ]) + ->column($this->db); + }); + + if (!empty($expiredIds)) { + Db::update($this->tableName, [ + 'dateReserved' => null, + 'timeUpdated' => null, + 'progress' => 0, + 'progressLabel' => null, + ], ['id' => $expiredIds], [], false, $this->db); + } } } @@ -911,7 +907,7 @@ private function _status(array|false $payload): int * @param callable $callback * @param int|null $timeout * @param bool $throwException - * @throws Exception + * @throws MutexException */ private function _lock(callable $callback, ?int $timeout = null, bool $throwException = true): void { @@ -922,7 +918,7 @@ private function _lock(callable $callback, ?int $timeout = null, bool $throwExce $mutexName = sprintf('%s::%s', __CLASS__, $channel); if (!$this->mutex->acquire($mutexName, $timeout ?? $this->mutexTimeout)) { if ($throwException) { - throw new Exception("Could not acquire a mutex lock for the queue ($channel)."); + throw new MutexException($mutexName, "Could not acquire a mutex lock for the queue ($channel)."); } return; } diff --git a/src/queue/jobs/Announcement.php b/src/queue/jobs/Announcement.php index 88a1594a77f..d8c40bba86f 100644 --- a/src/queue/jobs/Announcement.php +++ b/src/queue/jobs/Announcement.php @@ -39,6 +39,12 @@ class Announcement extends BaseJob */ public ?string $pluginHandle = null; + /** + * @var bool Whether only admins should receive the announcement. + * @since 4.5.6 + */ + public bool $adminsOnly = false; + /** * @inheritdoc * @throws Exception @@ -60,6 +66,10 @@ public function execute($queue): void $userQuery = User::find() ->can('accessCp'); + if ($this->adminsOnly) { + $userQuery->admin(); + } + $totalUsers = $userQuery->count(); $batchSize = 100; $dateCreated = Db::prepareDateForDb(new DateTime()); diff --git a/src/queue/jobs/ApplyNewPropagationMethod.php b/src/queue/jobs/ApplyNewPropagationMethod.php index 224c7eabd74..4621430333b 100644 --- a/src/queue/jobs/ApplyNewPropagationMethod.php +++ b/src/queue/jobs/ApplyNewPropagationMethod.php @@ -14,7 +14,6 @@ use craft\db\QueryBatcher; use craft\db\Table; use craft\errors\UnsupportedSiteException; -use craft\helpers\ArrayHelper; use craft\helpers\Db; use craft\helpers\ElementHelper; use craft\i18n\Translation; @@ -91,7 +90,10 @@ protected function processItem(mixed $item): void // See what sites the element should exist in going forward /** @var ElementInterface $item */ - $newSiteIds = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($item), 'siteId'); + $newSiteIds = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($item), + ); // What other sites are there? $otherSiteIds = array_diff($allSiteIds, $newSiteIds); @@ -120,7 +122,7 @@ protected function processItem(mixed $item): void Db::update(Table::ELEMENTS_SITES, [ 'uri' => null, ], [ - 'id' => ArrayHelper::getColumn($otherSiteElements, 'siteSettingsId'), + 'id' => array_map(fn(ElementInterface $element) => $element->siteSettingsId, $otherSiteElements), ], [], false); // Duplicate those elements so their content can live on @@ -179,7 +181,10 @@ protected function processItem(mixed $item): void } // This may support more than just the site it was saved in - $newElementSiteIds = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($newElement), 'siteId'); + $newElementSiteIds = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($newElement), + ); foreach ($newElementSiteIds as $newElementSiteId) { unset($otherSiteElements[$newElementSiteId]); $this->duplicatedElementIds[$item->id][$newElementSiteId] = $newElement->id; diff --git a/src/services/Addresses.php b/src/services/Addresses.php index 42ca2d8a62f..17ca6141169 100644 --- a/src/services/Addresses.php +++ b/src/services/Addresses.php @@ -24,7 +24,6 @@ use craft\events\DefineAddressFieldsEvent; use craft\events\DefineAddressSubdivisionsEvent; use craft\helpers\ProjectConfig as ProjectConfigHelper; -use craft\helpers\StringHelper; use craft\models\FieldLayout; use craft\models\FieldLayoutTab; use yii\base\Component; @@ -367,11 +366,10 @@ public function saveLayout(FieldLayout $layout, bool $runValidation = true): boo return false; } - $projectConfig = Craft::$app->getProjectConfig(); - $fieldLayoutConfig = $layout->getConfig(); - $uid = StringHelper::UUID(); + Craft::$app->getProjectConfig()->set(ProjectConfig::PATH_ADDRESS_FIELD_LAYOUTS, [ + $layout->uid => $layout->getConfig(), + ], 'Save the address field layout'); - $projectConfig->set(ProjectConfig::PATH_ADDRESS_FIELD_LAYOUTS, [$uid => $fieldLayoutConfig], 'Save the address field layout'); return true; } diff --git a/src/services/Announcements.php b/src/services/Announcements.php index 60a2222b9a3..0d609d5fad2 100644 --- a/src/services/Announcements.php +++ b/src/services/Announcements.php @@ -8,9 +8,9 @@ namespace craft\services; use Craft; +use craft\base\PluginInterface; use craft\db\Query; use craft\db\Table; -use craft\helpers\ArrayHelper; use craft\helpers\Db; use craft\helpers\Html; use craft\helpers\Queue; @@ -41,13 +41,15 @@ class Announcements extends Component * @param string $heading The announcement heading. * @param string $body The announcement body. * @param string|null $pluginHandle The plugin handle, if this announcement belongs to a plugin + * @param bool $adminsOnly Whether only admin users should receive the announcement */ - public function push(string $heading, string $body, ?string $pluginHandle = null): void + public function push(string $heading, string $body, ?string $pluginHandle = null, bool $adminsOnly = false): void { Queue::push(new Announcement([ 'heading' => $heading, 'body' => $body, 'pluginHandle' => $pluginHandle, + 'adminsOnly' => $adminsOnly, ])); } @@ -77,7 +79,10 @@ public function get(): array // Any enabled plugins? $pluginsService = Craft::$app->getPlugins(); - $enabledPluginHandles = ArrayHelper::getColumn($pluginsService->getAllPlugins(), 'id'); + $enabledPluginHandles = array_map( + fn(PluginInterface $plugin) => $plugin->getHandle(), + $pluginsService->getAllPlugins(), + ); if (!empty($enabledPluginHandles)) { $query ->addSelect(['pluginHandle' => 'p.handle']) diff --git a/src/services/AssetIndexer.php b/src/services/AssetIndexer.php index 41c06b584e0..6b5da101bd5 100644 --- a/src/services/AssetIndexer.php +++ b/src/services/AssetIndexer.php @@ -18,6 +18,7 @@ use craft\errors\FsException; use craft\errors\MissingAssetException; use craft\errors\MissingVolumeFolderException; +use craft\errors\MutexException; use craft\errors\VolumeException; use craft\helpers\Assets as AssetsHelper; use craft\helpers\DateTimeHelper; @@ -299,7 +300,7 @@ public function processIndexSession(AssetIndexingSession $indexingSession): Asse $lockName = 'idx--' . $indexingSession->id . '--'; if (!$mutex->acquire($lockName, 3)) { - throw new Exception('Could not acquire a lock for the indexing session "' . $indexingSession->id . '".'); + throw new MutexException($lockName, sprintf('Could not acquire a lock for the indexing session "%s".', $indexingSession->id)); } $indexEntry = $this->getNextIndexEntry($indexingSession); @@ -376,12 +377,13 @@ public function getSkippedItemsForSession(AssetIndexingSession $session): array * Get missing entries after an indexing session. * * @param AssetIndexingSession $session + * @param string $path * @return array with `files` and `folders` keys, containing missing entries. * @phpstan-return array{folders:array,files:array} * @throws AssetException * @since 4.0.0 */ - public function getMissingEntriesForSession(AssetIndexingSession $session): array + public function getMissingEntriesForSession(AssetIndexingSession $session, string $path = ''): array { if (!$session->actionRequired) { throw new AssetException('A session must be finished before missing entries can be fetched'); @@ -409,6 +411,10 @@ public function getMissingEntriesForSession(AssetIndexingSession $session): arra ->andWhere(['folders.volumeId' => $volumeList]) ->andWhere(['not', ['folders.parentId' => null]]); + if ($path !== '') { + $missingFoldersQuery->andWhere(['like', 'folders.path', "$path%", false]); + } + if (!$session->listEmptyFolders) { $missingFoldersQuery ->leftJoin(['indexData' => Table::ASSETINDEXDATA], ['and', '[[folders.id]] = [[indexData.recordId]]', ['indexData.isDir' => true]]) @@ -417,7 +423,7 @@ public function getMissingEntriesForSession(AssetIndexingSession $session): arra $missingFolders = $missingFoldersQuery->all(); - $missingFiles = (new Query()) + $missingFilesQuery = (new Query()) ->select(['path' => 'folders.path', 'volumeName' => 'volumes.name', 'filename' => 'assets.filename', 'assetId' => 'assets.id']) ->from(['assets' => Table::ASSETS]) ->leftJoin(['elements' => Table::ELEMENTS], '[[elements.id]] = [[assets.id]]') @@ -427,8 +433,13 @@ public function getMissingEntriesForSession(AssetIndexingSession $session): arra ->where(['<', 'assets.dateCreated', $cutoff]) ->andWhere(['assets.volumeId' => $volumeList]) ->andWhere(['elements.dateDeleted' => null]) - ->andWhere(['indexData.id' => null]) - ->all(); + ->andWhere(['indexData.id' => null]); + + if ($path !== '') { + $missingFilesQuery->andWhere(['like', 'folders.path', "$path%", false]); + } + + $missingFiles = $missingFilesQuery->all(); foreach ($missingFolders as ['folderId' => $folderId, 'path' => $path, 'volumeName' => $volumeName, 'volumeId' => $volumeId]) { /** @@ -835,7 +846,7 @@ protected function incrementProcessedEntryCount(AssetIndexingSession $session): $lockName = 'idx--update-' . $session->id . '--'; if (!$mutex->acquire($lockName, 5)) { - throw new Exception('Could not acquire a lock for the indexing session "' . $session->id . '".'); + throw new MutexException($lockName, sprintf('Could not acquire a lock for the indexing session "%s".', $session->id)); } /** @var AssetIndexingSessionRecord $record */ diff --git a/src/services/Assets.php b/src/services/Assets.php index b5db1f9da3c..c3c6a32314e 100644 --- a/src/services/Assets.php +++ b/src/services/Assets.php @@ -707,8 +707,8 @@ public function getThumbUrl(Asset $asset, int $width, ?int $height = null, $icon * @param int $maxWidth * @param int $maxHeight * @return string - * @since 4.0.0 * @throws NotSupportedException if the asset’s volume doesn’t have a filesystem with public URLs + * @since 4.0.0 */ public function getImagePreviewUrl(Asset $asset, int $maxWidth, int $maxHeight): string { @@ -853,7 +853,7 @@ public function getNameReplacementInFolder(string $originalFilename, int $folder } /** - * Ensures a folder entry exists in the DB for the full path and return its ID. Depending on the use, it's possible to also ensure a physical folder exists. + * Ensures a folder entry exists in the DB for the full path. Depending on the use, it’s also possible to ensure a physical folder exists. * * @param string $fullPath The path to ensure the folder exists at. * @param Volume $volume diff --git a/src/services/Categories.php b/src/services/Categories.php index 5ec74cf56f1..d434fe2bc87 100644 --- a/src/services/Categories.php +++ b/src/services/Categories.php @@ -95,7 +95,7 @@ public function __serialize() */ public function getAllGroupIds(): array { - return ArrayHelper::getColumn($this->getAllGroups(), 'id'); + return array_map(fn(CategoryGroup $group) => $group->id, $this->getAllGroups()); } /** @@ -105,7 +105,7 @@ public function getAllGroupIds(): array */ public function getEditableGroupIds(): array { - return ArrayHelper::getColumn($this->getEditableGroups(), 'id'); + return array_map(fn(CategoryGroup $group) => $group->id, $this->getEditableGroups()); } /** @@ -116,19 +116,15 @@ public function getEditableGroupIds(): array private function _groups(): MemoizableArray { if (!isset($this->_groups)) { - $groups = []; - - /** @var CategoryGroupRecord[] $groupRecords */ $groupRecords = CategoryGroupRecord::find() ->orderBy(['name' => SORT_ASC]) ->with('structure') ->all(); - foreach ($groupRecords as $groupRecord) { - $groups[] = $this->_createCategoryGroupFromRecord($groupRecord); - } - - $this->_groups = new MemoizableArray($groups); + $this->_groups = new MemoizableArray( + $groupRecords, + fn(CategoryGroupRecord $record) => $this->_createCategoryGroupFromRecord($record), + ); } return $this->_groups; diff --git a/src/services/Composer.php b/src/services/Composer.php index b02018389f8..e5a77622134 100644 --- a/src/services/Composer.php +++ b/src/services/Composer.php @@ -11,10 +11,10 @@ use Composer\IO\NullIO; use Composer\Json\JsonFile; use Craft; +use craft\helpers\App; use craft\helpers\FileHelper; use craft\helpers\Json; use Symfony\Component\Process\Exception\ProcessFailedException; -use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; use Throwable; use yii\base\Component; @@ -187,7 +187,7 @@ private function runComposerCommand(IOInterface $io, string $jsonPath, array $co copy(Craft::getAlias('@lib/composer.phar'), $pharPath); $command = array_merge([ - (new PhpExecutableFinder())->find() ?: 'php', + App::phpExecutable() ?? 'php', $pharPath, ], $command, [ '--working-dir', @@ -197,7 +197,12 @@ private function runComposerCommand(IOInterface $io, string $jsonPath, array $co '--no-interaction', ]); - $process = new Process($command); + $homePath = Craft::$app->getPath()->getRuntimePath() . DIRECTORY_SEPARATOR . 'composer'; + FileHelper::createDirectory($homePath); + + $process = new Process($command, null, [ + 'COMPOSER_HOME' => $homePath, + ]); $process->setTimeout(null); try { @@ -317,12 +322,38 @@ protected function updateRequirements(IOInterface $io, string $jsonPath, array $ } if ($config['config']['sort-packages'] ?? false) { - ksort($config['require']); + $this->sortPackages($config['require']); } $this->writeJson($jsonPath, $config); } + public function sortPackages(&$packages): void + { + // Adapted from JsonManipulator::sortPackages() + uksort($packages, fn($a, $b) => strnatcmp($this->prefixPackage($a), $this->prefixPackage($b))); + } + + private function prefixPackage(string $package): string + { + if (preg_match('/^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$/iD', $package)) { + $lower = strtolower($package); + if (str_starts_with($lower, 'php')) { + $group = '0'; + } elseif (str_starts_with($lower, 'hhvm')) { + $group = '1'; + } elseif (str_starts_with($lower, 'ext')) { + $group = '2'; + } elseif (str_starts_with($lower, 'lib')) { + $group = '3'; + } elseif (preg_match('/^\D/', $lower)) { + $group = '4'; + } + } + + return sprintf('%s-%s', $group ?? '5', $package); + } + /** * @param array $config * @return int|string|false The key in `$config['repositories']` referencing composer.craftcms.com diff --git a/src/services/Content.php b/src/services/Content.php index 76f9f5bbdc2..140ad6cff61 100644 --- a/src/services/Content.php +++ b/src/services/Content.php @@ -130,11 +130,14 @@ public function saveContent(ElementInterface $element): bool if (is_array($type)) { foreach (array_keys($type) as $i => $key) { $column = ElementHelper::fieldColumnFromField($field, $i !== 0 ? $key : null); - $values[$column] = Db::prepareValueForDb($value[$key] ?? null); + $values[$column] = Db::prepareValueForDb( + $value[$key] ?? null, + Db::parseColumnType($type[$key]), + ); } } else { $column = ElementHelper::fieldColumnFromField($field); - $values[$column] = Db::prepareValueForDb($value); + $values[$column] = Db::prepareValueForDb($value, Db::parseColumnType($type)); } } diff --git a/src/services/Drafts.php b/src/services/Drafts.php index 1b40f119b77..d8fddd5ed3e 100644 --- a/src/services/Drafts.php +++ b/src/services/Drafts.php @@ -261,6 +261,7 @@ public function applyDraft(ElementInterface $draft): ElementInterface /** @var DraftBehavior $behavior */ $behavior = $draft->getBehavior('draft'); $canonical = $draft->getCanonical(true); + $originalDraft = $draft; // If the canonical element ended up being from a different site than the draft, get the draft in that site if ($canonical->siteId != $draft->siteId) { @@ -341,6 +342,12 @@ public function applyDraft(ElementInterface $draft): ElementInterface ])); } + // if we were on another site when the applyDraft was triggered, + // ensure we return the canonical element for the site we were on + if ($newCanonical->siteId !== $originalDraft->siteId) { + $newCanonical = $originalDraft->getCanonical(); + } + return $newCanonical; } diff --git a/src/services/Elements.php b/src/services/Elements.php index 89f28e03f98..89ec42a4e6d 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -13,6 +13,7 @@ use craft\base\ElementExporterInterface; use craft\base\ElementInterface; use craft\base\ExpirableElementInterface; +use craft\base\FieldInterface; use craft\behaviors\DraftBehavior; use craft\behaviors\RevisionBehavior; use craft\db\Query; @@ -62,9 +63,7 @@ use craft\records\StructureElement as StructureElementRecord; use craft\validators\HandleValidator; use craft\validators\SlugValidator; -use craft\web\Application; use DateTime; -use Illuminate\Support\Collection; use Throwable; use UnitEnum; use yii\base\Behavior; @@ -184,6 +183,16 @@ class Elements extends Component */ public const EVENT_AFTER_SAVE_ELEMENT = 'afterSaveElement'; + /** + * @event ElementEvent The event that is triggered when setting a unique URI on an element. + * + * Event handlers must set `$event->handled` to `true` for their change to take effect. + * + * @see setElementUri() + * @since 4.6.0 + */ + public const EVENT_SET_ELEMENT_URI = 'setElementUri'; + /** * @event ElementEvent The event that is triggered before indexing an element’s search keywords, * or queuing the element’s search keywords to be updated. @@ -528,9 +537,9 @@ public function createElementQuery(string $elementType): ElementQueryInterface * Returns whether we are currently collecting element cache invalidation info. * * @return bool - * @since 4.3.0 * @see startCollectingCacheInfo() * @see stopCollectingCacheInfo() + * @since 4.3.0 */ public function getIsCollectingCacheInfo(): bool { @@ -1106,6 +1115,28 @@ public function saveElement( return $success; } + /** + * Sets the URI on an element. + * + * @param ElementInterface $element + * @throws OperationAbortedException if a unique URI could not be found + * @since 4.6.0 + */ + public function setElementUri(ElementInterface $element): void + { + if ($this->hasEventHandlers(self::EVENT_SET_ELEMENT_URI)) { + $event = new ElementEvent([ + 'element' => $element, + ]); + $this->trigger(self::EVENT_SET_ELEMENT_URI, $event); + if ($event->handled) { + return; + } + } + + ElementHelper::setUniqueUri($element); + } + /** * Merges recent canonical element changes into a given derivative, such as a draft. * @@ -1239,20 +1270,29 @@ public function updateCanonicalElement(ElementInterface $element, array $newAttr 'revisionId' => null, 'isProvisionalDraft' => false, 'updatingFromDerivative' => true, - 'dirtyAttributes' => Collection::make($changedAttributes) - ->where('siteId', $element->siteId) - ->pluck('attribute') - ->all(), - 'dirtyFields' => Collection::make($changedFields) - ->where('siteId', $element->siteId) - ->map(fn(array $field) => $fieldsService->getFieldById($field['fieldId'])?->handle) - ->filter() - ->all(), + 'dirtyAttributes' => [], + 'dirtyFields' => [], ]; + foreach ($changedAttributes as $attribute) { + $newAttributes['siteAttributes'][$attribute['siteId']]['dirtyAttributes'][] = $attribute['attribute']; + } + + foreach ($changedFields as $field) { + $newAttributes['siteAttributes'][$field['siteId']]['dirtyFields'][] = $fieldsService->getFieldById($field['fieldId'])?->handle; + } + + // if we're working with a revision, ensure we mark element's custom fields as dirty; + if ($element->getIsRevision()) { + $newAttributes['dirtyFields'] = array_map( + fn(FieldInterface $field) => $field->handle, + $element->getFieldLayout()?->getCustomFields() ?? [], + ); + } + $updatedCanonical = $this->duplicateElement($element, $newAttributes); - Craft::$app->on(Application::EVENT_AFTER_REQUEST, function() use ($canonical, $updatedCanonical, $changedAttributes, $changedFields) { + Craft::$app->onAfterRequest(function() use ($canonical, $updatedCanonical, $changedAttributes, $changedFields) { // Update change tracking for the canonical element $timestamp = Db::prepareDateForDb($updatedCanonical->dateUpdated); @@ -1486,7 +1526,8 @@ public function propagateElements(ElementQueryInterface $query, array|int $siteI * * @template T of ElementInterface * @param T $element the element to duplicate - * @param array $newAttributes any attributes to apply to the duplicate + * @param array $newAttributes any attributes to apply to the duplicate. This can contain a `siteAttributes` key, + * set to an array of site-specific attribute array, indexed by site IDs. * @param bool $placeInStructure whether to position the cloned element after the original one in its structure. * (This will only happen if the duplicated element is canonical.) * @param bool $trackDuplication whether to keep track of the duplication from [[Elements::$duplicatedElementIds]] @@ -1514,6 +1555,7 @@ public function duplicateElement( $mainClone = clone $element; $mainClone->id = null; $mainClone->uid = StringHelper::UUID(); + $mainClone->draftId = null; $mainClone->siteSettingsId = null; $mainClone->contentId = null; $mainClone->root = null; @@ -1529,9 +1571,15 @@ public function duplicateElement( $behaviors = ArrayHelper::remove($newAttributes, 'behaviors', []); $mainClone->setRevisionNotes(ArrayHelper::remove($newAttributes, 'revisionNotes')); + // Extract any attributes that are meant for other sites + $siteAttributes = ArrayHelper::remove($newAttributes, 'siteAttributes') ?? []; + // Note: must use Craft::configure() rather than setAttributes() here, // so we're not limited to whatever attributes() returns - Craft::configure($mainClone, $newAttributes); + Craft::configure($mainClone, ArrayHelper::merge( + $newAttributes, + $siteAttributes[$mainClone->siteId] ?? [], + )); // Attach behaviors foreach ($behaviors as $name => $behavior) { @@ -1662,15 +1710,20 @@ public function duplicateElement( // Note: must use Craft::configure() rather than setAttributes() here, // so we're not limited to whatever attributes() returns - Craft::configure($siteClone, $newAttributes); + Craft::configure($siteClone, ArrayHelper::merge( + $newAttributes, + $siteAttributes[$siteElement->siteId] ?? [], + )); $siteClone->siteId = $siteElement->siteId; - // Clone any field values that are objects + // Clone any field values that are objects (without affecting the dirty fields) + $dirtyFields = $siteClone->getDirtyFields(); foreach ($siteClone->getFieldValues() as $handle => $value) { if (is_object($value) && (!interface_exists(UnitEnum::class) || !$value instanceof UnitEnum)) { $siteClone->setFieldValue($handle, clone $value); } } + $siteClone->setDirtyFields($dirtyFields, false); if ($element::hasUris()) { // Make sure it has a valid slug @@ -1681,13 +1734,13 @@ public function duplicateElement( // Set a unique URI on the site clone try { - ElementHelper::setUniqueUri($siteClone); + $this->setElementUri($siteClone); } catch (OperationAbortedException) { // Oh well, not worth bailing over } } - if (!$this->_saveElementInternal($siteClone, false, false)) { + if (!$this->_saveElementInternal($siteClone, false, false, supportedSites: $supportedSites)) { throw new InvalidElementException($siteClone, "Element $element->id could not be duplicated for site $siteElement->siteId: " . implode(', ', $siteClone->getFirstErrors())); } @@ -1736,7 +1789,7 @@ public function updateElementSlugAndUri(ElementInterface $element, bool $updateO } if ($element::hasUris()) { - ElementHelper::setUniqueUri($element); + $this->setElementUri($element); } // Fire a 'beforeUpdateSlugAndUri' event @@ -2167,6 +2220,10 @@ public function deleteElementsForSite(array $elements): void } } + foreach ($multiSiteElements as $element) { + $element->beforeDeleteForSite(); + } + // Delete the rows in elements_sites Db::delete(Table::ELEMENTS_SITES, [ 'elementId' => $multiSiteElementIds, @@ -2185,6 +2242,10 @@ public function deleteElementsForSite(array $elements): void updateSearchIndex: false ); + foreach ($multiSiteElements as $element) { + $element->afterDeleteForSite(); + } + // Fire 'afterDeleteForSite' events if ($this->hasEventHandlers(self::EVENT_AFTER_DELETE_FOR_SITE)) { foreach ($multiSiteElements as $element) { @@ -2198,7 +2259,7 @@ public function deleteElementsForSite(array $elements): void // Fully delete any single-site elements if (!empty($singleSiteElements)) { foreach ($singleSiteElements as $element) { - $this->deleteElement($element); + $this->deleteElement($element, true); } } } @@ -3189,12 +3250,27 @@ private function _saveElementInternal( } } + $fieldLayout = $element->getFieldLayout(); + $dirtyFields = $element->getDirtyFields(); + // Validate - if ($runValidation && !$element->validate()) { - Craft::info('Element not saved due to validation error: ' . print_r($element->errors, true), __METHOD__); - $element->firstSave = $originalFirstSave; - $element->propagateAll = $originalPropagateAll; - return false; + if ($runValidation) { + // If we're propagating, only validate changed custom fields + if ($element->propagating) { + $names = array_map( + fn(string $handle) => "field:$handle", + array_unique(array_merge($dirtyFields, $element->getModifiedFields())) + ); + } else { + $names = null; + } + + if (($names === null || !empty($names)) && !$element->validate($names)) { + Craft::info('Element not saved due to validation error: ' . print_r($element->errors, true), __METHOD__); + $element->firstSave = $originalFirstSave; + $element->propagateAll = $originalPropagateAll; + return false; + } } // Figure out whether we will be updating the search index (and memoize that for nested element saves) @@ -3228,7 +3304,7 @@ private function _saveElementInternal( $elementRecord->canonicalId = $element->getIsDerivative() ? $element->getCanonicalId() : null; $elementRecord->draftId = (int)$element->draftId ?: null; $elementRecord->revisionId = (int)$element->revisionId ?: null; - $elementRecord->fieldLayoutId = $element->fieldLayoutId = (int)($element->fieldLayoutId ?? $element->getFieldLayout()->id ?? 0) ?: null; + $elementRecord->fieldLayoutId = $element->fieldLayoutId = (int)($element->fieldLayoutId ?? $fieldLayout?->id ?? 0) ?: null; $elementRecord->enabled = (bool)$element->enabled; $elementRecord->archived = (bool)$element->archived; $elementRecord->dateLastMerged = Db::prepareDateForDb($element->dateLastMerged); @@ -3348,6 +3424,9 @@ private function _saveElementInternal( // It is now officially saved $element->afterSave($isNewElement); + // Update the list of dirty attributes + $dirtyAttributes = $element->getDirtyAttributes(); + // Update the element across the other sites? if ($propagate) { $otherSiteIds = ArrayHelper::withoutValue(array_keys($supportedSites), $element->siteId); @@ -3367,7 +3446,13 @@ private function _saveElementInternal( // Skip the initial site if ($siteId != $element->siteId) { $siteElement = $siteElements[$siteId] ?? false; - if (!$this->_propagateElement($element, $supportedSites, $siteId, $siteElement, crossSiteValidate: $crossSiteValidate)) { + if (!$this->_propagateElement( + $element, + $supportedSites, + $siteId, + $siteElement, + crossSiteValidate: $runValidation && $crossSiteValidate, + )) { throw new InvalidConfigException(); } } @@ -3428,31 +3513,38 @@ private function _saveElementInternal( } // Update search index - if ($updateSearchIndex && !ElementHelper::isRevision($element)) { - $event = new ElementEvent([ - 'element' => $element, - ]); - $this->trigger(self::EVENT_BEFORE_UPDATE_SEARCH_INDEX, $event); - if ($event->isValid) { - if (Craft::$app->getRequest()->getIsConsoleRequest()) { - Craft::$app->getSearch()->indexElementAttributes($element); - } else { - Queue::push(new UpdateSearchIndex([ - 'elementType' => get_class($element), - 'elementId' => $element->id, - 'siteId' => $propagate ? '*' : $element->siteId, - 'fieldHandles' => $element->getDirtyFields(), - ]), 2048); + if ($updateSearchIndex && !$element->getIsRevision() && !ElementHelper::isRevision($element)) { + $searchableDirtyFields = array_filter( + $dirtyFields, + fn(string $handle) => $fieldLayout?->getFieldByHandle($handle)?->searchable, + ); + + if ( + !$trackChanges || + !empty($searchableDirtyFields) || + !empty(array_intersect($dirtyAttributes, ElementHelper::searchableAttributes($element))) + ) { + $event = new ElementEvent([ + 'element' => $element, + ]); + $this->trigger(self::EVENT_BEFORE_UPDATE_SEARCH_INDEX, $event); + if ($event->isValid) { + if (Craft::$app->getRequest()->getIsConsoleRequest()) { + Craft::$app->getSearch()->indexElementAttributes($element, $searchableDirtyFields); + } else { + Queue::push(new UpdateSearchIndex([ + 'elementType' => get_class($element), + 'elementId' => $element->id, + 'siteId' => $propagate ? '*' : $element->siteId, + 'fieldHandles' => $searchableDirtyFields, + ]), 2048); + } } } } // Update the changed attributes & fields if ($trackChanges) { - $dirtyAttributes = $element->getDirtyAttributes(); - $fieldLayout = $element->getFieldLayout(); - $dirtyFields = $fieldLayout ? $element->getDirtyFields() : null; - $userId = Craft::$app->getUser()->getId(); $timestamp = Db::prepareDateForDb(DateTimeHelper::now()); @@ -3547,6 +3639,7 @@ private function _propagateElement( $siteElement->siteId = $oldSiteElement->siteId; $siteElement->contentId = $oldSiteElement->contentId; $siteElement->setEnabledForSite($oldSiteElement->getEnabledForSite()); + $siteElement->uri = $oldSiteElement->uri; } else { $siteElement->enabled = $element->enabled; $siteElement->resaving = $element->resaving; @@ -3578,7 +3671,25 @@ private function _propagateElement( $siteElement->slug = $element->slug; } - // Copy the dirty attributes (except title and slug, which may be translatable) + // Ensure the uri is properly localized + // see https://github.com/craftcms/cms/issues/13812 for more details + if ( + $element::hasUris() && + ( + $isNewSiteForElement || + in_array('uri', $element->getDirtyAttributes()) || + $element->resaving + ) + ) { + // Set a unique URI on the site clone + try { + $this->setElementUri($siteElement); + } catch (OperationAbortedException) { + // carry on + } + } + + // Copy the dirty attributes (except title, slug and uri, which may be translatable) $siteElement->setDirtyAttributes(array_filter($element->getDirtyAttributes(), function(string $attribute): bool { return $attribute !== 'title' && $attribute !== 'slug'; })); @@ -3613,7 +3724,7 @@ private function _propagateElement( $siteElement->propagating = true; - if ($this->_saveElementInternal($siteElement, true, false, null, $supportedSites, crossSiteValidate: $crossSiteValidate) === false) { + if ($this->_saveElementInternal($siteElement, $crossSiteValidate, false, null, $supportedSites) === false) { // if the element we're trying to save has validation errors, notify original element about them if ($siteElement->hasErrors()) { return $this->_crossSiteValidationErrors($siteElement, $element); diff --git a/src/services/Fields.php b/src/services/Fields.php index 2e1989b7ddd..4844c01d587 100644 --- a/src/services/Fields.php +++ b/src/services/Fields.php @@ -19,6 +19,7 @@ use craft\db\Table; use craft\errors\MissingComponentException; use craft\events\ConfigEvent; +use craft\events\DefineCompatibleFieldTypesEvent; use craft\events\FieldEvent; use craft\events\FieldGroupEvent; use craft\events\FieldLayoutEvent; @@ -28,6 +29,7 @@ use craft\fields\Categories as CategoriesField; use craft\fields\Checkboxes; use craft\fields\Color; +use craft\fields\Country; use craft\fields\Date; use craft\fields\Dropdown; use craft\fields\Email; @@ -61,7 +63,6 @@ use craft\records\FieldLayout as FieldLayoutRecord; use craft\records\FieldLayoutField as FieldLayoutFieldRecord; use craft\records\FieldLayoutTab as FieldLayoutTabRecord; -use Illuminate\Support\Collection; use Throwable; use yii\base\Component; use yii\base\Exception; @@ -104,6 +105,13 @@ class Fields extends Component */ public const EVENT_REGISTER_FIELD_TYPES = 'registerFieldTypes'; + /** + * @event DefineCompatibleFieldTypesEvent The event that is triggered when defining the compatible field types for a field. + * @see getCompatibleFieldTypes() + * @since 4.5.7 + */ + public const EVENT_DEFINE_COMPATIBLE_FIELD_TYPES = 'defineCompatibleFieldTypes'; + /** * @event FieldGroupEvent The event that is triggered before a field group is saved. */ @@ -194,19 +202,10 @@ class Fields extends Component private ?MemoizableArray $_fields = null; /** - * @var FieldLayout[]|null[] - */ - private array $_layoutsById = []; - - /** - * @var FieldLayout[] + * @var MemoizableArray|null + * @see _layouts() */ - private array $_layoutsByType = []; - - /** - * @var FieldLayout[][] - */ - private array $_allLayoutsByType = []; + private ?MemoizableArray $_layouts = null; /** * @var array @@ -236,11 +235,7 @@ public function __serialize() private function _groups(): MemoizableArray { if (!isset($this->_groups)) { - $groups = []; - foreach ($this->_createGroupQuery()->all() as $result) { - $groups[] = new FieldGroup($result); - } - $this->_groups = new MemoizableArray($groups); + $this->_groups = new MemoizableArray($this->_createGroupQuery()->all(), fn(array $result) => new FieldGroup($result)); } return $this->_groups; @@ -464,6 +459,7 @@ public function getAllFieldTypes(): array CategoriesField::class, Checkboxes::class, Color::class, + Country::class, Date::class, Dropdown::class, Email::class, @@ -518,50 +514,48 @@ public function getFieldTypesWithContent(): array */ public function getCompatibleFieldTypes(FieldInterface $field, bool $includeCurrent = true): array { - if (!$field::hasContentColumn()) { - return $includeCurrent ? [get_class($field)] : []; - } + $types = []; - // If the field has any validation errors and has an ID, swap it with the saved field - if (!$field->getIsNew() && $field->hasErrors()) { - $field = $this->getFieldById($field->id); - } + if ($field::hasContentColumn()) { + // If the field has any validation errors and has an ID, swap it with the saved field + if (!$field->getIsNew() && $field->hasErrors()) { + $field = $this->getFieldById($field->id); + } - $fieldColumnType = $field->getContentColumnType(); + $fieldColumnType = $field->getContentColumnType(); - if (is_array($fieldColumnType)) { - return $includeCurrent ? [get_class($field)] : []; - } + if (is_array($fieldColumnType)) { + return $includeCurrent ? [get_class($field)] : []; + } - $types = []; + foreach ($this->getAllFieldTypes() as $class) { + /** @var string|FieldInterface $class */ + /** @phpstan-var class-string|FieldInterface $class */ + if ($class === get_class($field)) { + if ($includeCurrent) { + $types[] = $class; + } + continue; + } - foreach ($this->getAllFieldTypes() as $class) { - /** @var string|FieldInterface $class */ - /** @phpstan-var class-string|FieldInterface $class */ - if ($class === get_class($field)) { - if ($includeCurrent) { - $types[] = $class; + if (!$class::hasContentColumn()) { + continue; } - continue; - } - if (!$class::hasContentColumn()) { - continue; - } + /** @var FieldInterface $tempField */ + $tempField = new $class(); + $tempFieldColumnType = $tempField->getContentColumnType(); - /** @var FieldInterface $tempField */ - $tempField = new $class(); - $tempFieldColumnType = $tempField->getContentColumnType(); + if (is_array($tempFieldColumnType)) { + continue; + } - if (is_array($tempFieldColumnType)) { - continue; - } + if (!Db::areColumnTypesCompatible($fieldColumnType, $tempFieldColumnType)) { + continue; + } - if (!Db::areColumnTypesCompatible($fieldColumnType, $tempFieldColumnType)) { - continue; + $types[] = $class; } - - $types[] = $class; } // Make sure the current field class is in there if it's supposed to be @@ -569,6 +563,15 @@ public function getCompatibleFieldTypes(FieldInterface $field, bool $includeCurr $types[] = get_class($field); } + if ($this->hasEventHandlers(self::EVENT_DEFINE_COMPATIBLE_FIELD_TYPES)) { + $event = new DefineCompatibleFieldTypesEvent([ + 'field' => $field, + 'compatibleTypes' => $types, + ]); + $this->trigger(self::EVENT_DEFINE_COMPATIBLE_FIELD_TYPES, $event); + return $event->compatibleTypes; + } + return $types; } @@ -605,7 +608,7 @@ public function createField(mixed $config): FieldInterface } /** - * Returns a memoizable array of all fields. + * Returns a memoizable array of fields. * * @param string|string[]|false|null $context The field context(s) to fetch fields from. Defaults to [[\craft\services\Content::$fieldContext]]. * Set to `false` to get all fields regardless of context. @@ -614,22 +617,19 @@ public function createField(mixed $config): FieldInterface */ private function _fields(mixed $context = null): MemoizableArray { + $context ??= Craft::$app->getContent()->fieldContext; + if (!isset($this->_fields)) { - $fields = []; - foreach ($this->_createFieldQuery()->all() as $result) { - $fields[] = $this->createField($result); - } - $this->_fields = new MemoizableArray($fields); + $this->_fields = new MemoizableArray( + $this->_createFieldQuery()->all(), + fn(array $config) => $this->createField($config), + ); } if ($context === false) { return $this->_fields; } - if ($context === null) { - $context = Craft::$app->getContent()->fieldContext; - } - if (is_array($context)) { return $this->_fields->whereIn('context', $context, true); } @@ -1081,6 +1081,40 @@ public function refreshFields(): void // Layouts // ------------------------------------------------------------------------- + /** + * Returns a memoizable array of all field layouts. + * + * @return MemoizableArray + */ + private function _layouts(): MemoizableArray + { + if (!isset($this->_layouts)) { + if (Craft::$app->getIsInstalled()) { + $layoutConfigs = $this->_createLayoutQuery()->all(); + $layoutTabConfigs = ArrayHelper::index($this->_createLayoutTabQuery()->all(), null, ['layoutId']); + } else { + $layoutConfigs = []; + $layoutTabConfigs = []; + } + + $isMysql = Craft::$app->getDb()->getIsMysql(); + + $this->_layouts = new MemoizableArray($layoutConfigs, function($config) use (&$layoutTabConfigs, $isMysql) { + $layout = new FieldLayout($config); + /** @phpstan-ignore-next-line */ + $tabConfigs = ArrayHelper::remove($layoutTabConfigs, $layout->id) ?? []; + $tabs = array_map( + fn(array $row) => $this->_createLayoutTabFromRow(['layout' => $layout] + $row, $isMysql), + $tabConfigs + ); + $layout->setTabs($tabs); + return $layout; + }); + } + + return $this->_layouts; + } + /** * Returns a field layout by its ID. * @@ -1089,15 +1123,7 @@ public function refreshFields(): void */ public function getLayoutById(int $layoutId): ?FieldLayout { - if (array_key_exists($layoutId, $this->_layoutsById)) { - return $this->_layoutsById[$layoutId]; - } - - $result = $this->_createLayoutQuery() - ->andWhere(['id' => $layoutId]) - ->one(); - - return $this->_layoutsById[$layoutId] = $result ? new FieldLayout($result) : null; + return $this->_layouts()->firstWhere('id', $layoutId); } /** @@ -1109,33 +1135,7 @@ public function getLayoutById(int $layoutId): ?FieldLayout */ public function getLayoutsByIds(array $layoutIds): array { - $response = []; - - // Don't re-fetch any layouts we've already memoized - foreach ($layoutIds as $key => $id) { - if (array_key_exists($id, $this->_layoutsById)) { - if ($this->_layoutsById[$id] !== null) { - $response[$id] = $this->_layoutsById[$id]; - } - unset($layoutIds[$key]); - } - } - - if (!empty($layoutIds)) { - $result = $this->_createLayoutQuery() - ->andWhere(['id' => $layoutIds]) - ->all(); - - $layouts = []; - - foreach ($result as $row) { - $this->_layoutsById[$row['id']] = $response[$row['id']] = $layouts[$row['id']] = new FieldLayout($row); - } - - $this->_loadTabs($layouts); - } - - return $response; + return $this->_layouts()->whereIn('id', $layoutIds)->all(); } /** @@ -1147,27 +1147,8 @@ public function getLayoutsByIds(array $layoutIds): array */ public function getLayoutByType(string $type): FieldLayout { - if (!isset($this->_layoutsByType[$type])) { - if (Craft::$app->getIsInstalled()) { - $result = $this->_createLayoutQuery() - ->andWhere(['type' => $type]) - ->one(); - } - - if (isset($result)) { - if (!isset($this->_layoutsById[$result['id']])) { - $this->_layoutsById[$result['id']] = new FieldLayout($result); - } - - $this->_layoutsByType[$type] = $this->_layoutsById[$result['id']]; - } else { - $this->_layoutsByType[$type] = new FieldLayout([ - 'type' => $type, - ]); - } - } - - return $this->_layoutsByType[$type]; + return $this->_layouts()->firstWhere('type', $type) + ?? new FieldLayout(['type' => $type]); } /** @@ -1180,19 +1161,7 @@ public function getLayoutByType(string $type): FieldLayout */ public function getLayoutsByType(string $type): array { - if (!isset($this->_allLayoutsByType[$type])) { - $results = $this->_createLayoutQuery() - ->andWhere(['type' => $type]) - ->all(); - - $this->_allLayoutsByType[$type] = []; - - foreach ($results as $result) { - $this->_allLayoutsByType[$type][] = new FieldLayout($result); - } - } - - return $this->_allLayoutsByType[$type]; + return $this->_layouts()->where('type', $type)->all(); } /** @@ -1200,6 +1169,7 @@ public function getLayoutsByType(string $type): array * * @param int|int[] $layoutId The field layout’s ID(s) * @return FieldLayoutTab[] The field layout’s tabs + * @deprecated in 4.6.0 */ public function getLayoutTabsById(int|array $layoutId): array { @@ -1239,25 +1209,6 @@ private function _createLayoutTabFromRow(array $row, bool $isMysql): FieldLayout return new FieldLayoutTab($row); } - /** - * Fetches the layout tabs for the given layouts. - * - * @param FieldLayout[] $layouts Field layouts indexed by their IDs - */ - private function _loadTabs(array $layouts): void - { - if (empty($layouts)) { - return; - } - - $tabs = Collection::make($this->getLayoutTabsById(array_keys($layouts))); - - Collection::make($layouts) - ->each(function(FieldLayout $layout) use ($tabs) { - $layout->setTabs($tabs->where('layoutId', $layout->id)->all()); - }); - } - /** * Returns the field IDs grouped by layout IDs, for a given set of layout IDs. * @@ -1469,10 +1420,8 @@ public function saveLayout(FieldLayout $layout, bool $runValidation = true): boo ])); } - $this->_layoutsByType[$layout->type] = $this->_layoutsById[$layout->id] = $layout; - // Clear caches - unset($this->_allLayoutsByType[$layout->type]); + $this->_layouts = null; return true; } @@ -1526,9 +1475,7 @@ public function deleteLayout(FieldLayout $layout): bool } // Clear caches - unset($this->_layoutsById[$layout->id]); - unset($this->_layoutsByType[$layout->type]); - unset($this->_allLayoutsByType[$layout->type]); + $this->_layouts = null; return true; } @@ -1547,9 +1494,7 @@ public function deleteLayoutsByType(string $type): bool ->execute(); // Clear caches - $this->_layoutsById = []; - $this->_layoutsByType = []; - $this->_allLayoutsByType = []; + $this->_layouts = null; return (bool)$affectedRows; } @@ -1568,9 +1513,7 @@ public function restoreLayoutById(int $id): bool ->execute(); // Clear caches - $this->_layoutsById = []; - $this->_layoutsByType = []; - $this->_allLayoutsByType = []; + $this->_layouts = null; return (bool)$affectedRows; } @@ -1870,15 +1813,36 @@ private function _backupFieldColumn(Connection $db, string $table, string $colum // Find a unique backup table name $shortTableName = Db::rawTableShortName($table); + $schema = $db->getSchema(); + $prefix = "{$shortTableName}_$column"; $timestamp = time(); $n = 1; do { $suffix = $n === 1 ? '' : "_$n"; - $bakTable = "{{%{$shortTableName}_{$column}_bak_$timestamp$suffix}}"; + $bakTable = "{{%{$prefix}_bak_$timestamp$suffix}}"; + + // make sure it's not too long + $length = strlen($schema->getRawTableName($bakTable)); + if ($length > $schema->maxObjectNameLength) { + $overage = $length - $schema->maxObjectNameLength; + $prefixParts = explode('_', $prefix); + $removed = 0; + for ($i = 0; true; $i++) { + $partIndex = $i % count($prefixParts); + if (strlen($prefixParts[$partIndex]) > 1) { + $prefixParts[$partIndex] = substr($prefixParts[$partIndex], 0, -1); + $removed++; + if ($removed === $overage) { + break; + } + } + } + $shortenedPrefix = implode('_', $prefixParts); + $bakTable = "{{%{$shortenedPrefix}_bak_$timestamp$suffix}}"; + } $n++; } while ($db->tableExists($bakTable)); - $schema = $db->getSchema(); $columnSchema = $schema->getTableSchema($table)->getColumn($column); $db->createCommand() diff --git a/src/services/Fs.php b/src/services/Fs.php index 0ca245256fb..35ae5e7da1d 100644 --- a/src/services/Fs.php +++ b/src/services/Fs.php @@ -117,12 +117,12 @@ private function _filesystems(): MemoizableArray { if (!isset($this->_filesystems)) { $configs = Craft::$app->getProjectConfig()->get(ProjectConfig::PATH_FS) ?? []; - $filesystems = array_map(function(string $handle, array $config) { + $configs = array_map(function(string $handle, array $config) { $config['handle'] = $handle; - $config['settings'] = ProjectConfigHelper::unpackAssociativeArrays($config['settings']); - return $this->createFilesystem($config); + $config['settings'] = ProjectConfigHelper::unpackAssociativeArrays($config['settings'] ?? []); + return $config; }, array_keys($configs), $configs); - $this->_filesystems = new MemoizableArray($filesystems); + $this->_filesystems = new MemoizableArray($configs, fn(array $config) => $this->createFilesystem($config)); } return $this->_filesystems; diff --git a/src/services/Globals.php b/src/services/Globals.php index f98932dbf02..2228d4b8aff 100644 --- a/src/services/Globals.php +++ b/src/services/Globals.php @@ -85,7 +85,7 @@ public function __serialize() */ public function getAllSetIds(): array { - return ArrayHelper::getColumn($this->getAllSets(), 'id'); + return array_map(fn(GlobalSet $globalSet) => $globalSet->id, $this->getAllSets()); } /** @@ -104,7 +104,7 @@ public function getAllSetIds(): array */ public function getEditableSetIds(): array { - return ArrayHelper::getColumn($this->getEditableSets(), 'id'); + return array_map(fn(GlobalSet $globalSet) => $globalSet->id, $this->getEditableSets()); } /** diff --git a/src/services/Gql.php b/src/services/Gql.php index 6b495643037..11826de9e5f 100644 --- a/src/services/Gql.php +++ b/src/services/Gql.php @@ -30,7 +30,9 @@ use craft\gql\directives\Markdown; use craft\gql\directives\Money; use craft\gql\directives\ParseRefs; +use craft\gql\directives\StripTags; use craft\gql\directives\Transform; +use craft\gql\directives\Trim; use craft\gql\ElementQueryConditionBuilder; use craft\gql\GqlEntityRegistry; use craft\gql\interfaces\Element as ElementInterface; @@ -395,7 +397,7 @@ public function getSchemaDef(?GqlSchema $schema = null, bool $prebuildSchema = f $this->_schemaDef = new Schema($schemaConfig); $this->_schemaDef->getTypeMap(); } catch (Throwable $exception) { - throw new GqlException('Failed to validate the GQL Schema - ' . $exception->getMessage()); + throw new GqlException('Failed to validate the GQL Schema - ' . $exception->getMessage(), previous: $exception); } } @@ -497,7 +499,7 @@ public function executeQuery( $event->result = $cachedResult; } else { $isIntrospectionQuery = StringHelper::containsAny($event->query, ['__schema', '__type']); - $schemaDef = $this->getSchemaDef($schema, $debugMode || $isIntrospectionQuery); + $schemaDef = $this->getSchemaDef($schema, true); $elementsService = Craft::$app->getElements(); $elementsService->startCollectingCacheInfo(); @@ -1387,6 +1389,8 @@ private function _loadGqlDirectives(): array Markdown::class, Money::class, ParseRefs::class, + StripTags::class, + Trim::class, ]; if (!Craft::$app->getConfig()->getGeneral()->disableGraphqlTransformDirective) { diff --git a/src/services/ImageTransforms.php b/src/services/ImageTransforms.php index 17d336bd2ae..dc662971a9a 100644 --- a/src/services/ImageTransforms.php +++ b/src/services/ImageTransforms.php @@ -126,11 +126,10 @@ public function init(): void private function _transforms(): MemoizableArray { if (!isset($this->_transforms)) { - $transforms = []; - foreach ($this->_createTransformQuery()->all() as $result) { - $transforms[] = new ImageTransform($result); - } - $this->_transforms = new MemoizableArray($transforms); + $this->_transforms = new MemoizableArray( + $this->_createTransformQuery()->all(), + fn(array $result) => new ImageTransform($result), + ); } return $this->_transforms; diff --git a/src/services/Matrix.php b/src/services/Matrix.php index 912b299ac43..4d1c7ddd4e1 100644 --- a/src/services/Matrix.php +++ b/src/services/Matrix.php @@ -755,7 +755,10 @@ public function saveField(MatrixField $field, ElementInterface $owner): void ($owner->propagateAll || !empty($owner->newSiteIds)) ) { // Find the owner's site IDs that *aren't* supported by this site's Matrix blocks - $ownerSiteIds = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($owner), 'siteId'); + $ownerSiteIds = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($owner), + ); $fieldSiteIds = $this->getSupportedSiteIds($field->propagationMethod, $owner, $field->propagationKeyFormat); $otherSiteIds = array_diff($ownerSiteIds, $fieldSiteIds); @@ -879,11 +882,15 @@ public function duplicateBlocks( $transaction = Craft::$app->getDb()->beginTransaction(); try { + $setCanonicalId = $target->getIsDerivative() && $target->getCanonical()->id !== $target->id; + /** @var MatrixBlock[] $blocks */ foreach ($blocks as $block) { $newAttributes = [ // Only set the canonicalId if the target owner element is a derivative - 'canonicalId' => $target->getIsDerivative() ? $block->id : null, + // and if the target's canonical element is not the same as target element, see + // https://app.frontapp.com/open/msg_ukaoki1?key=U6zkE_S6_ApMXn3ntPMwUxSLe0sUPsmY for more info + 'canonicalId' => $setCanonicalId ? $block->id : null, 'primaryOwnerId' => $target->id, 'owner' => $target, 'siteId' => $target->siteId, @@ -932,7 +939,10 @@ public function duplicateBlocks( // Duplicate blocks for other sites as well? if ($checkOtherSites && $field->propagationMethod !== MatrixField::PROPAGATION_METHOD_ALL) { // Find the target's site IDs that *aren't* supported by this site's Matrix blocks - $targetSiteIds = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($target), 'siteId'); + $targetSiteIds = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($target), + ); $fieldSiteIds = $this->getSupportedSiteIds($field->propagationMethod, $target, $field->propagationKeyFormat); $otherSiteIds = array_diff($targetSiteIds, $fieldSiteIds); @@ -1024,7 +1034,10 @@ public function duplicateOwnership(MatrixField $field, ElementInterface $canonic public function createRevisionBlocks(MatrixField $field, ElementInterface $canonical, ElementInterface $revision): void { // Only fetch blocks in the sites the owner element supports - $siteIds = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($canonical), 'siteId'); + $siteIds = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($canonical), + ); /** @var MatrixBlock[] $blocks */ $blocks = MatrixBlock::find() @@ -1158,7 +1171,10 @@ public function getSupportedSiteIds(string $propagationMethod, ElementInterface { /** @var Site[] $allSites */ $allSites = ArrayHelper::index(Craft::$app->getSites()->getAllSites(), 'id'); - $ownerSiteIds = ArrayHelper::getColumn(ElementHelper::supportedSitesForElement($owner), 'siteId'); + $ownerSiteIds = array_map( + fn(array $siteInfo) => $siteInfo['siteId'], + ElementHelper::supportedSitesForElement($owner), + ); $siteIds = []; $view = Craft::$app->getView(); diff --git a/src/services/ProjectConfig.php b/src/services/ProjectConfig.php index d82307f2bf1..8096d03e719 100644 --- a/src/services/ProjectConfig.php +++ b/src/services/ProjectConfig.php @@ -96,9 +96,9 @@ class ProjectConfig extends Component public const ASSOC_KEY = '__assoc__'; /** - * @since 3.7.35 * @see _acquireLock() * @see _releaseLock() + * @since 3.7.35 */ public const MUTEX_NAME = 'project-config'; @@ -715,8 +715,10 @@ public function updateParsedConfigTimesAfterRequest(): void return; } - Craft::$app->on(Application::EVENT_AFTER_REQUEST, [$this, 'updateParsedConfigTimes']); $this->_waitingToUpdateParsedConfigTimes = true; + Craft::$app->onAfterRequest(function() { + $this->updateParsedConfigTimes(); + }); } /** @@ -1652,8 +1654,10 @@ public function removeNameMapping(string $uid): void private function setNameMappingInternal(string $uid, ?string $name): void { - // call _setInternal() so we avoid recursive calls to _saveConfigAfterRequest() via set() - $this->_setInternal(sprintf('%s.%s', self::PATH_META_NAMES, $uid), $name, updateTimestamp: false); + if (!$this->readOnly) { + // call _setInternal() so we avoid recursive calls to _saveConfigAfterRequest() via set() + $this->_setInternal(sprintf('%s.%s', self::PATH_META_NAMES, $uid), $name, updateTimestamp: false); + } } /** diff --git a/src/services/Revisions.php b/src/services/Revisions.php index 3e6d9940f38..07c536a1f02 100644 --- a/src/services/Revisions.php +++ b/src/services/Revisions.php @@ -13,6 +13,7 @@ use craft\db\Query; use craft\db\Table; use craft\errors\InvalidElementException; +use craft\errors\MutexException; use craft\events\RevisionEvent; use craft\helpers\ArrayHelper; use craft\helpers\DateTimeHelper; @@ -21,7 +22,6 @@ use craft\queue\jobs\PruneRevisions; use Throwable; use yii\base\Component; -use yii\base\Exception; use yii\base\InvalidArgumentException; /** @@ -77,7 +77,7 @@ public function createRevision(ElementInterface $canonical, ?int $creatorId = nu $lockKey = 'revision:' . $canonical->id; $mutex = Craft::$app->getMutex(); if (!$mutex->acquire($lockKey, 3)) { - throw new Exception('Could not acquire a lock to save a revision for element ' . $canonical->id); + throw new MutexException($lockKey, sprintf('Could not acquire a lock to save a revision for element %s', $canonical->id)); } $db = Craft::$app->getDb(); diff --git a/src/services/Search.php b/src/services/Search.php index 2fe455a3da6..6684ac90028 100644 --- a/src/services/Search.php +++ b/src/services/Search.php @@ -19,9 +19,9 @@ use craft\events\SearchEvent; use craft\helpers\ArrayHelper; use craft\helpers\Db; +use craft\helpers\ElementHelper; use craft\helpers\Search as SearchHelper; use craft\helpers\StringHelper; -use craft\models\Site; use craft\search\SearchQuery; use craft\search\SearchQueryTerm; use craft\search\SearchQueryTermGroup; @@ -178,12 +178,7 @@ public function indexElementAttributes(ElementInterface $element, ?array $fieldH Db::delete(Table::SEARCHINDEX, $deleteCondition); // Update the element attributes' keywords - $searchableAttributes = array_flip($element::searchableAttributes()); - $searchableAttributes['slug'] = true; - if ($element::hasTitles()) { - $searchableAttributes['title'] = true; - } - foreach (array_keys($searchableAttributes) as $attribute) { + foreach (ElementHelper::searchableAttributes($element) as $attribute) { $value = $element->getSearchKeywords($attribute); $this->_indexKeywords($element, $value, attribute: $attribute); } @@ -205,7 +200,7 @@ public function indexElementAttributes(ElementInterface $element, ?array $fieldH * Searches for elements that match the given element query. * * @param ElementQuery $elementQuery The element query being executed - * @return int[] The filtered list of element IDs. + * @return array Element ID and score mapping, with scores descending * @phpstan-return array * @since 3.7.14 */ @@ -214,6 +209,7 @@ public function searchElements(ElementQuery $elementQuery): array $searchQuery = $this->normalizeSearchQuery($elementQuery->search); $elementQuery = (clone $elementQuery) + ->select('elements.id') ->search(null) ->offset(null) ->limit(null); @@ -227,6 +223,57 @@ public function searchElements(ElementQuery $elementQuery): array ])); } + // Execute the sql + $query = $this->createDbQuery($searchQuery, $elementQuery); + + if ($query === false) { + return []; + } + + $results = $query + ->andWhere(['elementId' => $elementQuery]) + ->cache(true, new ElementQueryTagDependency($elementQuery)) + ->all(); + + // Score the results + $scores = $this->_scoreResults($results, $searchQuery, $elementQuery); + + // Fire an 'afterSearch' event + if ($this->hasEventHandlers(self::EVENT_AFTER_SEARCH)) { + $event = new SearchEvent([ + 'elementQuery' => $elementQuery, + 'query' => $searchQuery, + 'siteId' => $elementQuery->siteId, + 'results' => $results, + 'scores' => $scores, + ]); + $this->trigger(self::EVENT_AFTER_SEARCH, $event); + + // Use the scores from the event, in case any last minute changes were made to it + if ($event->scores !== null) { + $scores = $event->scores; + } + } + + // Sort by element ID ascending, then score descending + ksort($scores); + arsort($scores); + + return $scores; + } + + /** + * Returns a database query which will fetch results for a given search query. + * + * @param string|array|SearchQuery $searchQuery The search term to filter the resulting elements by. + * @param ElementQuery $elementQuery The element query being executed + * @return Query|false + * @since 4.6.0 + */ + public function createDbQuery(string|array|SearchQuery $searchQuery, ElementQuery $elementQuery): Query|false + { + $searchQuery = $this->normalizeSearchQuery($searchQuery); + // Get tokens for query $this->_terms = []; $this->_groups = []; @@ -250,27 +297,22 @@ public function searchElements(ElementQuery $elementQuery): array $where = $this->_getWhereClause($elementQuery->siteId, $customFields); if (empty($where)) { - return []; + return false; } $query = (new Query()) ->from([Table::SEARCHINDEX]) - ->where(new Expression($where)) - ->andWhere([ - 'elementId' => $elementQuery->select(['elements.id']), - ]) - ->cache(true, new ElementQueryTagDependency($elementQuery)); + ->where(new Expression($where)); if ($elementQuery->siteId !== null) { $query->andWhere(['siteId' => $elementQuery->siteId]); } - // Execute the sql - $results = $query->all(); - - // Score the results - $scores = null; + return $query; + } + private function _scoreResults(array $results, SearchQuery $searchQuery, ElementQuery $elementQuery): array + { // Fire a 'beforeScoreResults' event if ($this->hasEventHandlers(self::EVENT_BEFORE_SCORE_RESULTS)) { $event = new SearchEvent([ @@ -281,47 +323,27 @@ public function searchElements(ElementQuery $elementQuery): array ]); $this->trigger(self::EVENT_BEFORE_SCORE_RESULTS, $event); + if ($event->scores !== null) { + return $event->scores; + } + // Use whatever changes may have been made to the results (unless it was set to null for some reason) if ($event->results !== null) { $results = $event->results; } - - // If a handler set the scores, use that instead of figuring it out for ourselves. - $scores = $event->scores; - } - - if ($scores === null) { - $scores = []; - - // Loop through results and calculate score per element - foreach ($results as $row) { - $elementId = $row['elementId']; - $score = $this->_scoreRow($row, $elementQuery->siteId); - - if (!isset($scores[$elementId])) { - $scores[$elementId] = $score; - } else { - $scores[$elementId] += $score; - } - } } - arsort($scores); + $scores = []; - // Fire an 'afterSearch' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SEARCH)) { - $event = new SearchEvent([ - 'elementQuery' => $elementQuery, - 'query' => $searchQuery, - 'siteId' => $elementQuery->siteId, - 'results' => $results, - 'scores' => $scores, - ]); - $this->trigger(self::EVENT_AFTER_SEARCH, $event); + // Loop through results and calculate score per element + foreach ($results as $row) { + $elementId = $row['elementId']; + $score = $this->_scoreRow($row, $elementQuery->siteId); - // Return the scores from the event, in case any last minute changes were made to it - if ($event->scores !== null) { - return $event->scores; + if (!isset($scores[$elementId])) { + $scores[$elementId] = $score; + } else { + $scores[$elementId] += $score; } } @@ -780,7 +802,7 @@ private function _normalizeTerm(string $term, array|int|null $siteId = null): st } /** - * Get the fieldId for given attribute or 0 for unmatched. + * Get the fieldId for given attribute or `null` for unmatched. * * @param string $attribute * @param MemoizableArray|null $customFields @@ -789,7 +811,10 @@ private function _normalizeTerm(string $term, array|int|null $siteId = null): st private function _getFieldIdFromAttribute(string $attribute, ?MemoizableArray $customFields): array|int|null { if ($customFields !== null) { - return ArrayHelper::getColumn($customFields->where('handle', $attribute)->all(), 'id'); + return array_map( + fn(FieldInterface $field) => $field->id, + $customFields->where('handle', $attribute)->all(), + ); } $field = Craft::$app->getFields()->getFieldByHandle($attribute); diff --git a/src/services/Sections.php b/src/services/Sections.php index b407c9ae499..26df3643106 100644 --- a/src/services/Sections.php +++ b/src/services/Sections.php @@ -164,7 +164,7 @@ public function __serialize() */ public function getAllSectionIds(): array { - return ArrayHelper::getColumn($this->getAllSections(), 'id', false); + return array_values(array_map(fn(Section $section) => $section->id, $this->getAllSections())); } /** @@ -183,7 +183,7 @@ public function getAllSectionIds(): array */ public function getEditableSectionIds(): array { - return ArrayHelper::getColumn($this->getEditableSections(), 'id', false); + return array_values(array_map(fn(Section $section) => $section->id, $this->getEditableSections())); } /** @@ -194,34 +194,35 @@ public function getEditableSectionIds(): array private function _sections(): MemoizableArray { if (!isset($this->_sections)) { - $sections = []; + $results = $this->_createSectionQuery()->all(); + $siteSettingsBySection = []; - foreach ($this->_createSectionQuery()->all() as $result) { + if (!empty($results) && Craft::$app->getRequest()->getIsCpRequest()) { + // Eager load the site settings + $sectionIds = array_map(fn(array $result) => $result['id'], $results); + $siteSettingsBySection = ArrayHelper::index( + $this->_createSectionSiteSettingsQuery()->where(['sections_sites.sectionId' => $sectionIds])->all(), + null, + ['sectionId'], + ); + } + + $this->_sections = new MemoizableArray($results, function(array $result) use (&$siteSettingsBySection) { if (!empty($result['previewTargets'])) { $result['previewTargets'] = Json::decode($result['previewTargets']); } else { $result['previewTargets'] = []; } - $sections[$result['id']] = new Section($result); - } - - $this->_sections = new MemoizableArray(array_values($sections)); - - if (!empty($sections) && Craft::$app->getRequest()->getIsCpRequest()) { - // Eager load the site settings - $allSiteSettings = $this->_createSectionSiteSettingsQuery() - ->where(['sections_sites.sectionId' => array_keys($sections)]) - ->all(); - - $siteSettingsBySection = []; - foreach ($allSiteSettings as $siteSettings) { - $siteSettingsBySection[$siteSettings['sectionId']][] = new Section_SiteSettings($siteSettings); + $section = new Section($result); + /** @phpstan-ignore-next-line */ + $siteSettings = ArrayHelper::remove($siteSettingsBySection, $section->id); + if ($siteSettings !== null) { + $section->setSiteSettings( + array_map(fn(array $config) => new Section_SiteSettings($config), $siteSettings), + ); } - - foreach ($siteSettingsBySection as $sectionId => $sectionSiteSettings) { - $sections[$sectionId]->setSiteSettings($sectionSiteSettings); - } - } + return $section; + }); } return $this->_sections; @@ -983,22 +984,10 @@ public function getEntryTypesBySectionId(int $sectionId): array private function _entryTypes(): MemoizableArray { if (!isset($this->_entryTypes)) { - $entryTypes = []; - foreach ($this->_createEntryTypeQuery()->all() as $result) { - $entryTypes[] = new EntryType($result); - } - $this->_entryTypes = new MemoizableArray($entryTypes); - - if (!empty($entryTypes) && Craft::$app->getRequest()->getIsCpRequest()) { - // Eager load the field layouts - /** @var EntryType[] $entryTypesByLayoutId */ - $entryTypesByLayoutId = ArrayHelper::index($entryTypes, 'fieldLayoutId'); - $allLayouts = Craft::$app->getFields()->getLayoutsByIds(array_filter(array_keys($entryTypesByLayoutId))); - - foreach ($allLayouts as $layout) { - $entryTypesByLayoutId[$layout->id]->setFieldLayout($layout); - } - } + $this->_entryTypes = new MemoizableArray( + $this->_createEntryTypeQuery()->all(), + fn(array $result) => new EntryType($result), + ); } return $this->_entryTypes; @@ -1504,12 +1493,15 @@ private function _ensureSingleEntry(Section $section, ?array $siteSettings = nul return isset($siteSettings[$site->uid]); }, true, true, false); - $siteIds = ArrayHelper::getColumn($sites, 'id'); + $siteIds = array_map(fn(Site $site) => $site->id, $sites); // Get the section's entry types // --------------------------------------------------------------------- - $entryTypeIds = ArrayHelper::getColumn($this->getEntryTypesBySectionId($section->id), 'id', false); + $entryTypeIds = array_values(array_map( + fn(EntryType $entryType) => $entryType->id, + $this->getEntryTypesBySectionId($section->id), + )); // There should always be at least one entry type by the time this is called if (empty($entryTypeIds)) { diff --git a/src/services/Security.php b/src/services/Security.php index 0aa0c901e9d..df065c27f2b 100644 --- a/src/services/Security.php +++ b/src/services/Security.php @@ -40,7 +40,14 @@ class Security extends \yii\base\Security public function init(): void { parent::init(); + $this->_blowFishHashCost = Craft::$app->getConfig()->getGeneral()->blowfishHashCost; + + // normalize the sensitive keywords + $this->sensitiveKeywords = array_map( + fn(string $word) => Inflector::camel2words($word, false), + $this->sensitiveKeywords, + ); } /** diff --git a/src/services/Sites.php b/src/services/Sites.php index 0b260fe4883..d09bf8692ce 100644 --- a/src/services/Sites.php +++ b/src/services/Sites.php @@ -194,11 +194,10 @@ public function init(): void private function _groups(): MemoizableArray { if (!isset($this->_groups)) { - $groups = []; - foreach ($this->_createGroupQuery()->all() as $result) { - $groups[] = new SiteGroup($result); - } - $this->_groups = new MemoizableArray($groups); + $this->_groups = new MemoizableArray( + $this->_createGroupQuery()->all(), + fn(array $result) => new SiteGroup($result), + ); } return $this->_groups; @@ -414,7 +413,7 @@ public function deleteGroup(SiteGroup $group): bool */ public function getAllSiteIds(?bool $withDisabled = null): array { - return ArrayHelper::getColumn($this->_allSites($withDisabled), 'id', false); + return array_values(array_map(fn(Site $site) => $site->id, $this->_allSites($withDisabled))); } /** @@ -1164,7 +1163,7 @@ private function _loadAllSites(): void ->innerJoin(['sg' => Table::SITEGROUPS], '[[sg.id]] = [[s.groupId]]') ->where(['s.dateDeleted' => null]) ->andWhere(['sg.dateDeleted' => null]) - ->orderBy(['sg.name' => SORT_ASC, 's.sortOrder' => SORT_ASC]) + ->orderBy(['sg.name' => SORT_ASC, 's.sortOrder' => SORT_ASC, 's.id' => SORT_ASC]) ->all(); // Check for results because during installation, the transaction hasn't been committed yet. diff --git a/src/services/Structures.php b/src/services/Structures.php index 404b21c482f..5bd72941b72 100644 --- a/src/services/Structures.php +++ b/src/services/Structures.php @@ -12,6 +12,7 @@ use craft\base\ElementInterface; use craft\db\Query; use craft\db\Table; +use craft\errors\MutexException; use craft\errors\StructureNotFoundException; use craft\events\MoveElementEvent; use craft\models\Structure; @@ -503,7 +504,7 @@ private function _doIt(int $structureId, ElementInterface $element, StructureEle $lockName = 'structure:' . $structureId; $mutex = Craft::$app->getMutex(); if (!$mutex->acquire($lockName, $this->mutexTimeout)) { - throw new Exception('Unable to acquire a lock for the structure ' . $structureId); + throw new MutexException($lockName, sprintf('Unable to acquire a lock for the structure %s', $structureId)); } $elementRecord = null; diff --git a/src/services/Tags.php b/src/services/Tags.php index 5f516c807d9..949ea57d849 100644 --- a/src/services/Tags.php +++ b/src/services/Tags.php @@ -14,7 +14,6 @@ use craft\errors\TagGroupNotFoundException; use craft\events\ConfigEvent; use craft\events\TagGroupEvent; -use craft\helpers\ArrayHelper; use craft\helpers\Db; use craft\helpers\ProjectConfig as ProjectConfigHelper; use craft\helpers\StringHelper; @@ -89,7 +88,7 @@ public function __serialize() */ public function getAllTagGroupIds(): array { - return ArrayHelper::getColumn($this->getAllTagGroups(), 'id'); + return array_map(fn(TagGroup $group) => $group->id, $this->getAllTagGroups()); } /** @@ -100,17 +99,14 @@ public function getAllTagGroupIds(): array private function _tagGroups(): MemoizableArray { if (!isset($this->_tagGroups)) { - $groups = []; - /** @var TagGroupRecord[] $records */ $records = TagGroupRecord::find() ->orderBy(['name' => SORT_ASC]) ->all(); - foreach ($records as $record) { - $groups[] = $this->_createTagGroupFromRecord($record); - } - - $this->_tagGroups = new MemoizableArray($groups); + $this->_tagGroups = new MemoizableArray( + $records, + fn(TagGroupRecord $record) => $this->_createTagGroupFromRecord($record), + ); } return $this->_tagGroups; diff --git a/src/services/TemplateCaches.php b/src/services/TemplateCaches.php index f8391f03784..b8b836252cc 100644 --- a/src/services/TemplateCaches.php +++ b/src/services/TemplateCaches.php @@ -69,7 +69,7 @@ public function getTemplateCache(string $key, bool $global, bool $registerResour return null; } - [$body, $cacheInfo, $bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles, $bufferedHtml] = array_pad($data, 8, null); + [$body, $cacheInfo, $bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles, $bufferedHtml, $bufferedMetaTags] = array_pad($data, 9, null); // If we're actively collecting element cache info, register this cache's tags and duration $elementsService = Craft::$app->getElements(); @@ -82,7 +82,7 @@ public function getTemplateCache(string $key, bool $global, bool $registerResour } } - // Register JS and CSS tags + // Register JS, CSS and meta tags if ($registerResources) { $this->_registerResources( $bufferedJs ?? [], @@ -90,7 +90,8 @@ public function getTemplateCache(string $key, bool $global, bool $registerResour $bufferedCss ?? [], $bufferedJsFiles ?? [], $bufferedCssFiles ?? [], - $bufferedHtml ?? [] + $bufferedHtml ?? [], + $bufferedMetaTags ?? [] ); } @@ -124,6 +125,7 @@ public function startTemplateCache(bool $withResources = false, bool $global = f $view->startJsFileBuffer(); $view->startCssFileBuffer(); $view->startHtmlBuffer(); + $view->startMetaTagBuffer(); } } @@ -159,6 +161,7 @@ public function endTemplateCache(string $key, bool $global, ?string $duration, m $bufferedJsFiles = $view->clearJsFileBuffer(); $bufferedCssFiles = $view->clearCssFileBuffer(); $bufferedHtml = $view->clearHtmlBuffer(); + $bufferedMetaTags = $view->clearMetaTagBuffer(); } // If there are any transform generation URLs in the body, don't cache it. @@ -192,13 +195,14 @@ public function endTemplateCache(string $key, bool $global, ?string $duration, m $bufferedCss = $this->_parseInlineResourceTags($bufferedCss); $bufferedJsFiles = array_map(fn(array $tags) => $this->_parseExternalResourceTags($tags, 'src'), $bufferedJsFiles); $bufferedCssFiles = $this->_parseExternalResourceTags($bufferedCssFiles, 'href'); + $bufferedMetaTags = $this->_parseSelfClosingTags($bufferedMetaTags); if ($saveCache) { - array_push($cacheValue, $bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles, $bufferedHtml); + array_push($cacheValue, $bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles, $bufferedHtml, $bufferedMetaTags); } // Re-register the JS and CSS - $this->_registerResources($bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles, $bufferedHtml); + $this->_registerResources($bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles, $bufferedHtml, $bufferedMetaTags); } if (!$saveCache) { @@ -235,6 +239,20 @@ private function _parseInlineResourceTags(array $tags): array }, $tags); } + /** + * Parse each tag and return an array of its attributes + * where the key is the name of the attribute and the value is its value. + * + * @param array $tags + * @return array + */ + private function _parseSelfClosingTags(array $tags): array + { + return array_map(function($tag) { + return Html::parseTagAttributes($tag); + }, $tags); + } + private function _parseExternalResourceTags(array $tags, string $urlAttribute): array { return array_map(function($tag) use ($urlAttribute) { @@ -260,6 +278,7 @@ private function _registerResources( array $bufferedJsFiles, array $bufferedCssFiles, array $bufferedHtml, + array $bufferedMetaTags, ): void { $view = Craft::$app->getView(); @@ -295,6 +314,10 @@ private function _registerResources( $view->registerHtml($html, $pos, $key); } } + + foreach ($bufferedMetaTags as $key => $options) { + $view->registerMetaTag($options, $key); + } } /** diff --git a/src/services/UserGroups.php b/src/services/UserGroups.php index f68bb2bfff6..42f1f993bdd 100644 --- a/src/services/UserGroups.php +++ b/src/services/UserGroups.php @@ -14,7 +14,6 @@ use craft\errors\WrongEditionException; use craft\events\ConfigEvent; use craft\events\UserGroupEvent; -use craft\helpers\ArrayHelper; use craft\helpers\Db; use craft\helpers\StringHelper; use craft\models\UserGroup; @@ -199,7 +198,7 @@ public function eagerLoadGroups(array $users): void ->select(['groupId', 'userId']) ->from([Table::USERGROUPS_USERS]) ->where([ - 'userId' => array_unique(ArrayHelper::getColumn($users, 'id')), + 'userId' => array_unique(array_map(fn(User $user) => $user->id, $users)), ]) ->all(); @@ -210,7 +209,7 @@ public function eagerLoadGroups(array $users): void $groups = []; $groupResults = $this->_createUserGroupsQuery() ->where([ - 'id' => array_unique(ArrayHelper::getColumn($assignments, 'groupId')), + 'id' => array_unique(array_map(fn(array $assignment) => $assignment['groupId'], $assignments)), ]) ->all(); foreach ($groupResults as $result) { diff --git a/src/services/Users.php b/src/services/Users.php index 9d9aaf9b47f..9209fa4133e 100644 --- a/src/services/Users.php +++ b/src/services/Users.php @@ -18,6 +18,7 @@ use craft\errors\UserNotFoundException; use craft\errors\VolumeException; use craft\events\ConfigEvent; +use craft\events\DefineUserGroupsEvent; use craft\events\UserAssignGroupEvent; use craft\events\UserEvent; use craft\events\UserGroupsAssignEvent; @@ -32,6 +33,7 @@ use craft\helpers\Template; use craft\helpers\UrlHelper; use craft\models\FieldLayout; +use craft\models\UserGroup; use craft\models\Volume; use craft\records\User as UserRecord; use craft\web\Request; @@ -143,6 +145,13 @@ class Users extends Component */ public const EVENT_AFTER_ASSIGN_USER_TO_GROUPS = 'afterAssignUserToGroups'; + /** + * @event DefineUserGroupsEvent The event that is triggered when defining the default user groups to assign to a publicly-registered user. + * @see getDefaultUserGroups() + * @since 4.5.4 + */ + public const EVENT_DEFINE_DEFAULT_USER_GROUPS = 'defineDefaultUserGroups'; + /** * @event UserAssignGroupEvent The event that is triggered before a user is assigned to the default user group. * @@ -1344,7 +1353,37 @@ public function assignUserToGroups(int $userId, array $groupIds): bool } /** - * Assigns a user to the default user group. + * Returns the default user groups that the given user should belong to. + * + * @param User $user + * @return UserGroup[] + * @since 4.5.4 + */ + public function getDefaultUserGroups(User $user): array + { + $groups = []; + $uid = Craft::$app->getProjectConfig()->get('users.defaultGroup'); + if ($uid) { + $group = Craft::$app->getUserGroups()->getGroupByUid($uid); + if ($group) { + $groups[] = $group; + } + } + + if ($this->hasEventHandlers(self::EVENT_DEFINE_DEFAULT_USER_GROUPS)) { + $event = new DefineUserGroupsEvent([ + 'user' => $user, + 'userGroups' => $groups, + ]); + $this->trigger(self::EVENT_DEFINE_DEFAULT_USER_GROUPS, $event); + return $event->userGroups; + } + + return $groups; + } + + /** + * Assigns a user to the default user group(s). * * This method is called toward the end of a public registration request. * @@ -1353,22 +1392,16 @@ public function assignUserToGroups(int $userId, array $groupIds): bool */ public function assignUserToDefaultGroup(User $user): bool { - // Make sure there's a default group - $uid = Craft::$app->getProjectConfig()->get('users.defaultGroup'); - - if (!$uid) { - return false; - } - - $group = Craft::$app->getUserGroups()->getGroupByUid($uid); + $groups = $this->getDefaultUserGroups($user); - if (!$group) { + if (empty($groups)) { return false; } // Fire a 'beforeAssignUserToDefaultGroup' event $event = new UserAssignGroupEvent([ 'user' => $user, + 'userGroups' => $groups, ]); $this->trigger(self::EVENT_BEFORE_ASSIGN_USER_TO_DEFAULT_GROUP, $event); @@ -1376,7 +1409,8 @@ public function assignUserToDefaultGroup(User $user): bool return false; } - if (!$this->assignUserToGroups($user->id, [$group->id])) { + $groupIds = array_map(fn(UserGroup $group) => $group->id, $groups); + if (!$this->assignUserToGroups($user->id, $groupIds)) { return false; } @@ -1384,6 +1418,7 @@ public function assignUserToDefaultGroup(User $user): bool if ($this->hasEventHandlers(self::EVENT_AFTER_ASSIGN_USER_TO_DEFAULT_GROUP)) { $this->trigger(self::EVENT_AFTER_ASSIGN_USER_TO_DEFAULT_GROUP, new UserAssignGroupEvent([ 'user' => $user, + 'userGroups' => $groups, ])); } @@ -1434,11 +1469,10 @@ public function saveLayout(FieldLayout $layout, bool $runValidation = true): boo return false; } - $projectConfig = Craft::$app->getProjectConfig(); - $fieldLayoutConfig = $layout->getConfig(); - $uid = StringHelper::UUID(); + Craft::$app->getProjectConfig()->set(ProjectConfig::PATH_USER_FIELD_LAYOUTS, [ + $layout->uid => $layout->getConfig(), + ], 'Save the user field layout'); - $projectConfig->set(ProjectConfig::PATH_USER_FIELD_LAYOUTS, [$uid => $fieldLayoutConfig], "Save the user field layout"); return true; } diff --git a/src/services/Utilities.php b/src/services/Utilities.php index b9fcb925a55..fc1279001b8 100644 --- a/src/services/Utilities.php +++ b/src/services/Utilities.php @@ -96,7 +96,12 @@ public function getAllUtilityTypes(): array ]); $this->trigger(self::EVENT_REGISTER_UTILITY_TYPES, $event); - return $event->types; + $disabledUtilities = array_flip(Craft::$app->getConfig()->getGeneral()->disabledUtilities); + + return array_values(array_filter($event->types, function(string $class) use ($disabledUtilities) { + /** @var string|UtilityInterface $class */ + return !isset($disabledUtilities[$class::id()]); + })); } /** diff --git a/src/services/Volumes.php b/src/services/Volumes.php index 6f0d7cc718d..0671c273d9c 100644 --- a/src/services/Volumes.php +++ b/src/services/Volumes.php @@ -103,7 +103,7 @@ public function __serialize(): array */ public function getAllVolumeIds(): array { - return ArrayHelper::getColumn($this->getAllVolumes(), 'id', false); + return array_values(array_map(fn(Volume $volume) => $volume->id, $this->getAllVolumes())); } /** @@ -113,7 +113,7 @@ public function getAllVolumeIds(): array */ public function getViewableVolumeIds(): array { - return ArrayHelper::getColumn($this->getViewableVolumes(), 'id', false); + return array_values(array_map(fn(Volume $volume) => $volume->id, $this->getViewableVolumes())); } /** @@ -161,11 +161,10 @@ public function getTotalViewableVolumes(): int private function _volumes(): MemoizableArray { if (!isset($this->_volumes)) { - $volumes = []; - foreach ($this->_createVolumeQuery()->all() as $result) { - $volumes[] = Craft::createObject(Volume::class, [$result]); - } - $this->_volumes = new MemoizableArray($volumes); + $this->_volumes = new MemoizableArray( + $this->_createVolumeQuery()->all(), + fn(array $result) => Craft::createObject(Volume::class, [$result]), + ); } return $this->_volumes; diff --git a/src/templates/_components/fieldtypes/Assets/settings.twig b/src/templates/_components/fieldtypes/Assets/settings.twig index 23ab3d42297..726db327cdf 100644 --- a/src/templates/_components/fieldtypes/Assets/settings.twig +++ b/src/templates/_components/fieldtypes/Assets/settings.twig @@ -62,7 +62,7 @@ sourceOptions: sourceOptions, sourceValue: field.restrictedLocationSource, subpathValue: field.restrictedLocationSubpath, - errors: field.getErrors('restrictedLocationSubpath') + errors: field.getErrors('restrictedLocationSource') + field.getErrors('restrictedLocationSubpath') }) }} {{ forms.checkboxField({ @@ -103,7 +103,7 @@ sourceOptions: sourceOptions, sourceValue: field.defaultUploadLocationSource, subpathValue: field.defaultUploadLocationSubpath, - errors: field.getErrors('defaultUploadLocationSubpath') + errors: field.getErrors('defaultUploadLocationSource') + field.getErrors('defaultUploadLocationSubpath') }) }} {% endtag %} diff --git a/src/templates/_components/utilities/DeprecationErrors/index.twig b/src/templates/_components/utilities/DeprecationErrors/index.twig index d1e4ad7c738..c8e23b4425c 100644 --- a/src/templates/_components/utilities/DeprecationErrors/index.twig +++ b/src/templates/_components/utilities/DeprecationErrors/index.twig @@ -24,7 +24,7 @@ {% for log in logs %} - {{ log.message|e|md(inlineOnly=true)|raw }} + {{ log.message|md(inlineOnly=true, encode=true)|raw }} {{- log.file|e|replace('/', '/')|raw }} {%- if log.line -%} diff --git a/src/templates/_components/widgets/CraftSupport/body.twig b/src/templates/_components/widgets/CraftSupport/body.twig index 1b7bd0d51b2..82397301b82 100644 --- a/src/templates/_components/widgets/CraftSupport/body.twig +++ b/src/templates/_components/widgets/CraftSupport/body.twig @@ -16,6 +16,7 @@
{{ tag('h2', { text: submitText, + class: 'cs-heading' }) }} {{ forms.textareaField({ first: true, diff --git a/src/templates/_elements/sources.twig b/src/templates/_elements/sources.twig index 576eb3208b1..7b5c964c994 100644 --- a/src/templates/_elements/sources.twig +++ b/src/templates/_elements/sources.twig @@ -61,7 +61,13 @@ {{ (svg(source.iconMask, sanitize=true, namespace=true) ?: "")|raw }} {% endif %} - {{ source.label|trim is not same as('') ? source.label : '(blank)'|t('app') }} + + {% if source.label|trim is not same as('') %} + {{ (source.type ?? null) == 'custom' ? source.label|t('site') : source.label }} + {% else %} + {{ '(blank)'|t('app') }} + {% endif %} + {% if source.badgeCount is defined %} {{ tag('span', { diff --git a/src/templates/_elements/toolbar.twig b/src/templates/_elements/toolbar.twig index 9f819e2b5b0..f2d5eff7bb0 100644 --- a/src/templates/_elements/toolbar.twig +++ b/src/templates/_elements/toolbar.twig @@ -68,5 +68,5 @@ label: 'Clear search'|t('app'), }, }) }} - +
diff --git a/src/templates/_includes/forms/autosuggest.twig b/src/templates/_includes/forms/autosuggest.twig index 8c28152ed0b..e8fbb1beec7 100644 --- a/src/templates/_includes/forms/autosuggest.twig +++ b/src/templates/_includes/forms/autosuggest.twig @@ -30,8 +30,6 @@ @focus="updateFilteredOptions" @blur="onBlur" @input="onInputChange" - @opened="onOpened" - @closed="onClosed" v-model="inputProps.initialValue" >