Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ SPAM_MODEL_FILE=
MAILER_DSN=null://null
###< symfony/mailer ###

# leaving these empty is fine: search stops working, the rest of the site does not
ALGOLIA_APP_ID=
ALGOLIA_ADMIN_KEY=
ALGOLIA_SEARCH_KEY=
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ permissions.

### Search

To use the search in your local development environment, setup an
Search is optional locally: with no Algolia config the rest of the site works
normally, `/search.json` returns an error response and the search page renders
without results. To use the search, setup an
[Algolia Account](https://www.algolia.com/) and configure following keys
in your `.env.local`:

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"ext-apcu": "*",
"ext-ctype": "*",
"ext-iconv": "*",
"algolia/algoliasearch-client-php": "^3.4",
"algolia/algoliasearch-client-php": "^4.47",
"babdev/pagerfanta-bundle": "^4.2",
"beelab/recaptcha2-bundle": "^2.3",
"cebe/markdown": "^1.1",
Expand Down
83 changes: 38 additions & 45 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,15 @@ services:
tags:
- { name: knp_menu.menu, alias: organization_menu }

Algolia\AlgoliaSearch\SearchClient:
# lazy: the client throws if no credentials are configured, and PackageManager (so every package
# page) depends on it transitively. Building it only when something actually searches keeps the
# rest of the site working for devs with no Algolia setup.
Algolia\AlgoliaSearch\Api\SearchClient:
lazy: true
arguments: ['%env(ALGOLIA_APP_ID)%', '%env(ALGOLIA_ADMIN_KEY)%']
factory: ['Algolia\AlgoliaSearch\SearchClient', create]
factory: ['Algolia\AlgoliaSearch\Api\SearchClient', create]

App\Search\PackageIndex: '@App\Search\AlgoliaPackageIndex'

App\Service\QueueWorker:
$jobWorkers:
Expand Down
5 changes: 3 additions & 2 deletions config/services_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ services:
App\Service\Scheduler:
public: true

Algolia\AlgoliaSearch\SearchClient:
# public so AlgoliaMock can be swapped in
App\Search\PackageIndex:
alias: App\Search\AlgoliaPackageIndex
public: true
factory: ['Algolia\AlgoliaSearch\SearchClient', create]

# stub to replace 2FA code generation
App\Tests\Mock\TotpAuthenticatorStub:
Expand Down
69 changes: 32 additions & 37 deletions src/Command/CleanIndexCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

namespace App\Command;

use Algolia\AlgoliaSearch\SearchClient;
use App\Search\PackageIndex;
use App\Service\Locker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
Expand All @@ -21,20 +21,12 @@

class CleanIndexCommand extends Command
{
private SearchClient $algolia;
private Locker $locker;
private EntityManagerInterface $doctrine;
private string $algoliaIndexName;
private string $cacheDir;

public function __construct(SearchClient $algolia, Locker $locker, EntityManagerInterface $doctrine, string $algoliaIndexName, string $cacheDir)
{
$this->algolia = $algolia;
$this->locker = $locker;
$this->doctrine = $doctrine;
$this->algoliaIndexName = $algoliaIndexName;
$this->cacheDir = $cacheDir;

public function __construct(
private PackageIndex $packageIndex,
private Locker $locker,
private EntityManagerInterface $doctrine,
private string $cacheDir,
) {
parent::__construct();
}

Expand Down Expand Up @@ -69,33 +61,36 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 0;
}

$index = $this->algolia->initIndex($this->algoliaIndexName);

$page = 0;
$perPage = 100;
do {
$results = $index->search('', ['facets' => '*,type,tags', 'facetFilters' => ['type:virtual-package'], 'numericFilters' => ['trendiness=100'], 'hitsPerPage' => $perPage, 'page' => $page]);
foreach ($results['hits'] as $result) {
if (!str_starts_with($result['objectID'], 'virtual:')) {
$duplicate = $index->search('', ['facets' => '*,objectID,type,tags', 'facetFilters' => ['objectID:virtual:'.$result['objectID']]]);
if (\count($duplicate['hits']) === 1) {
if ($verbose) {
$output->writeln('Deleting '.$result['objectID'].' which is a duplicate of '.$duplicate['hits'][0]['objectID']);
}
$index->deleteObject($result['objectID']);
continue;
}
}
// Browsing uses a cursor, so deleting records while iterating cannot make it skip any
$records = $this->packageIndex->browse(['filters' => 'type:"virtual-package" AND trendiness=100']);

foreach ($records as $record) {
// Deleting is driven by this loop, so it does not rely on the filters above having been
// applied: anything that is not a stale virtual package is left alone regardless.
if (($record['type'] ?? null) !== 'virtual-package') {
continue;
}

$objectId = $record['objectID'];

if (!$this->hasProviders($result['name'])) {
if (!str_starts_with($objectId, 'virtual:')) {
$duplicate = $this->packageIndex->search(['query' => '', 'facetFilters' => ['objectID:virtual:'.$objectId]]);
if (\count($duplicate['hits']) === 1) {
if ($verbose) {
$output->writeln('Deleting '.$result['objectID'].' which has no provider anymore');
$output->writeln('Deleting '.$objectId.' which is a duplicate of '.$duplicate['hits'][0]['objectID']);
}
$index->deleteObject($result['objectID']);
$this->packageIndex->deleteRecord($objectId);
continue;
}
}

if (!$this->hasProviders($record['name'])) {
if ($verbose) {
$output->writeln('Deleting '.$objectId.' which has no provider anymore');
}
$this->packageIndex->deleteRecord($objectId);
}
$page++;
} while (\count($results['hits']) >= $perPage);
}

$this->locker->unlockCommand(__CLASS__);

Expand Down
9 changes: 3 additions & 6 deletions src/Command/ConfigureAlgoliaCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

namespace App\Command;

use Algolia\AlgoliaSearch\SearchClient;
use App\Search\PackageIndex;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -23,8 +23,7 @@
class ConfigureAlgoliaCommand extends Command
{
public function __construct(
private SearchClient $algolia,
private string $algoliaIndexName,
private PackageIndex $packageIndex,
private string $configDir,
) {
parent::__construct();
Expand All @@ -40,9 +39,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$settings = Yaml::parse($yaml);

$index = $this->algolia->initIndex($this->algoliaIndexName);

$index->setSettings($settings);
$this->packageIndex->updateSettings($settings);

return 0;
}
Expand Down
Loading