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" +}