diff --git a/.github/workflows/cypress-e2e.yml b/.github/workflows/cypress-e2e.yml
index b8bb18b00..9be82aacd 100644
--- a/.github/workflows/cypress-e2e.yml
+++ b/.github/workflows/cypress-e2e.yml
@@ -252,7 +252,7 @@ jobs:
env:
PHP_CLI_SERVER_WORKERS: 3
- - name: Add Nextcloud users and a collective for full-text search
+ - name: Add Nextcloud users
run: |
for user in alice bob jane john; do \
OC_PASS="$user" ./occ user:add --password-from-env "$user"; \
@@ -261,8 +261,6 @@ jobs:
for user in bob jane; do \
OC_PASS="$user" ./occ group:adduser "Bobs Group" "$user"; \
done
- ./occ collectives:create SearchTest --owner=bob
- ./occ collectives:index
- name: Register index for cypress-split
env:
diff --git a/DEVELOPING.md b/DEVELOPING.md
index 7a0f38f5a..09162bde6 100644
--- a/DEVELOPING.md
+++ b/DEVELOPING.md
@@ -12,7 +12,6 @@ The following tools are required for app development:
* npm: to install NodeJS dependencies and compile JS assets
* g++: to compile some NodeJS dependencies
* rsync and openssl: for generating release tarballs
-* php `dom` and `sqlite` extension
* composer for installing php dependencies
* nextcloud server for running php tests
* teams/circles app for passing some php tests that depend on it
diff --git a/Makefile b/Makefile
index 41bcaa679..1afe6423b 100644
--- a/Makefile
+++ b/Makefile
@@ -144,6 +144,7 @@ build: node-modules build-js-production composer-install-no-dev
--exclude="$(APP_NAME)/stylelint.config.cjs" \
--exclude="$(APP_NAME)/tests" \
--exclude="$(APP_NAME)/tsconfig.json" \
+ --exclude="$(APP_NAME)/vendor/.scoped" \
--exclude="$(APP_NAME)/vendor-bin" \
--exclude="$(APP_NAME)/vite.*" \
$(PROJECT_DIR) $(RELEASE_DIR)/
diff --git a/README.md b/README.md
index 2e26ad486..0cbf2af4a 100644
--- a/README.md
+++ b/README.md
@@ -26,10 +26,6 @@ organize together. Come and gather in collectives to build shared knowledge.
In your Nextcloud instance, simply navigate to **»Apps«**, find the
**»Teams«** and **»Collectives«** apps and enable them.
-## Requirements
-
-For full-text search to work the sqlite and pdo PHP extensions must be installed.
-
## Documentation and help
Take a look at our [online documentation](https://nextcloud.github.io/collectives/).
diff --git a/composer.json b/composer.json
index ff3ea711d..a4423dfe1 100644
--- a/composer.json
+++ b/composer.json
@@ -15,15 +15,15 @@
"php": "^8.1",
"ext-json": "*",
"ext-pdo": "*",
- "ext-pdo_sqlite": "*",
+ "bamarni/composer-bin-plugin": "^1.9",
"league/commonmark": "^2.7",
+ "patrickschur/language-detection": "^5.3",
"symfony/string": "^6.0",
"symfony/translation-contracts": "^3.6",
- "teamtnt/tntsearch": "^5.0"
+ "wamania/php-stemmer": "^4.0"
},
"require-dev": {
"ext-dom": "*",
- "bamarni/composer-bin-plugin": "^1.8",
"guzzlehttp/guzzle": "^7.8",
"rector/rector": "^2.0.3"
},
@@ -36,10 +36,14 @@
"psalm:update-baseline": "psalm --threads=1 --update-baseline --set-baseline=tests/psalm-baseline.xml",
"test:unit": "phpunit -c tests/phpunit.xml",
"post-install-cmd": [
- "[ $COMPOSER_DEV_MODE -eq 0 ] || composer bin all install --ansi"
+ "composer install --working-dir=vendor-bin/php-scoper --ansi --no-interaction",
+ "test -f vendor/.scoped || (vendor-bin/php-scoper/vendor/bin/php-scoper add-prefix --prefix='OCA\\Collectives\\Vendor' --output-dir=\"/tmp/scoped-vendor\" --working-dir=\"./vendor/\" -f --config=\"../scoper.inc.php\" && cp -rf /tmp/scoped-vendor/* ./vendor/ && rm -rf /tmp/scoped-vendor && touch vendor/.scoped)",
+ "composer dump-autoload"
],
"post-update-cmd": [
- "[ $COMPOSER_DEV_MODE -eq 0 ] || composer bin all update --ansi"
+ "composer install --working-dir=vendor-bin/php-scoper --ansi --no-interaction",
+ "test -f vendor/.scoped || (vendor-bin/php-scoper/vendor/bin/php-scoper add-prefix --prefix='OCA\\Collectives\\Vendor' --output-dir=\"/tmp/scoped-vendor\" --working-dir=\"./vendor/\" -f --config=\"../scoper.inc.php\" && cp -rf /tmp/scoped-vendor/* ./vendor/ && rm -rf /tmp/scoped-vendor && touch vendor/.scoped)",
+ "composer dump-autoload"
]
},
"extra": {
diff --git a/composer.lock b/composer.lock
index 91bfc7075..8360ba00b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,65 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "87d8f7961a0905ef792fbe1800c20a18",
+ "content-hash": "abe08af8d2125b62acdec7b2578f018c",
"packages": [
+ {
+ "name": "bamarni/composer-bin-plugin",
+ "version": "1.9.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/bamarni/composer-bin-plugin.git",
+ "reference": "641d0663f5ac270b1aeec4337b7856f76204df47"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/641d0663f5ac270b1aeec4337b7856f76204df47",
+ "reference": "641d0663f5ac270b1aeec4337b7856f76204df47",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.0",
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "composer/composer": "^2.2.26",
+ "ext-json": "*",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.1 || ^2.0",
+ "phpunit/phpunit": "^8.5 || ^9.6 || ^10.0",
+ "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
+ "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
+ "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "Bamarni\\Composer\\Bin\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "No conflicts for your bin dependencies",
+ "keywords": [
+ "composer",
+ "conflict",
+ "dependency",
+ "executable",
+ "isolation",
+ "tool"
+ ],
+ "support": {
+ "issues": "https://github.com/bamarni/composer-bin-plugin/issues",
+ "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.9.1"
+ },
+ "time": "2026-02-04T10:18:12+00:00"
+ },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -81,6 +138,85 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
+ {
+ "name": "joomla/string",
+ "version": "3.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/joomla-framework/string.git",
+ "reference": "0b3d33564db389e27346f7e275c694897c939434"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/joomla-framework/string/zipball/0b3d33564db389e27346f7e275c694897c939434",
+ "reference": "0b3d33564db389e27346f7e275c694897c939434",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1.0",
+ "symfony/deprecation-contracts": "^2|^3"
+ },
+ "conflict": {
+ "doctrine/inflector": "<1.2"
+ },
+ "require-dev": {
+ "doctrine/inflector": "^1.2",
+ "joomla/test": "^3.0",
+ "phpstan/phpstan": "1.12.27",
+ "phpstan/phpstan-deprecation-rules": "1.2.1",
+ "phpunit/phpunit": "^9.5.28",
+ "squizlabs/php_codesniffer": "^3.7.2"
+ },
+ "suggest": {
+ "doctrine/inflector": "To use the string inflector",
+ "ext-mbstring": "For improved processing"
+ },
+ "type": "joomla-package",
+ "extra": {
+ "branch-alias": {
+ "dev-2.0-dev": "2.0-dev",
+ "dev-3.x-dev": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/phputf8/utf8.php",
+ "src/phputf8/ord.php",
+ "src/phputf8/str_ireplace.php",
+ "src/phputf8/str_pad.php",
+ "src/phputf8/str_split.php",
+ "src/phputf8/strcasecmp.php",
+ "src/phputf8/strcspn.php",
+ "src/phputf8/stristr.php",
+ "src/phputf8/strrev.php",
+ "src/phputf8/strspn.php",
+ "src/phputf8/trim.php",
+ "src/phputf8/ucfirst.php",
+ "src/phputf8/ucwords.php",
+ "src/phputf8/utils/ascii.php",
+ "src/phputf8/utils/validation.php"
+ ],
+ "psr-4": {
+ "Joomla\\String\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "description": "Joomla String Package",
+ "homepage": "https://github.com/joomla-framework/string",
+ "keywords": [
+ "framework",
+ "joomla",
+ "string"
+ ],
+ "support": {
+ "issues": "https://github.com/joomla-framework/string/issues",
+ "source": "https://github.com/joomla-framework/string/tree/3.0.4"
+ },
+ "time": "2025-07-19T15:25:56+00:00"
+ },
{
"name": "league/commonmark",
"version": "2.8.2",
@@ -427,35 +563,31 @@
"time": "2025-12-01T17:30:42+00:00"
},
{
- "name": "predis/predis",
- "version": "v2.4.0",
+ "name": "patrickschur/language-detection",
+ "version": "v5.3.1",
"source": {
"type": "git",
- "url": "https://github.com/predis/predis.git",
- "reference": "f49e13ee3a2a825631562aa0223ac922ec5d058b"
+ "url": "https://github.com/patrickschur/language-detection.git",
+ "reference": "df8d32021b2ef9fde52e6fcccb83e3806822c9c6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/predis/predis/zipball/f49e13ee3a2a825631562aa0223ac922ec5d058b",
- "reference": "f49e13ee3a2a825631562aa0223ac922ec5d058b",
+ "url": "https://api.github.com/repos/patrickschur/language-detection/zipball/df8d32021b2ef9fde52e6fcccb83e3806822c9c6",
+ "reference": "df8d32021b2ef9fde52e6fcccb83e3806822c9c6",
"shasum": ""
},
"require": {
- "php": "^7.2 || ^8.0"
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "php": "^7.4 || ^8.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.3",
- "phpstan/phpstan": "^1.9",
- "phpunit/phpcov": "^6.0 || ^8.0",
- "phpunit/phpunit": "^8.0 || ^9.4"
- },
- "suggest": {
- "ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
+ "phpunit/phpunit": "^9.5.0"
},
"type": "library",
"autoload": {
"psr-4": {
- "Predis\\": "src/"
+ "LanguageDetection\\": "src/LanguageDetection"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -464,29 +596,22 @@
],
"authors": [
{
- "name": "Till Krüss",
- "homepage": "https://till.im",
- "role": "Maintainer"
+ "name": "Patrick Schur",
+ "email": "patrick_schur@outlook.de"
}
],
- "description": "A flexible and feature-complete Redis/Valkey client for PHP.",
- "homepage": "http://github.com/predis/predis",
+ "description": "A language detection library for PHP. Detects the language from a given text string.",
+ "homepage": "https://github.com/patrickschur/language-detection",
"keywords": [
- "nosql",
- "predis",
- "redis"
+ "detect",
+ "detection",
+ "language"
],
"support": {
- "issues": "https://github.com/predis/predis/issues",
- "source": "https://github.com/predis/predis/tree/v2.4.0"
+ "issues": "https://github.com/patrickschur/language-detection/issues",
+ "source": "https://github.com/patrickschur/language-detection/tree/v5.3.1"
},
- "funding": [
- {
- "url": "https://github.com/sponsors/tillkruss",
- "type": "github"
- }
- ],
- "time": "2025-04-30T15:16:02+00:00"
+ "time": "2025-03-25T22:47:08+00:00"
},
{
"name": "psr/event-dispatcher",
@@ -1177,36 +1302,30 @@
"time": "2025-07-15T13:41:35+00:00"
},
{
- "name": "teamtnt/tntsearch",
- "version": "v5.0.0",
+ "name": "wamania/php-stemmer",
+ "version": "v4.0.0",
"source": {
"type": "git",
- "url": "https://github.com/teamtnt/tntsearch.git",
- "reference": "3f6078c37d55feab3927d8f988f9e1e8b3aaa2a0"
+ "url": "https://github.com/wamania/php-stemmer.git",
+ "reference": "d96509294ea843b4b86e4900df27424a6ea0ace8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/teamtnt/tntsearch/zipball/3f6078c37d55feab3927d8f988f9e1e8b3aaa2a0",
- "reference": "3f6078c37d55feab3927d8f988f9e1e8b3aaa2a0",
+ "url": "https://api.github.com/repos/wamania/php-stemmer/zipball/d96509294ea843b4b86e4900df27424a6ea0ace8",
+ "reference": "d96509294ea843b4b86e4900df27424a6ea0ace8",
"shasum": ""
},
"require": {
- "ext-mbstring": "*",
- "ext-pdo": "*",
- "php": "~7.1|^8",
- "predis/predis": "^2.2"
+ "joomla/string": ">=2.0.1",
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "7.*|8.*|9.*",
- "symfony/var-dumper": "^4|^5.2"
+ "phpunit/phpunit": "^9.0"
},
"type": "library",
"autoload": {
- "files": [
- "helper/helpers.php"
- ],
"psr-4": {
- "TeamTNT\\TNTSearch\\": "src"
+ "Wamania\\Snowball\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1215,96 +1334,24 @@
],
"authors": [
{
- "name": "Nenad Tičarić",
- "email": "nticaric@gmail.com",
- "homepage": "http://www.tntstudio.us",
- "role": "Developer"
+ "name": "Wamania",
+ "homepage": "http://wamania.com"
}
],
- "description": "A fully featured full text search engine written in PHP",
- "homepage": "https://github.com/teamtnt/tntsearch",
+ "description": "Native PHP Stemmer",
"keywords": [
- "Fuzzy search",
- "bm25",
- "fulltext",
- "geosearch",
- "search",
- "stemming",
- "teamtnt",
- "text classification",
- "tntsearch"
+ "php",
+ "porter",
+ "stemmer"
],
"support": {
- "issues": "https://github.com/teamtnt/tntsearch/issues",
- "source": "https://github.com/teamtnt/tntsearch/tree/v5.0.0"
+ "issues": "https://github.com/wamania/php-stemmer/issues",
+ "source": "https://github.com/wamania/php-stemmer/tree/v4.0.0"
},
- "funding": [
- {
- "url": "https://github.com/teamtnt",
- "type": "github"
- }
- ],
- "time": "2025-04-15T20:07:13+00:00"
+ "time": "2024-12-22T08:54:03+00:00"
}
],
"packages-dev": [
- {
- "name": "bamarni/composer-bin-plugin",
- "version": "1.9.1",
- "source": {
- "type": "git",
- "url": "https://github.com/bamarni/composer-bin-plugin.git",
- "reference": "641d0663f5ac270b1aeec4337b7856f76204df47"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/641d0663f5ac270b1aeec4337b7856f76204df47",
- "reference": "641d0663f5ac270b1aeec4337b7856f76204df47",
- "shasum": ""
- },
- "require": {
- "composer-plugin-api": "^2.0",
- "php": "^7.2.5 || ^8.0"
- },
- "require-dev": {
- "composer/composer": "^2.2.26",
- "ext-json": "*",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "^1.8 || ^2.0",
- "phpstan/phpstan-phpunit": "^1.1 || ^2.0",
- "phpunit/phpunit": "^8.5 || ^9.6 || ^10.0",
- "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
- "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
- "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0"
- },
- "type": "composer-plugin",
- "extra": {
- "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin"
- },
- "autoload": {
- "psr-4": {
- "Bamarni\\Composer\\Bin\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "No conflicts for your bin dependencies",
- "keywords": [
- "composer",
- "conflict",
- "dependency",
- "executable",
- "isolation",
- "tool"
- ],
- "support": {
- "issues": "https://github.com/bamarni/composer-bin-plugin/issues",
- "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.9.1"
- },
- "time": "2026-02-04T10:18:12+00:00"
- },
{
"name": "guzzlehttp/guzzle",
"version": "7.10.0",
@@ -1956,8 +2003,7 @@
"platform": {
"php": "^8.1",
"ext-json": "*",
- "ext-pdo": "*",
- "ext-pdo_sqlite": "*"
+ "ext-pdo": "*"
},
"platform-dev": {
"ext-dom": "*"
diff --git a/cypress/e2e/unified-search.spec.js b/cypress/e2e/unified-search.spec.js
deleted file mode 100644
index 6849074b4..000000000
--- a/cypress/e2e/unified-search.spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-describe('Unified search', function() {
- before(function() {
- cy.loginAs('bob')
- cy.deleteAndSeedCollective('UnifiedSearchCollective')
- })
-
- beforeEach(function() {
- cy.loginAs('bob')
- cy.visit('/apps/collectives/UnifiedSearchCollective')
- })
-
- it('Search for page title', function() {
- cy.get('.unified-search a, button.unified-search__button, .unified-search-menu button').click()
- cy.get('.unified-search__form input, .unified-search-modal input')
- .type('Day')
- cy.get('.unified-search__results-collectives-pages, .unified-search-modal__results')
- .should('contain', 'Day 1')
- })
-
- it('Search for page content', function() {
- cy.get('.unified-search a, button.unified-search__button, .unified-search-menu button').click()
- cy.get('.unified-search__form input, .unified-search-modal input')
- .type('yourself at home')
- cy.get('.unified-search__results-collectives-page-content, .unified-search-modal__results')
- .should('contain', 'Multiple people can edit')
- })
-})
diff --git a/docs/content/administration/_index.md b/docs/content/administration/_index.md
index 421ee1dfb..8d37196fc 100644
--- a/docs/content/administration/_index.md
+++ b/docs/content/administration/_index.md
@@ -37,8 +37,7 @@ users will not see collectives with the "everyone" group as member. The group me
be synced once in the circles app: `occ circles:sync --groups`
This only needs to be done once. New users that got created after the app was enabled will see
-the collectives straight away
-.
+the collectives straight away.
## Collectives and guest users
@@ -48,13 +47,6 @@ of enabled apps for guest users in admin settings.
Please note that this enables guest users to create new collectives.
-## Searching Collectives
-
-To enable searching of collectives from the unified Nextcloud search, make sure the `ext-pdo` and `ext-pdo_sqlite` PHP extensions are installed and the Nextcloud cronjob is running. The index of collectives page contents should update with every cronjob run.
-
-Tip: On Ubuntu 22.04, the relevant package to install is `phpXX-sqlite3` - with the XX being replaced with your PHP version. E.g. ` php8.1-sqlite3` for PHP 8.1.
-
-
## Public shares
WebDAV access to public shares must not be disabled (i.e. it must be enabled)
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 8bc17e0fe..bad9df1a2 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -36,7 +36,6 @@
use OCA\Collectives\Search\PageProvider;
use OCA\Collectives\Service\CollectiveHelper;
use OCA\Collectives\SetupChecks\CirclesAppIsEnableCheck;
-use OCA\Collectives\SetupChecks\PDOSQLiteDriverIsEnableCheck;
use OCA\Collectives\Team\CollectiveTeamResourceProvider;
use OCA\Collectives\Trash\PageTrashBackend;
use OCA\Collectives\Trash\PageTrashManager;
@@ -146,8 +145,6 @@ public function register(IRegistrationContext $context): void {
}
if (interface_exists(ISetupCheck::class) && method_exists($context, 'registerSetupCheck')) {
- /** @psalm-suppress MissingDependency */
- $context->registerSetupCheck(PDOSQLiteDriverIsEnableCheck::class);
/** @psalm-suppress MissingDependency */
$context->registerSetupCheck(CirclesAppIsEnableCheck::class);
}
diff --git a/lib/BackgroundJob/IndexCollectives.php b/lib/BackgroundJob/IndexCollectives.php
index 2b1801946..783c37044 100644
--- a/lib/BackgroundJob/IndexCollectives.php
+++ b/lib/BackgroundJob/IndexCollectives.php
@@ -12,6 +12,7 @@
use OCA\Collectives\Db\Collective;
use OCA\Collectives\Db\CollectiveMapper;
use OCA\Collectives\Mount\CollectiveFolderManager;
+use OCA\Collectives\Search\FileSearch\Db\SearchFileMapper;
use OCA\Collectives\Search\FileSearch\FileSearchException;
use OCA\Collectives\Service\SearchService;
use OCP\AppFramework\Utility\ITimeFactory;
@@ -25,6 +26,7 @@ public function __construct(
ITimeFactory $time,
private CollectiveMapper $collectiveMapper,
private CollectiveFolderManager $collectiveFolderManager,
+ private SearchFileMapper $fileMapper,
private LoggerInterface $logger,
private SearchService $searchService,
) {
@@ -37,15 +39,11 @@ public function __construct(
* @param $argument
*/
protected function run($argument): void {
- if (!$this->searchService->areDependenciesMet()) {
- return;
- }
-
$collectives = $this->collectiveMapper->getAll();
foreach ($collectives as $collective) {
if ($this->isOutdatedIndex($collective)) {
try {
- $this->searchService->indexCollective($collective);
+ $this->searchService->indexCollective($collective, true);
} catch (FileSearchException $e) {
$this->logger->error('Collectives background job failed to index collective ' . $collective->getId(), [
'message' => $e->getMessage(),
@@ -57,14 +55,12 @@ protected function run($argument): void {
}
private function isOutdatedIndex(Collective $collective): bool {
- $index = $this->searchService->getIndexForCollective($collective);
- if (!$index) {
- return true;
- }
-
try {
$folder = $this->collectiveFolderManager->getRootFolder()->get((string)$collective->getId());
- return $folder->getMTime() > $index->getMTime();
+ $folderMtime = $folder->getMTime();
+ $maxIndexedMtime = $this->fileMapper->getMaxMtimeByCircle($collective->getCircleId());
+
+ return $maxIndexedMtime === null || $folderMtime > $maxIndexedMtime;
} catch (NotFoundException|InvalidPathException) {
return false;
}
diff --git a/lib/Command/IndexCollectives.php b/lib/Command/IndexCollectives.php
index d39a8fd0c..2cb38aa2b 100644
--- a/lib/Command/IndexCollectives.php
+++ b/lib/Command/IndexCollectives.php
@@ -18,6 +18,7 @@
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class IndexCollectives extends Command {
@@ -32,18 +33,15 @@ protected function configure(): void {
$this
->setName('collectives:index')
->setDescription('Indexes collectives for full text search.')
- ->addArgument('name', InputArgument::OPTIONAL, 'name of new collective');
+ ->addArgument('name', InputArgument::OPTIONAL, 'name of new collective')
+ ->addOption('full', 'f', InputOption::VALUE_NONE, 'Full re-index (default: incremental)');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int {
- if (!$this->searchService->areDependenciesMet()) {
- $output->writeln('Could not index the collectives: PDO or SQLite extension not installed.');
- return 1;
- }
-
$collectives = $this->collectiveMapper->getAll();
$name = $input->getArgument('name');
+ $fullIndex = $input->getOption('full');
foreach ($collectives as $collective) {
try {
@@ -53,7 +51,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
continue;
}
$output->write('Creating index for ' . $circleName . ' …');
- $this->searchService->indexCollective($collective);
+ $this->searchService->indexCollective($collective, !$fullIndex);
$output->writeln('done');
} catch (MissingDependencyException|NotFoundException|NotPermittedException) {
$output->writeln("Failed to find team associated with collective with ID={$collective->getId()}");
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 684b91f88..c41dffcbf 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -451,7 +451,7 @@ public function contentSearch(int $collectiveId, string $searchString): DataResp
$results = $this->indexedSearchService->searchCollective($collective, $searchString, 100);
$pages = [];
foreach ($results as $value) {
- $pages[] = $this->service->find($collectiveId, $value['id'], $uid);
+ $pages[] = $this->service->find($collectiveId, $value['file_id'], $uid);
}
return $pages;
}, $this->logger);
diff --git a/lib/Migration/Version030301Date20251029000000.php b/lib/Migration/Version030301Date20251029000000.php
new file mode 100644
index 000000000..ec0afe890
--- /dev/null
+++ b/lib/Migration/Version030301Date20251029000000.php
@@ -0,0 +1,115 @@
+hasTable('collectives_s_words')) {
+ $table = $schema->createTable('collectives_s_words');
+
+ $table->addColumn('id', Types::BIGINT, [
+ 'notnull' => true,
+ 'autoincrement' => true,
+ ]);
+ $table->addColumn('circle_unique_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]);
+ $table->addColumn('term', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 50,
+ ]);
+ $table->addColumn('num_hits', Types::INTEGER, [
+ 'notnull' => true,
+ 'default' => 0,
+ ]);
+ $table->addColumn('num_files', Types::INTEGER, [
+ 'notnull' => true,
+ 'default' => 0,
+ ]);
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['circle_unique_id', 'term'], 's_words_circle_term');
+ $table->addIndex(['circle_unique_id'], 's_words_circle');
+ }
+
+ if (!$schema->hasTable('collectives_s_docs')) {
+ $table = $schema->createTable('collectives_s_docs');
+
+ $table->addColumn('id', Types::BIGINT, [
+ 'notnull' => true,
+ 'autoincrement' => true,
+ ]);
+ $table->addColumn('circle_unique_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]);
+ $table->addColumn('word_id', Types::BIGINT, [
+ 'notnull' => true,
+ ]);
+ $table->addColumn('file_id', Types::BIGINT, [
+ 'notnull' => true,
+ ]);
+ $table->addColumn('hit_count', Types::INTEGER, [
+ 'notnull' => true,
+ 'default' => 0,
+ ]);
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['circle_unique_id', 'word_id', 'file_id'], 's_uniq_docs');
+ $table->addIndex(['circle_unique_id', 'word_id'], 's_docs_circle_word');
+ $table->addIndex(['circle_unique_id', 'file_id'], 's_docs_circle_file');
+ $table->addIndex(['word_id', 'hit_count'], 's_docs_word_hits');
+ }
+
+ if (!$schema->hasTable('collectives_s_files')) {
+ $table = $schema->createTable('collectives_s_files');
+
+ $table->addColumn('id', Types::BIGINT, [
+ 'notnull' => true,
+ 'autoincrement' => true,
+ ]);
+ $table->addColumn('circle_unique_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]);
+ $table->addColumn('file_id', Types::BIGINT, [
+ 'notnull' => true,
+ ]);
+ $table->addColumn('path', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 1024,
+ ]);
+ $table->addColumn('mtime', Types::INTEGER, [
+ 'notnull' => true,
+ 'default' => 0,
+ ]);
+ $table->addColumn('language', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 10,
+ 'default' => null,
+ ]);
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['file_id'], 's_files_file_id');
+ }
+
+ return $schema;
+ }
+}
diff --git a/lib/Search/FileSearch/ClauseTokenizer.php b/lib/Search/FileSearch/ClauseTokenizer.php
deleted file mode 100644
index 2fca0b17a..000000000
--- a/lib/Search/FileSearch/ClauseTokenizer.php
+++ /dev/null
@@ -1,39 +0,0 @@
-
+ */
+class SearchDocMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'collectives_s_docs', SearchDoc::class);
+ }
+
+ public function insertDoc(string $circleUniqueId, int $wordId, int $fileId, int $hitCount): SearchDoc {
+ $doc = new SearchDoc();
+ $doc->setCircleUniqueId($circleUniqueId);
+ $doc->setWordId($wordId);
+ $doc->setFileId($fileId);
+ $doc->setHitCount($hitCount);
+ return $this->insert($doc);
+ }
+
+ public function deleteByCircle(string $circleUniqueId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)));
+ $qb->executeStatement();
+ }
+
+ public function findDocumentsByWords(string $circleUniqueId, array $wordIds, int $limit): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('file_id')
+ ->selectAlias($qb->func()->sum('hit_count'), 'total_hits')
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->in('word_id', $qb->createNamedParameter($wordIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->groupBy('file_id')
+ ->orderBy('total_hits', 'DESC')
+ ->setMaxResults($limit);
+
+ $result = $qb->executeQuery();
+ return $result->fetchAll();
+ }
+
+ public function findByCircleAndFileId(string $circleUniqueId, int $fileId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId)));
+
+ return $this->findEntities($qb);
+ }
+}
diff --git a/lib/Search/FileSearch/Db/SearchFile.php b/lib/Search/FileSearch/Db/SearchFile.php
new file mode 100644
index 000000000..cee3800d4
--- /dev/null
+++ b/lib/Search/FileSearch/Db/SearchFile.php
@@ -0,0 +1,34 @@
+
+ */
+class SearchFileMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'collectives_s_files', SearchFile::class);
+ }
+
+ public function insertFile(string $circleUniqueId, int $fileId, string $path, int $mtime, ?string $language = null): SearchFile {
+ $file = new SearchFile();
+ $file->setCircleUniqueId($circleUniqueId);
+ $file->setFileId($fileId);
+ $file->setPath($path);
+ $file->setMtime($mtime);
+ $file->setLanguage($language);
+ return $this->insert($file);
+ }
+
+ public function findByCircleAndFileId(string $circleUniqueId, int $fileId): ?SearchFile {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId)));
+
+ try {
+ return $this->findEntity($qb);
+ } catch (\Exception) {
+ return null;
+ }
+ }
+
+ public function getMaxMtimeByCircle(string $circleUniqueId): ?int {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->max('mtime'))
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)));
+
+ $result = $qb->executeQuery();
+ $mtime = $result->fetchOne();
+ $result->closeCursor();
+
+ return $mtime ? (int)$mtime : null;
+ }
+
+ public function getLanguagesByCircle(string $circleUniqueId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->selectDistinct('language')
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->isNotNull('language'));
+
+ $result = $qb->executeQuery();
+ $languages = $result->fetchAll(\PDO::FETCH_COLUMN);
+ $result->closeCursor();
+
+ return $languages;
+ }
+
+ public function deleteByCircle(string $circleUniqueId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)));
+ $qb->executeStatement();
+ }
+
+ public function deleteByCircleAndFileId(string $circleUniqueId, int $fileId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId)));
+ $qb->executeStatement();
+ }
+}
diff --git a/lib/Search/FileSearch/Db/SearchWord.php b/lib/Search/FileSearch/Db/SearchWord.php
new file mode 100644
index 000000000..52ccc9c8d
--- /dev/null
+++ b/lib/Search/FileSearch/Db/SearchWord.php
@@ -0,0 +1,31 @@
+
+ */
+class SearchWordMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'collectives_s_words', SearchWord::class);
+ }
+
+ public function upsert(string $circleUniqueId, string $term, int $numHits, int $numFiles): SearchWord {
+ $word = $this->findByCircleAndTerm($circleUniqueId, $term);
+
+ if ($word !== null) {
+ $word->setNumHits($word->getNumHits() + $numHits);
+ $word->setNumFiles($word->getNumFiles() + $numFiles);
+ return $this->update($word);
+ }
+
+ $word = new SearchWord();
+ $word->setCircleUniqueId($circleUniqueId);
+ $word->setTerm($term);
+ $word->setNumHits($numHits);
+ $word->setNumFiles($numFiles);
+ return $this->insert($word);
+ }
+
+ public function deleteByCircle(string $circleUniqueId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)));
+ $qb->executeStatement();
+ }
+
+ public function findByCircleAndTerm(string $circleUniqueId, string $term): ?SearchWord {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->eq('term', $qb->createNamedParameter($term)));
+
+ try {
+ return $this->findEntity($qb);
+ } catch (DoesNotExistException|MultipleObjectsReturnedException) {
+ return null;
+ }
+ }
+
+ public function findByCircleAndPrefix(string $circleUniqueId, string $prefix, int $limit = 20): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->like('term', $qb->createNamedParameter($prefix . '%')))
+ ->orderBy('num_hits', 'DESC')
+ ->setMaxResults($limit);
+
+ return $this->findEntities($qb);
+ }
+
+ public function decrementCounts(string $circleUniqueId, string $wordId, int $hitCount): void {
+ $qb = $this->db->getQueryBuilder();
+
+ $hitCountParam = $qb->createNamedParameter($hitCount, IQueryBuilder::PARAM_INT);
+ $qb->update($this->tableName)
+ ->set('num_hits', $qb->func()->greatest($qb->createFunction("num_hits - $hitCountParam"), $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
+ ->set('num_files', $qb->func()->greatest($qb->createFunction('num_files - 1'), $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($wordId)));
+ $qb->executeStatement();
+
+ $this->deleteOrphanedWords($circleUniqueId);
+ }
+
+ private function deleteOrphanedWords(string $circleUniqueId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->eq('circle_unique_id', $qb->createNamedParameter($circleUniqueId)))
+ ->andWhere($qb->expr()->orX(
+ $qb->expr()->lte('num_hits', $qb->createNamedParameter(0)),
+ $qb->expr()->lte('num_files', $qb->createNamedParameter(0))
+ ));
+ $qb->executeStatement();
+ }
+}
diff --git a/lib/Search/FileSearch/FileIndexer.php b/lib/Search/FileSearch/FileIndexer.php
index b54c25f65..1d2d82310 100644
--- a/lib/Search/FileSearch/FileIndexer.php
+++ b/lib/Search/FileSearch/FileIndexer.php
@@ -3,36 +3,100 @@
declare(strict_types=1);
/**
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Collectives\Search\FileSearch;
+use OCA\Collectives\Fs\NodeHelper;
+use OCA\Collectives\Search\FileSearch\Db\SearchDocMapper;
+use OCA\Collectives\Search\FileSearch\Db\SearchFileMapper;
+use OCA\Collectives\Search\FileSearch\Db\SearchWordMapper;
+use OCA\Collectives\Search\FileSearch\LanguageDetector\LanguageDetector;
+use OCA\Collectives\Search\FileSearch\Stemmer\Stemmer;
+use OCA\Collectives\Search\FileSearch\Tokenizer\WordTokenizer;
use OCP\Files\File;
use OCP\Files\Folder;
-use OCP\Files\GenericFileException;
-use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
-use OCP\Files\NotPermittedException;
-use OCP\Lock\LockedException;
-use PDO;
-use TeamTNT\TNTSearch\Contracts\EngineContract;
-use TeamTNT\TNTSearch\Indexer\TNTIndexer;
-use TeamTNT\TNTSearch\Support\Collection;
-/**
- * @property PDO|null $index
- */
-class FileIndexer extends TNTIndexer {
- public function __construct(EngineContract $engine) {
- parent::__construct($engine);
- $this->disableOutput(true);
+class FileIndexer {
+ private const LANGUAGE_DETECTION_LIMIT = 2000;
+
+ public function __construct(
+ private SearchWordMapper $wordMapper,
+ private SearchDocMapper $docMapper,
+ private SearchFileMapper $fileMapper,
+ private WordTokenizer $tokenizer,
+ private Stemmer $stemmer,
+ private LanguageDetector $languageDetector,
+ ) {
+ }
+
+ public function indexFolder(Folder $folder, string $circleUniqueId, bool $incremental = false): void {
+
+ if (!$incremental) {
+ $this->deleteIndexByCircle($circleUniqueId);
+ }
+
+ $files = $this->getDirectoryFiles($folder, true);
+
+ foreach ($files as $file) {
+ $this->indexFile($file, $circleUniqueId, $incremental);
+ }
+ }
+
+ private function deleteIndexByCircle(string $circleUniqueId): void {
+ $this->wordMapper->deleteByCircle($circleUniqueId);
+ $this->docMapper->deleteByCircle($circleUniqueId);
+ $this->fileMapper->deleteByCircle($circleUniqueId);
}
- public function loadConfig(array $config): void {
- parent::loadConfig($config);
- $this->engine->config['storage'] = '';
+ private function indexFile(File $file, string $circleUniqueId, bool $incremental): void {
+
+ if ($incremental) {
+ $existingFile = $this->fileMapper->findByCircleAndFileId($circleUniqueId, $file->getId());
+
+ if ($existingFile && $existingFile->getMtime() >= $file->getMTime()) {
+ return;
+ }
+
+ if ($existingFile) {
+ $this->deleteFileFromIndex($circleUniqueId, $file->getId());
+ }
+ }
+
+ try {
+ $content = $file->getContent();
+ } catch (\Exception) {
+ return;
+ }
+
+ $language = $this->languageDetector->detect(mb_substr($content, 0, self::LANGUAGE_DETECTION_LIMIT));
+ $tokens = $this->tokenizer->tokenize($content);
+
+ $stems = [];
+ foreach ($tokens as $token) {
+ $stems[] = $this->stemmer->stem($token, $language);
+ }
+
+ $terms = array_count_values($stems);
+ unset($content, $tokens, $stems);
+
+ foreach ($terms as $term => $hitCount) {
+ try {
+ $term = mb_substr((string)$term, 0, 50);
+ $word = $this->wordMapper->upsert($circleUniqueId, $term, $hitCount, 1);
+ $this->docMapper->insertDoc($circleUniqueId, $word->getId(), $file->getId(), $hitCount);
+ } catch (\Exception) {
+ continue;
+ }
+ }
+
+ try {
+ $this->fileMapper->insertFile($circleUniqueId, $file->getId(), $file->getInternalPath(), $file->getMTime(), $language);
+ } catch (\Exception) {
+ }
}
private function getDirectoryFiles(Folder $folder, bool $recursive = false): array {
@@ -49,8 +113,7 @@ private function getDirectoryFiles(Folder $folder, bool $recursive = false): arr
$filesRecursive[] = $this->getDirectoryFiles($node, true);
}
- $extension = pathinfo($node->getName(), PATHINFO_EXTENSION);
- if ($node instanceof File === false || !in_array($extension, ['md', 'txt'], true)) {
+ if (!$node instanceof File || !NodeHelper::isPage($node)) {
continue;
}
@@ -60,61 +123,14 @@ private function getDirectoryFiles(Folder $folder, bool $recursive = false): arr
return array_merge($files, ...$filesRecursive);
}
- /**
- * @throws FileSearchException
- */
- public function runOnDirectory(Folder $folder, bool $recursive = true): void {
- $this->run($this->getDirectoryFiles($folder, $recursive));
- }
-
- /**
- * @throws FileSearchException
- */
- public function run(array $pages = []): void {
- $index = $this->getIndex();
- if ($index === null) {
- throw new FileSearchException('Indexing could not be performed because index is not selected.');
- }
-
- $index->exec('CREATE TABLE IF NOT EXISTS filemap (
- id INTEGER PRIMARY KEY,
- path TEXT)');
- $index->beginTransaction();
+ private function deleteFileFromIndex(string $circleUniqueId, int $fileId): void {
+ $docs = $this->docMapper->findByCircleAndFileId($circleUniqueId, $fileId);
- $processedPages = 0;
- foreach ($pages as $page) {
- try {
- $id = $page->getId();
- $internalPath = $page->getInternalPath();
- try {
- $fileCollection = new Collection([
- 'id' => $id,
- 'name' => $page->getName(),
- 'content' => $page->getContent()
- ]);
- } catch (GenericFileException) {
- // Ignore files that went missing
- continue;
- }
- $this->processDocument($fileCollection);
-
- $statement = $index->prepare("INSERT INTO filemap ( 'id', 'path') values ( :id, :path)");
- $statement->bindParam(':id', $id);
- $statement->bindParam(':path', $internalPath);
- $statement->execute();
-
- $processedPages++;
- } catch (NotFoundException|NotPermittedException|InvalidPathException|LockedException $e) {
- throw new FileSearchException('File indexer failed to open and/or read file', 0, $e);
- }
+ foreach ($docs as $doc) {
+ $this->wordMapper->decrementCounts($circleUniqueId, $doc->getWordId(), $doc->getHitCount());
+ $this->docMapper->delete($doc);
}
- $index->exec("UPDATE info SET `value`=$processedPages WHERE `key`='total_documents'");
- $index->exec("INSERT INTO info ( 'key', 'value') values ( 'driver', 'filesystem')");
-
- $index->commit();
- }
- public function getIndex(): ?PDO {
- return $this->engine->index;
+ $this->fileMapper->deleteByCircleAndFileId($circleUniqueId, $fileId);
}
}
diff --git a/lib/Search/FileSearch/FileSearcher.php b/lib/Search/FileSearch/FileSearcher.php
index 1111b2a9d..98e0d88dd 100644
--- a/lib/Search/FileSearch/FileSearcher.php
+++ b/lib/Search/FileSearch/FileSearcher.php
@@ -3,126 +3,118 @@
declare(strict_types=1);
/**
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Collectives\Search\FileSearch;
-use OCP\Files\File;
-use OCP\Files\NotFoundException;
-use PDO;
-use TeamTNT\TNTSearch\Support\TokenizerInterface;
-use TeamTNT\TNTSearch\TNTSearch;
+use OCA\Collectives\Search\FileSearch\Db\SearchDocMapper;
+use OCA\Collectives\Search\FileSearch\Db\SearchFileMapper;
+use OCA\Collectives\Search\FileSearch\Db\SearchWordMapper;
+use OCA\Collectives\Search\FileSearch\Stemmer\Stemmer;
+use OCA\Collectives\Search\FileSearch\Tokenizer\ClauseTokenizer;
+use OCA\Collectives\Search\FileSearch\Tokenizer\WordTokenizer;
-/**
- * @property PDO|null $index;
- */
-class FileSearcher extends TNTSearch {
- public const DEFAULT_CONFIG = [
- 'tokenizer' => WordTokenizer::class,
- 'wal' => false,
- 'driver' => 'filesystem',
- 'storage' => ''
- ];
-
- public const SUPPORTED_LANGUAGES = [
- 'ar' => 'Arabic',
- 'cr' => 'Croatian',
- 'fr' => 'French',
- 'de' => 'German',
- 'en' => 'Porter',
- 'it' => 'Italian',
- 'lv' => 'Latvian',
- 'pl' => 'Polish',
- 'pt' => 'Portuguese',
- 'ru' => 'Russian',
- 'uk' => 'Ukrainian',
- ];
-
- private const UNSUPPORTED_LANGUAGE = 'No';
-
- protected FileIndexer $indexer;
+class FileSearcher {
+ private const DEFAULT_LIMIT = 15;
+ private const FUZZY_PREFIX_LENGTH = 3;
+ private const FUZZY_MAX_DISTANCE = 1;
public function __construct(
- private ?string $language = null,
+ private SearchWordMapper $wordMapper,
+ private SearchDocMapper $docMapper,
+ private SearchFileMapper $fileMapper,
+ private WordTokenizer $tokenizer,
+ private ClauseTokenizer $clauseTokenizer,
+ private Stemmer $stemmer,
) {
- parent::__construct();
- $this->loadConfig();
- $this->asYouType(true);
- $this->fuzziness(true);
}
- public function loadConfig(array $config = self::DEFAULT_CONFIG): void {
- parent::loadConfig($config);
- $this->indexer = new FileIndexer($this->engine);
- $this->indexer->loadConfig($config);
- }
+ public function search(string $circleId, string $query, int $limit = self::DEFAULT_LIMIT): array {
+ $tokens = $this->tokenizer->tokenize($query);
+ $languages = $this->fileMapper->getLanguagesByCircle($circleId) ?: [null];
- /**
- * @param string $phrase
- * @param int $numOfResults
- */
- public function search($phrase, $numOfResults = 1000): array {
- $this->setStemmer();
- $this->setTokenizer();
- return parent::search($phrase, $numOfResults);
- }
+ $stems = [];
+ foreach ($tokens as $token) {
+ foreach ($languages as $language) {
+ $stems[] = $this->stemmer->stem($token, $language);
+ }
+ }
+ $stems = array_unique($stems);
+
+ if (empty($stems)) {
+ return [];
+ }
+
+ $wordIds = [];
+ foreach ($stems as $stem) {
+ $word = $this->wordMapper->findByCircleAndTerm($circleId, $stem);
+ $words = $word ? [$word] : $this->fuzzySearchWord($circleId, $stem);
- /**
- * @throws FileSearchException
- */
- public function selectIndexFile(File $indexFile): FileIndexer {
- try {
- $path = $indexFile->getStorage()->getLocalFile($indexFile->getInternalPath());
- } catch (NotFoundException) {
- throw new FileSearchException('File searcher could not find storage for index file.');
+ foreach ($words as $word) {
+ $wordIds[] = $word->getId();
+ }
}
- if (!$path) {
- throw new FileSearchException('File searcher could not create local index.');
+ $wordIds = array_unique($wordIds);
+
+ if (empty($wordIds)) {
+ return [];
}
- return $this->selectIndex($path);
+ return $this->docMapper->findDocumentsByWords($circleId, $wordIds, $limit);
}
- /**
- * @param string $indexName
- * @throws FileSearchException
- */
- public function selectIndex($indexName): FileIndexer {
- if (!file_exists($indexName)) {
- throw new FileSearchException('Could not find an index for the collective.');
+ public function rankByBigrams(string $query, array $files): array {
+ $phrases = $this->clauseTokenizer->tokenize($query);
+
+ if (empty($phrases)) {
+ return $files;
+ }
+
+ $scored = [];
+ foreach ($files as $file) {
+ try {
+ $content = mb_strtolower($file->getContent());
+ } catch (\Exception) {
+ continue;
+ }
+
+ $score = 0;
+
+ foreach ($phrases as $phrase) {
+ if (empty($phrase)) {
+ continue;
+ }
+ $count = substr_count(mb_strtolower($content), mb_strtolower($phrase));
+ $score += $count;
+ }
+
+ $scored[$file->getId()] = ['file' => $file, 'score' => $score];
}
- $this->index = new PDO('sqlite:' . $indexName);
- $this->index->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
- $this->indexer->setIndex($this->index);
- $this->indexer->setLanguage($this->language ?? self::UNSUPPORTED_LANGUAGE);
+ uasort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
- return $this->indexer;
+ return array_map(fn ($item) => $item['file'], $scored);
}
- /**
- * @param string $indexName
- * @param bool $disableOutput
- */
- public function createIndex($indexName = '', $disableOutput = false): FileIndexer {
- $this->indexer->createIndex($indexName);
- $this->indexer->setLanguage($this->language ?? self::UNSUPPORTED_LANGUAGE);
+ private function fuzzySearchWord(string $circleId, string $term): array {
+ if (mb_strlen($term) < self::FUZZY_PREFIX_LENGTH) {
+ return [];
+ }
- $this->index = $this->indexer->getIndex();
- return $this->indexer;
- }
+ $prefix = mb_substr($term, 0, self::FUZZY_PREFIX_LENGTH);
+ $candidates = $this->wordMapper->findByCircleAndPrefix($circleId, $prefix);
- public function createInMemoryIndex(): FileIndexer {
- return $this->createIndex(':memory:');
- }
+ $matches = [];
+ foreach ($candidates as $candidate) {
+ $distance = levenshtein($candidate->getTerm(), $term);
+ if ($distance <= self::FUZZY_MAX_DISTANCE) {
+ $matches[] = $candidate;
+ }
+ }
- public function getTokenizer(): ?TokenizerInterface {
- $this->index && $this->setTokenizer();
- $configTokenizer = $this->config['tokenizer'];
- $tokenizer = $this->tokenizer ?: new $configTokenizer();
- return ($tokenizer instanceof TokenizerInterface) ? $tokenizer : null;
+ return $matches;
}
}
diff --git a/lib/Search/FileSearch/LanguageDetector/LanguageDetector.php b/lib/Search/FileSearch/LanguageDetector/LanguageDetector.php
new file mode 100644
index 000000000..0a4d5f100
--- /dev/null
+++ b/lib/Search/FileSearch/LanguageDetector/LanguageDetector.php
@@ -0,0 +1,28 @@
+detect($text);
+ return $result !== '' ? $result : null;
+ } catch (\Exception) {
+ return null;
+ }
+ }
+}
diff --git a/lib/Search/FileSearch/Stemmer/Stemmer.php b/lib/Search/FileSearch/Stemmer/Stemmer.php
new file mode 100644
index 000000000..a8faa2ed6
--- /dev/null
+++ b/lib/Search/FileSearch/Stemmer/Stemmer.php
@@ -0,0 +1,50 @@
+ */
+ private array $stemmers = [];
+
+ public function __construct(
+ private IConfig $config,
+ ) {
+ }
+
+ private function getStemmer(string $language): ?WamaniaStemmer {
+ if (!array_key_exists($language, $this->stemmers)) {
+ try {
+ $this->stemmers[$language] = StemmerFactory::create($language);
+ } catch (NotFoundException) {
+ $this->stemmers[$language] = null;
+ }
+ }
+ return $this->stemmers[$language];
+ }
+
+ public function stem(string $word, ?string $language = null): string {
+ $language ??= $this->config->getSystemValue('default_language', 'en');
+ $stemmer = $this->getStemmer($language);
+ if ($stemmer === null) {
+ return $word;
+ }
+
+ try {
+ return $stemmer->stem($word);
+ } catch (\Exception) {
+ return $word;
+ }
+ }
+}
diff --git a/lib/Search/FileSearch/Tokenizer/ClauseTokenizer.php b/lib/Search/FileSearch/Tokenizer/ClauseTokenizer.php
new file mode 100644
index 000000000..9bf4baff3
--- /dev/null
+++ b/lib/Search/FileSearch/Tokenizer/ClauseTokenizer.php
@@ -0,0 +1,31 @@
+= self::MIN_LENGTH && $len <= self::MAX_LENGTH;
+ });
+ }
+}
diff --git a/lib/Search/FileSearch/WordTokenizer.php b/lib/Search/FileSearch/WordTokenizer.php
deleted file mode 100644
index 81f462c09..000000000
--- a/lib/Search/FileSearch/WordTokenizer.php
+++ /dev/null
@@ -1,30 +0,0 @@
- $fileData) {
+
+ foreach ($results as $fileData) {
+ $fileId = $fileData['file_id'];
$fileEntry = $collectiveRoot->getFirstNodeById($fileId);
- if ($fileEntry !== null) {
- $pages[$fileId] = $fileEntry;
- $collectiveMap[$fileId] = $collective;
+ if (empty($fileEntry)) {
+ continue;
}
+
+ $pages[$fileId] = $fileEntry;
+ $collectiveMap[$fileId] = $collective;
}
}
- $highlighter = new Highlighter((new FileSearcher())->getTokenizer());
- $highlightLength = 50;
+ $pages = $this->indexedSearchService->rankByBigrams($query->getTerm(), $pages);
- $pages = $this->rankPages($query->getTerm(), $pages);
$pageSearchResults = [];
foreach ($pages as $page) {
$collective = $collectiveMap[$page->getId()];
@@ -109,9 +108,11 @@ public function search(IUser $user, ISearchQuery $query): SearchResult {
: '';
$description = $this->l10n->t('In collective %1$s', [$this->collectiveService->getCollectiveNameWithEmoji($collective)])
. $descriptionSuffix;
+
+ $content = $page->getContent();
$pageSearchResults[] = new SearchResultEntry(
'',
- $highlighter->extractRelevant($query->getTerm(), $page->getContent(), $highlightLength, 5, ''),
+ mb_substr($content, mb_stripos($content, $query->getTerm()), 200),
$description,
$this->urlGenerator->linkToRouteAbsolute('collectives.start.index') . $this->pageService->getPageLink($collective->getUrlPath(), $pageInfo),
'icon-collectives-page'
@@ -124,45 +125,6 @@ public function search(IUser $user, ISearchQuery $query): SearchResult {
);
}
- /**
- * @param File[] $pages
- * @return File[]
- * @throws FileSearchException
- */
- private function rankPages(string $term, array $pages): array {
- if (!$this->indexedSearchService->areDependenciesMet()) {
- return $pages;
- }
-
- $ranked = [];
- $searcher = new FileSearcher();
-
- // Run once using clause tokenizer to extract most relevant results (if term has at least two words)
- $words = explode(' ', $term);
- if (count($words) > 1) {
- $config = FileSearcher::DEFAULT_CONFIG;
- $config['tokenizer'] = ClauseTokenizer::class;
- $searcher->loadConfig($config);
-
- $searcher->createInMemoryIndex()->run($pages);
- $results = $searcher->search($term);
- foreach (array_keys($results) as $pageId) {
- $ranked[] = $pages[$pageId];
- unset($pages[$pageId]);
- }
- }
-
- // Run using default tokenizer to rank remaining results
- $searcher = new FileSearcher();
- $searcher->createInMemoryIndex()->run($pages);
- $results = $searcher->search($term, 50);
- foreach (array_keys($results) as $pageId) {
- $ranked[] = $pages[$pageId];
- }
-
- return $ranked;
- }
-
private function getPageInfo(Collective $collective, File $file, string $userId): ?PageInfo {
try {
return $this->pageService->findByFile($collective->getId(), $file, $userId);
diff --git a/lib/Service/SearchService.php b/lib/Service/SearchService.php
index 8d51d509b..790f61e02 100644
--- a/lib/Service/SearchService.php
+++ b/lib/Service/SearchService.php
@@ -11,143 +11,39 @@
use OCA\Collectives\Db\Collective;
use OCA\Collectives\Mount\CollectiveFolderManager;
+use OCA\Collectives\Search\FileSearch\FileIndexer;
use OCA\Collectives\Search\FileSearch\FileSearcher;
use OCA\Collectives\Search\FileSearch\FileSearchException;
-use OCP\Files\File;
-use OCP\Files\Folder;
-use OCP\Files\GenericFileException;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
-use OCP\Files\NotPermittedException;
-use OCP\IConfig;
-use OCP\ITempManager;
-use OCP\Lock\LockedException;
-use PDO;
-use Psr\Log\LoggerInterface;
class SearchService {
- private const INDICES_DIR_NAME = 'indices';
public function __construct(
+ private FileIndexer $indexer,
+ private FileSearcher $searcher,
private CollectiveFolderManager $collectiveFolderManager,
- private ITempManager $tempManager,
- private LoggerInterface $logger,
- private IConfig $config,
) {
}
/**
* @throws FileSearchException
*/
- public function indexCollective(Collective $collective): void {
- $indexPath = $this->tempManager->getTemporaryFile();
-
+ public function indexCollective(Collective $collective, $incremental = false): void {
try {
$collectiveFolder = $this->collectiveFolderManager->getFolder($collective->getId());
} catch (InvalidPathException|NotFoundException $e) {
throw new FileSearchException('Collectives search service could not find folder for collective.', 0, $e);
}
- $indexer = $this->createFileSearcher()->createIndex($indexPath);
- $indexer->runOnDirectory($collectiveFolder);
-
- $this->saveIndex($collective, $indexPath);
- $this->tempManager->clean();
+ $this->indexer->indexFolder($collectiveFolder, $collective->getCircleId(), $incremental);
}
- /**
- * @throws FileSearchException
- */
- public function searchCollective(Collective $collective, string $term, int $maxResults = 15): array {
- if (!$this->areDependenciesMet()) {
- $this->logger->warning('Collectives full-text search is not operational, because the PDO SQLite driver is not available.');
- return [];
- }
-
- $searcher = $this->createFileSearcher();
- $file = $this->getIndexForCollective($collective);
- if ($file === null) {
- $this->logger->warning('Collectives search failed to find search index for collective with ID ' . $collective->getId());
- return [];
- }
-
- $searcher->selectIndexFile($file);
- return $searcher->search($term, $maxResults);
- }
-
- /**
- * @throws FileSearchException
- */
- public function getIndexForCollective(Collective $collective): ?File {
- try {
- $file = $this->getIndicesFolder()->get($this->getIndexName($collective));
- } catch (NotFoundException) {
- return null;
- }
-
- return $file instanceof File ? $file : null;
- }
-
- public function getIndexName(Collective $collective): string {
- return 'index_' . $collective->getCircleId() . '.db';
- }
-
- /**
- * @throws FileSearchException
- */
- private function saveIndex(Collective $collective, string $path): void {
- $file = $this->getOrCreateIndexForCollective($collective);
- if (!$file) {
- throw new FileSearchException('Could not create index file for collective.');
- }
-
- try {
- $file->putContent(file_get_contents($path));
- } catch (NotPermittedException|GenericFileException|LockedException $e) {
- throw new FileSearchException('Could not write to index file for collective.', 0, $e);
- }
- }
-
- /**
- * @throws FileSearchException
- */
- private function getOrCreateIndexForCollective(Collective $collective): ?File {
- $file = $this->getIndexForCollective($collective);
-
- try {
- $file = $this->getIndicesFolder()->newFile($this->getIndexName($collective));
- } catch (NotPermittedException) {
- }
-
- return $file instanceof File ? $file : null;
- }
-
- private function createFileSearcher(): FileSearcher {
- $defaultLanguage = $this->config->getSystemValue('default_language', 'en');
- return new FileSearcher(FileSearcher::SUPPORTED_LANGUAGES[$defaultLanguage] ?? null);
- }
-
- /**
- * @throws FileSearchException
- */
- private function getIndicesFolder(): Folder {
- $rootFolder = $this->collectiveFolderManager->getRootFolder();
- try {
- $folder = $rootFolder->get(self::INDICES_DIR_NAME);
- if ($folder instanceof Folder) {
- return $folder;
- }
- } catch (NotFoundException) {
- }
-
- try {
- return $rootFolder->newFolder(self::INDICES_DIR_NAME);
- } catch (NotPermittedException) {
- throw new FileSearchException('Could not find or create the indices directory.');
- }
+ public function searchCollective(Collective $collective, string $query, int $maxResults = 15): array {
+ return $this->searcher->search($collective->getCircleId(), $query, $maxResults);
}
- public function areDependenciesMet(): bool {
- return in_array('sqlite', PDO::getAvailableDrivers(), true);
+ public function rankByBigrams(string $query, array $files): array {
+ return $this->searcher->rankByBigrams($query, $files);
}
}
diff --git a/lib/SetupChecks/PDOSQLiteDriverIsEnableCheck.php b/lib/SetupChecks/PDOSQLiteDriverIsEnableCheck.php
deleted file mode 100644
index 14ffaab2a..000000000
--- a/lib/SetupChecks/PDOSQLiteDriverIsEnableCheck.php
+++ /dev/null
@@ -1,42 +0,0 @@
-l10n->t('PDO SQLite driver');
- }
-
- public function run(): SetupResult {
- if ($this->searchService->areDependenciesMet()) {
- return SetupResult::success($this->l10n->t('PDO SQLite driver is enabled, full text search of page content is available.'));
- }
-
- return SetupResult::error(
- $this->l10n->t('Collectives app is enabled, but PDO SQLite driver is missing. Please install it to enable full text search of the page content.')
- );
- }
-}
diff --git a/playwright/e2e/unified-search.spec.ts b/playwright/e2e/unified-search.spec.ts
new file mode 100644
index 000000000..8205bb205
--- /dev/null
+++ b/playwright/e2e/unified-search.spec.ts
@@ -0,0 +1,52 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Locator } from '@playwright/test'
+import type { CollectivePage } from '../support/fixtures/CollectivePage.ts'
+
+import { runOcc } from '@nextcloud/e2e-test-server'
+import { expect, mergeTests } from '@playwright/test'
+import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts'
+import { test as editorTest } from '../support/fixtures/editor.ts'
+
+const test = mergeTests(createCollectiveTest, editorTest)
+
+test.describe('Unified search', () => {
+ let page1: CollectivePage
+ let page2: CollectivePage
+ let unifiedSearchDialog: Locator
+
+ test.beforeEach(async ({ page, user, collective }) => {
+ page1 = await collective.createPage({ title: 'Page 1', user, page })
+ page2 = await collective.createPage({ title: 'Page 2', user, page })
+ await collective.openCollective()
+ await page.getByRole('button', { name: 'Unified search' }).click()
+ unifiedSearchDialog = page.locator('.unified-search-modal')
+ await expect(unifiedSearchDialog).toBeVisible()
+ })
+
+ // eslint-disable-next-line no-empty-pattern
+ test('Search for page title', async ({}) => {
+ await unifiedSearchDialog.getByRole('textbox').fill('page')
+ await expect(unifiedSearchDialog.getByRole('heading', { name: 'Collectives - Pages' })
+ .locator('~ ul')
+ .locator('.list-item-content__name'))
+ .toContainText(['Landing page', page1.data.title, page2.data.title])
+ })
+
+ test('Search for page content', async ({ page, user, collective }) => {
+ await page1.setContent({ content: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', user, page })
+ await page2.setContent({ content: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', user, page })
+ await runOcc([
+ 'collectives:index',
+ collective.data.name,
+ ])
+ await unifiedSearchDialog.getByRole('textbox').fill('lorem')
+ await expect(unifiedSearchDialog.getByRole('heading', { name: 'Collectives - Page content' })
+ .locator('~ ul')
+ .locator('.list-item-content__name'))
+ .toContainText([/^Lorem ipsum.*/, /^Lorem ipsum.*/])
+ })
+})
diff --git a/scoper.inc.php b/scoper.inc.php
new file mode 100644
index 000000000..449263a44
--- /dev/null
+++ b/scoper.inc.php
@@ -0,0 +1,31 @@
+ 'OCA\\Collectives\\Vendor',
+
+ 'finders' => [
+ Finder::create()
+ ->files()
+ ->exclude(['bin', 'bamarni', 'nextcloud', 'psr', 'symfony'])
+ ->in('.'),
+ ],
+
+ 'patchers' => [
+ static function (string $filePath, string $prefix, string $content): string {
+ if (str_contains($filePath, '/joomla/string/src/StringHelper.php')) {
+ return preg_replace(
+ '/(? false,
+];
diff --git a/tests/Unit/Search/PageContentProviderTest.php b/tests/Unit/Search/PageContentProviderTest.php
index be8a37186..6ca4dbdf0 100644
--- a/tests/Unit/Search/PageContentProviderTest.php
+++ b/tests/Unit/Search/PageContentProviderTest.php
@@ -55,7 +55,7 @@ protected function setUp(): void {
/** @var SearchService&MockObject $indexedSearchService */
$indexedSearchService = $this->createMock(SearchService::class);
$indexedSearchService->method('searchCollective')
- ->willReturn([404 => 'fileData']);
+ ->willReturn([['file_id' => 404]]);
/** @var LoggerInterface&MockObject $logger */
$logger = $this->createMock(LoggerInterface::class);
/** @var IAppManager&MockObject $appManager */
diff --git a/vendor-bin/php-scoper/composer.json b/vendor-bin/php-scoper/composer.json
new file mode 100644
index 000000000..9c0579663
--- /dev/null
+++ b/vendor-bin/php-scoper/composer.json
@@ -0,0 +1,5 @@
+{
+ "require": {
+ "humbug/php-scoper": "^0.18.18"
+ }
+}
diff --git a/vendor-bin/php-scoper/composer.lock b/vendor-bin/php-scoper/composer.lock
new file mode 100644
index 000000000..a369693cc
--- /dev/null
+++ b/vendor-bin/php-scoper/composer.lock
@@ -0,0 +1,1773 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "531ca5497e9d687fe33d8512915ebb0c",
+ "packages": [
+ {
+ "name": "fidry/console",
+ "version": "0.6.11",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/console.git",
+ "reference": "bea8316beae874fc5b8be679d67dd3169c7e205f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/console/zipball/bea8316beae874fc5b8be679d67dd3169c7e205f",
+ "reference": "bea8316beae874fc5b8be679d67dd3169c7e205f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.2",
+ "psr/log": "^1.0 || ^2.0 || ^3.0",
+ "symfony/console": "^6.4 || ^7.2",
+ "symfony/deprecation-contracts": "^3.4",
+ "symfony/event-dispatcher-contracts": "^2.5 || ^3.0",
+ "symfony/polyfill-php84": "^1.31",
+ "symfony/service-contracts": "^2.5 || ^3.0",
+ "thecodingmachine/safe": "^2.0 || ^3.0",
+ "webmozart/assert": "^1.11"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4.0 || >=7.0.0 <7.2.0",
+ "symfony/framework-bundle": "<6.4.0 || >=7.0.0 <7.2.0",
+ "symfony/http-kernel": "<6.4.0 || >=7.0.0 <7.2.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "composer/semver": "^3.3.2",
+ "ergebnis/composer-normalize": "^2.33",
+ "fidry/makefile": "^0.2.1 || ^1.0.0",
+ "infection/infection": "^0.28",
+ "phpunit/phpunit": "^10.2",
+ "symfony/dependency-injection": "^6.4 || ^7.2",
+ "symfony/flex": "^2.4.0",
+ "symfony/framework-bundle": "^6.4 || ^7.2",
+ "symfony/http-kernel": "^6.4 || ^7.2",
+ "symfony/yaml": "^6.4 || ^7.2"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": false,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-main": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Fidry\\Console\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Théo Fidry",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Library to create CLI applications",
+ "keywords": [
+ "cli",
+ "console",
+ "symfony"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/console/issues",
+ "source": "https://github.com/theofidry/console/tree/0.6.11"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-14T11:06:15+00:00"
+ },
+ {
+ "name": "fidry/filesystem",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/filesystem.git",
+ "reference": "d0d9e8dfa43f7663da153c306b0d5bc24846ad8e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/filesystem/zipball/d0d9e8dfa43f7663da153c306b0d5bc24846ad8e",
+ "reference": "d0d9e8dfa43f7663da153c306b0d5bc24846ad8e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.3",
+ "symfony/filesystem": "^6.4 || ^7.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.4",
+ "ergebnis/composer-normalize": "^2.28",
+ "infection/infection": ">=0.26",
+ "phpunit/phpunit": "^12",
+ "symfony/finder": "^6.4 || ^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": false,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-main": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Fidry\\FileSystem\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Théo Fidry",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Symfony Filesystem with a few more utilities.",
+ "keywords": [
+ "filesystem"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/filesystem/issues",
+ "source": "https://github.com/theofidry/filesystem/tree/1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-13T23:05:19+00:00"
+ },
+ {
+ "name": "humbug/php-scoper",
+ "version": "0.18.18",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/humbug/php-scoper.git",
+ "reference": "dd55d01a937602c9473cfbe0ecab9521cb9740aa"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/humbug/php-scoper/zipball/dd55d01a937602c9473cfbe0ecab9521cb9740aa",
+ "reference": "dd55d01a937602c9473cfbe0ecab9521cb9740aa",
+ "shasum": ""
+ },
+ "require": {
+ "fidry/console": "^0.6.10",
+ "fidry/filesystem": "^1.1",
+ "jetbrains/phpstorm-stubs": "^2024.1",
+ "nikic/php-parser": "^5.0",
+ "php": "^8.2",
+ "symfony/console": "^6.4 || ^7.0",
+ "symfony/filesystem": "^6.4 || ^7.0",
+ "symfony/finder": "^6.4 || ^7.0",
+ "symfony/var-dumper": "^7.1",
+ "thecodingmachine/safe": "^3.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.1",
+ "ergebnis/composer-normalize": "^2.28",
+ "fidry/makefile": "^1.0",
+ "humbug/box": "^4.6.2",
+ "phpspec/prophecy-phpunit": "^2.0",
+ "phpunit/phpunit": "^10.0 || ^11.0",
+ "symfony/yaml": "^6.4 || ^7.0"
+ },
+ "bin": [
+ "bin/php-scoper"
+ ],
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": false,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Humbug\\PhpScoper\\": "src/"
+ },
+ "classmap": [
+ "vendor-hotfix/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Théo Fidry",
+ "email": "theo.fidry@gmail.com"
+ },
+ {
+ "name": "Pádraic Brady",
+ "email": "padraic.brady@gmail.com"
+ }
+ ],
+ "description": "Prefixes all PHP namespaces in a file or directory.",
+ "support": {
+ "issues": "https://github.com/humbug/php-scoper/issues",
+ "source": "https://github.com/humbug/php-scoper/tree/0.18.18"
+ },
+ "time": "2025-10-15T15:29:47+00:00"
+ },
+ {
+ "name": "jetbrains/phpstorm-stubs",
+ "version": "v2024.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "0e82bdfe850c71857ee4ee3501ed82a9fc5d043c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/0e82bdfe850c71857ee4ee3501ed82a9fc5d043c",
+ "reference": "0e82bdfe850c71857ee4ee3501ed82a9fc5d043c",
+ "shasum": ""
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "v3.64.0",
+ "nikic/php-parser": "v5.3.1",
+ "phpdocumentor/reflection-docblock": "5.6.0",
+ "phpunit/phpunit": "11.4.3"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "PhpStormStubsMap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "description": "PHP runtime & extensions header files for PhpStorm",
+ "homepage": "https://www.jetbrains.com/phpstorm",
+ "keywords": [
+ "autocomplete",
+ "code",
+ "inference",
+ "inspection",
+ "jetbrains",
+ "phpstorm",
+ "stubs",
+ "type"
+ ],
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2024.3"
+ },
+ "time": "2024-12-14T08:03:12+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
+ },
+ {
+ "name": "psr/event-dispatcher",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/event-dispatcher.git",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\EventDispatcher\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Standard interfaces for event handling.",
+ "keywords": [
+ "events",
+ "psr",
+ "psr-14"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
+ "time": "2019-01-08T18:20:26+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
+ "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^7.2|^8.0"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/dotenv": "<6.4",
+ "symfony/event-dispatcher": "<6.4",
+ "symfony/lock": "<6.4",
+ "symfony/process": "<6.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T13:54:39+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+ "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
+ "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/event-dispatcher": "^1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\EventDispatcher\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to dispatching event",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
+ {
+ "name": "symfony/filesystem",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5",
+ "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
+ },
+ "require-dev": {
+ "symfony/process": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides basic utilities for the filesystem",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-24T13:12:05+00:00"
+ },
+ {
+ "name": "symfony/finder",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/finder.git",
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "symfony/filesystem": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Finder\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Finds files and directories via an intuitive fluent interface",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-24T13:12:05+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-27T09:58:17+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-12-23T08:48:59+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php84",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php84.git",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php84\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-24T13:30:11+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.6.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-15T11:30:57+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "114ac57257d75df748eda23dd003878080b8e688"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688",
+ "reference": "114ac57257d75df748eda23dd003878080b8e688",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.33",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-24T13:12:05+00:00"
+ },
+ {
+ "name": "symfony/var-dumper",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/var-dumper.git",
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/console": "<6.4"
+ },
+ "require-dev": {
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
+ "twig/twig": "^3.12"
+ },
+ "bin": [
+ "Resources/bin/var-dump-server"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions/dump.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\VarDumper\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides mechanisms for walking through any arbitrary PHP variable",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "debug",
+ "dump"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/var-dumper/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T13:44:50+00:00"
+ },
+ {
+ "name": "thecodingmachine/safe",
+ "version": "v3.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thecodingmachine/safe.git",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpstan/phpstan": "^2",
+ "phpunit/phpunit": "^10",
+ "squizlabs/php_codesniffer": "^3.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/special_cases.php",
+ "generated/apache.php",
+ "generated/apcu.php",
+ "generated/array.php",
+ "generated/bzip2.php",
+ "generated/calendar.php",
+ "generated/classobj.php",
+ "generated/com.php",
+ "generated/cubrid.php",
+ "generated/curl.php",
+ "generated/datetime.php",
+ "generated/dir.php",
+ "generated/eio.php",
+ "generated/errorfunc.php",
+ "generated/exec.php",
+ "generated/fileinfo.php",
+ "generated/filesystem.php",
+ "generated/filter.php",
+ "generated/fpm.php",
+ "generated/ftp.php",
+ "generated/funchand.php",
+ "generated/gettext.php",
+ "generated/gmp.php",
+ "generated/gnupg.php",
+ "generated/hash.php",
+ "generated/ibase.php",
+ "generated/ibmDb2.php",
+ "generated/iconv.php",
+ "generated/image.php",
+ "generated/imap.php",
+ "generated/info.php",
+ "generated/inotify.php",
+ "generated/json.php",
+ "generated/ldap.php",
+ "generated/libxml.php",
+ "generated/lzf.php",
+ "generated/mailparse.php",
+ "generated/mbstring.php",
+ "generated/misc.php",
+ "generated/mysql.php",
+ "generated/mysqli.php",
+ "generated/network.php",
+ "generated/oci8.php",
+ "generated/opcache.php",
+ "generated/openssl.php",
+ "generated/outcontrol.php",
+ "generated/pcntl.php",
+ "generated/pcre.php",
+ "generated/pgsql.php",
+ "generated/posix.php",
+ "generated/ps.php",
+ "generated/pspell.php",
+ "generated/readline.php",
+ "generated/rnp.php",
+ "generated/rpminfo.php",
+ "generated/rrd.php",
+ "generated/sem.php",
+ "generated/session.php",
+ "generated/shmop.php",
+ "generated/sockets.php",
+ "generated/sodium.php",
+ "generated/solr.php",
+ "generated/spl.php",
+ "generated/sqlsrv.php",
+ "generated/ssdeep.php",
+ "generated/ssh2.php",
+ "generated/stream.php",
+ "generated/strings.php",
+ "generated/swoole.php",
+ "generated/uodbc.php",
+ "generated/uopz.php",
+ "generated/url.php",
+ "generated/var.php",
+ "generated/xdiff.php",
+ "generated/xml.php",
+ "generated/xmlrpc.php",
+ "generated/yaml.php",
+ "generated/yaz.php",
+ "generated/zip.php",
+ "generated/zlib.php"
+ ],
+ "classmap": [
+ "lib/DateTime.php",
+ "lib/DateTimeImmutable.php",
+ "lib/Exceptions/",
+ "generated/Exceptions/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHP core functions that throw exceptions instead of returning FALSE on error",
+ "support": {
+ "issues": "https://github.com/thecodingmachine/safe/issues",
+ "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/OskarStark",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/shish",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/silasjoisten",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-04T18:08:13+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "1.12.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "9be6926d8b485f55b9229203f962b51ed377ba68"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68",
+ "reference": "9be6926d8b485f55b9229203f962b51ed377ba68",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.10-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/1.12.1"
+ },
+ "time": "2025-10-29T15:56:20+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {},
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
+}