feat(fulltextsearch): new API

Signed-off-by: Maxence Lange <maxence@artificial-owl.com>
This commit is contained in:
Maxence Lange 2025-10-28 10:45:26 -01:00
parent 9d41f4bcf5
commit 0294117308
17 changed files with 587 additions and 0 deletions

View file

@ -17,6 +17,17 @@ return array(
'NCU\\Config\\Lexicon\\Preset' => $baseDir . '/lib/unstable/Config/Lexicon/Preset.php',
'NCU\\Config\\ValueType' => $baseDir . '/lib/unstable/Config/ValueType.php',
'NCU\\Federation\\ISignedCloudFederationProvider' => $baseDir . '/lib/unstable/Federation/ISignedCloudFederationProvider.php',
'NCU\\FullTextSearch\\Exceptions\\ServiceNotFoundException' => $baseDir . '/lib/unstable/FullTextSearch/Exceptions/ServiceNotFoundException.php',
'NCU\\FullTextSearch\\IContentProvider' => $baseDir . '/lib/unstable/FullTextSearch/IContentProvider.php',
'NCU\\FullTextSearch\\IContentProviderImprovedSearch' => $baseDir . '/lib/unstable/FullTextSearch/IContentProviderImprovedSearch.php',
'NCU\\FullTextSearch\\IContentProviderSyncIndex' => $baseDir . '/lib/unstable/FullTextSearch/IContentProviderSyncIndex.php',
'NCU\\FullTextSearch\\IIndexQueryHelper' => $baseDir . '/lib/unstable/FullTextSearch/IIndexQueryHelper.php',
'NCU\\FullTextSearch\\ILoggerService' => $baseDir . '/lib/unstable/FullTextSearch/ILoggerService.php',
'NCU\\FullTextSearch\\IManager' => $baseDir . '/lib/unstable/FullTextSearch/IManager.php',
'NCU\\FullTextSearch\\IService' => $baseDir . '/lib/unstable/FullTextSearch/IService.php',
'NCU\\FullTextSearch\\Model\\Document' => $baseDir . '/lib/unstable/FullTextSearch/Model/Document.php',
'NCU\\FullTextSearch\\Model\\DocumentAccess' => $baseDir . '/lib/unstable/FullTextSearch/Model/DocumentAccess.php',
'NCU\\FullTextSearch\\Model\\UnindexedDocument' => $baseDir . '/lib/unstable/FullTextSearch/Model/UnindexedDocument.php',
'NCU\\Security\\Signature\\Enum\\DigestAlgorithm' => $baseDir . '/lib/unstable/Security/Signature/Enum/DigestAlgorithm.php',
'NCU\\Security\\Signature\\Enum\\SignatoryStatus' => $baseDir . '/lib/unstable/Security/Signature/Enum/SignatoryStatus.php',
'NCU\\Security\\Signature\\Enum\\SignatoryType' => $baseDir . '/lib/unstable/Security/Signature/Enum/SignatoryType.php',
@ -1775,6 +1786,7 @@ return array(
'OC\\Files\\View' => $baseDir . '/lib/private/Files/View.php',
'OC\\ForbiddenException' => $baseDir . '/lib/private/ForbiddenException.php',
'OC\\FullTextSearch\\FullTextSearchManager' => $baseDir . '/lib/private/FullTextSearch/FullTextSearchManager.php',
'OC\\FullTextSearch\\Manager' => $baseDir . '/lib/private/FullTextSearch/Manager.php',
'OC\\FullTextSearch\\Model\\DocumentAccess' => $baseDir . '/lib/private/FullTextSearch/Model/DocumentAccess.php',
'OC\\FullTextSearch\\Model\\IndexDocument' => $baseDir . '/lib/private/FullTextSearch/Model/IndexDocument.php',
'OC\\FullTextSearch\\Model\\SearchOption' => $baseDir . '/lib/private/FullTextSearch/Model/SearchOption.php',

View file

@ -58,6 +58,17 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'NCU\\Config\\Lexicon\\Preset' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/Preset.php',
'NCU\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/unstable/Config/ValueType.php',
'NCU\\Federation\\ISignedCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/unstable/Federation/ISignedCloudFederationProvider.php',
'NCU\\FullTextSearch\\Exceptions\\ServiceNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/Exceptions/ServiceNotFoundException.php',
'NCU\\FullTextSearch\\IContentProvider' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/IContentProvider.php',
'NCU\\FullTextSearch\\IContentProviderImprovedSearch' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/IContentProviderImprovedSearch.php',
'NCU\\FullTextSearch\\IContentProviderSyncIndex' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/IContentProviderSyncIndex.php',
'NCU\\FullTextSearch\\IIndexQueryHelper' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/IIndexQueryHelper.php',
'NCU\\FullTextSearch\\ILoggerService' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/ILoggerService.php',
'NCU\\FullTextSearch\\IManager' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/IManager.php',
'NCU\\FullTextSearch\\IService' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/IService.php',
'NCU\\FullTextSearch\\Model\\Document' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/Model/Document.php',
'NCU\\FullTextSearch\\Model\\DocumentAccess' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/Model/DocumentAccess.php',
'NCU\\FullTextSearch\\Model\\UnindexedDocument' => __DIR__ . '/../../..' . '/lib/unstable/FullTextSearch/Model/UnindexedDocument.php',
'NCU\\Security\\Signature\\Enum\\DigestAlgorithm' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/DigestAlgorithm.php',
'NCU\\Security\\Signature\\Enum\\SignatoryStatus' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/SignatoryStatus.php',
'NCU\\Security\\Signature\\Enum\\SignatoryType' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/SignatoryType.php',
@ -1816,6 +1827,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Files\\View' => __DIR__ . '/../../..' . '/lib/private/Files/View.php',
'OC\\ForbiddenException' => __DIR__ . '/../../..' . '/lib/private/ForbiddenException.php',
'OC\\FullTextSearch\\FullTextSearchManager' => __DIR__ . '/../../..' . '/lib/private/FullTextSearch/FullTextSearchManager.php',
'OC\\FullTextSearch\\Manager' => __DIR__ . '/../../..' . '/lib/private/FullTextSearch/Manager.php',
'OC\\FullTextSearch\\Model\\DocumentAccess' => __DIR__ . '/../../..' . '/lib/private/FullTextSearch/Model/DocumentAccess.php',
'OC\\FullTextSearch\\Model\\IndexDocument' => __DIR__ . '/../../..' . '/lib/private/FullTextSearch/Model/IndexDocument.php',
'OC\\FullTextSearch\\Model\\SearchOption' => __DIR__ . '/../../..' . '/lib/private/FullTextSearch/Model/SearchOption.php',

View file

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace OC\AppFramework\Bootstrap;
use Closure;
use NCU\FullTextSearch\IService as IFullTextSearchService;
use OC\Support\CrashReport\Registry;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
@ -145,6 +146,11 @@ class RegistrationContext {
/** @var array<array-key, string> */
private array $configLexiconClasses = [];
/** @var null|ServiceRegistration<IFullTextSearchService> */
private ?ServiceRegistration $fullTextSearchService = null;
/** @var array<array-key, string> */
private array $fullTextSearchContentProviders = [];
/** @var ServiceRegistration<ITeamResourceProvider>[] */
private array $teamResourceProviders = [];
@ -443,6 +449,20 @@ class RegistrationContext {
$configLexiconClass
);
}
public function registerFullTextSearchService(string $service): void {
$this->context->registerFullTextSearchService(
$this->appId,
$service
);
}
public function registerFullTextSearchContentProvider(string $contentProviderClass): void {
$this->context->registerFullTextSearchContentProvider(
$this->appId,
$contentProviderClass
);
}
};
}
@ -657,6 +677,24 @@ class RegistrationContext {
$this->configLexiconClasses[$appId] = $configLexiconClass;
}
public function registerFullTextSearchService(string $appId, string $service) {
if ($appId !== 'fulltextsearch') {
throw new RuntimeException('Only the FullTextSearch app is allowed to register a fts service');
}
if ($this->fullTextSearchService !== null) {
throw new RuntimeException('There can only be one FullTextSearch service');
}
$this->fullTextSearchService = new ServiceRegistration($appId, $service);
}
/**
* @psalm-param class-string<ILexicon> $contentProviderClass
*/
public function registerFullTextSearchContentProvider(string $appId, string $contentProviderClass): void {
$this->fullTextSearchContentProviders[$appId] = $contentProviderClass;
}
/**
* @param App[] $apps
*/
@ -1031,4 +1069,18 @@ class RegistrationContext {
return \OCP\Server::get($this->configLexiconClasses[$appId]);
}
/**
* @return ServiceRegistration<IFullTextSearchService>|null
*/
public function getFullTextSearchService(): ?ServiceRegistration {
return $this->fullTextSearchService;
}
/**
* @return ServiceRegistration<\NCU\FullTextSearch\IContentProvider>[]
*/
public function getFullTextSearchContentProviders(): array {
return $this->fullTextSearchContentProviders;
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\FullTextSearch;
use NCU\FullTextSearch\Exceptions\ServiceNotFoundException;
use NCU\FullTextSearch\IContentProvider;
use NCU\FullTextSearch\IManager;
use NCU\FullTextSearch\IService;
use OC\AppFramework\Bootstrap\Coordinator;
use OC\AppFramework\Bootstrap\ServiceRegistration;
use Psr\Container\ContainerInterface;
class Manager implements IManager {
private ?IService $service = null;
public function __construct(
private readonly Coordinator $coordinator,
private readonly ContainerInterface $container,
) {
}
/**
* @throws ServiceNotFoundException if no full text search service found
*/
public function getService(): IService {
if ($this->service === null) {
$registration = $this->coordinator->getRegistrationContext()?->getFullTextSearchService();
if ($registration === null) {
throw new ServiceNotFoundException('fts service not found');
}
$this->service = $this->container->get($registration->getService());
}
return $this->service;
}
/**
* @return ServiceRegistration<IContentProvider>[]
*/
public function getContentProviders(): array {
return $this->coordinator->getRegistrationContext()?->getFullTextSearchContentProviders() ?? [];
}
}

View file

@ -1208,6 +1208,7 @@ class Server extends ServerContainer implements IServerContainer {
$this->registerAlias(\OCP\Dashboard\IManager::class, \OC\Dashboard\Manager::class);
$this->registerAlias(IFullTextSearchManager::class, FullTextSearchManager::class);
$this->registerAlias(\NCU\FullTextSearch\IManager::class, \OC\FullTextSearch\Manager::class);
$this->registerAlias(IFilesMetadataManager::class, FilesMetadataManager::class);
$this->registerAlias(ISubAdmin::class, SubAdmin::class);

View file

@ -447,4 +447,21 @@ interface IRegistrationContext {
* @since 31.0.0
*/
public function registerConfigLexicon(string $configLexiconClass): void;
/**
* allow FullTextSearch to register its service
* @since 33.0.0
*/
public function registerFullTextSearchService(string $service): void;
/**
* Register an implementation of \NCU\FullTextSearch\IContentProvider that
* will handle the getaway with full text search to extract app's content
*
* @param string $contentProviderClass
*
* @psalm-param class-string<\NCU\FullTextSearch\IContentProvider> $contentProviderClass
* @since 33.0.0
*/
public function registerFullTextSearchContentProvider(string $contentProviderClass): void;
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch\Exceptions;
use Exception;
class ServiceNotFoundException extends Exception {
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch;
use NCU\FullTextSearch\Model\Document;
interface IContentProvider {
public function getId(): string;
public function getConfiguration(): array;
// public function setIndexOptions(IIndexOptions $options);
public function getDocument(string $documentId): ?Document;
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch;
use OCP\FullTextSearch\Model\ISearchRequest;
use OCP\FullTextSearch\Model\ISearchResult;
use OCP\FullTextSearch\Model\ISearchTemplate;
/**
* implementing this interface to your content provider
* will complete it with search related features
*/
interface IContentProviderImprovedSearch {
public function getSearchTemplate(): ?ISearchTemplate;
public function improveSearchRequest(ISearchRequest $searchRequest): void;
public function improveSearchResult(ISearchResult $searchResult): void;
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch;
use Generator;
use OCP\FullTextSearch\IFullTextSearchManager;
/**
* this interface needs to be implemented to re-generate a full sync
* if not implemented, the only source of documents to be indexed are via the API
*
* @see IFullTextSearchManager::requestIndex()
*/
interface IContentProviderSyncIndex {
/*
* if $qh is ignored, indexes are compared lately in the process
* if the IIndexQueryHelper is useless but returned documents are to be indexed, you must
* initiated the method with a call to $qh->notNeeded()
*
* @return Generator<UnindexedDocument|[UnindexedDocument]>
*/
public function getUnindexedDocuments(IIndexQueryHelper $qh): Generator;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch;
interface IIndexQueryHelper {
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types = 1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch;
interface ILoggerService {
public function info(string $entry): void;
public function action(string $entry): void;
public function warning(string $entry, array $data = []): void;
public function error(string $entry, array $data = []): void;
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch;
use NCU\FullTextSearch\Exceptions\ServiceNotFoundException;
use OC\AppFramework\Bootstrap\ServiceRegistration;
interface IManager {
/**
* returns FullTextSearch API
*
* @throws ServiceNotFoundException if no full text search service found
* @since 33.0.0
*/
public function getService(): IService;
/**
* returns list of registered FullTextSearch config provider
*
* @return ServiceRegistration<IContentProvider>[]
* @since 33.0.0
*/
public function getContentProviders(): array;
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch;
interface IService {
public function getLogger(): ILoggerService;
public function requestIndex(string $providerId, string $documentId): void;
public function deleteIndex(string $providerId, string $documentId): void;
}

View file

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch\Model;
use JsonException;
use Psr\Log\LoggerInterface;
final class Document {
private string $id = '';
private int $flags = 0;
private ?DocumentAccess $access = null;
private string $title = '';
private string $content = '';
private bool $contentEncoded = false;
private int $contentSize = 0;
private int $lastModificationTime = 0;
private array $tags = [];
private array $documentTags = [];
private array $parts = [];
private string $checksum = '';
public function setId(string $id): void {
$this->id = $id;
}
public function getId(): string {
return $this->id;
}
public function setFlags(int $flags): Document {
$this->flags = $flags;
$this->checksum = '';
return $this;
}
public function getFlags(): int {
return $this->flags;
}
public function setAccess(?DocumentAccess $access): Document {
$this->access = $access;
$this->checksum = '';
return $this;
}
public function getAccess(): ?DocumentAccess {
return $this->access;
}
public function setTitle(string $title): Document {
$this->title = $title;
$this->checksum = '';
return $this;
}
public function getTitle(): string {
return $this->title;
}
public function setContent(string $content, bool $encoded = false): Document {
$this->content = $content;
$this->contentSize = strlen($content);
$this->contentEncoded = $encoded;
$this->checksum = '';
return $this;
}
public function getContent(): string {
return $this->content;
}
public function isContentEncoded(): bool {
return $this->contentEncoded;
}
public function getContentSize(): int {
return $this->contentSize;
}
public function setLastModificationTime(int $lastModificationTime): Document {
$this->lastModificationTime = $lastModificationTime;
return $this;
}
public function getLastModificationTime(): int {
return $this->lastModificationTime;
}
public function setTags(array $tags): Document {
$this->tags = $tags;
$this->checksum = '';
return $this;
}
public function getTags(): array {
return $this->tags;
}
public function setDocumentTags(array $documentTags): Document {
$this->documentTags = $documentTags;
$this->checksum = '';
return $this;
}
public function getDocumentTags(): array {
return $this->documentTags;
}
public function addPart(string $key, string $part): Document {
$this->parts[$key] = $part;
$this->checksum = '';
return $this;
}
public function setParts(array $parts): Document {
$this->parts = $parts;
$this->checksum = '';
return $this;
}
public function getParts(): array {
return $this->parts;
}
public function getChecksum(): string {
if ($this->checksum === '') {
$data = [
'title' => $this->getTitle(),
'access' => $this->getAccess(),
'flags' => $this->getFlags(),
'tags' => $this->getTags(),
'documentTags' => $this->getDocumentTags(),
'parts' => $this->getParts(),
'content' => $this->getContent()
];
try {
$this->checksum = hash('xxh3', json_encode($data, JSON_THROW_ON_ERROR));
} catch (JsonException $e) {
\OCP\Server::get(LoggerInterface::class)->warning('issue while generating checksum', ['exception' => $e]);
}
}
return $this->checksum;
}
public function jsonSerialize(): array {
return [
'title' => $this->getTitle(),
'access' => $this->getAccess(),
'flags' => $this->getFlags(),
'tags' => $this->getTags(),
'documentTags' => $this->getDocumentTags(),
'lastModificationTime' => $this->getLastModificationTime(),
'contentSize' => $this->getContentSize(),
'contentEncoded' => $this->isContentEncoded(),
'checksum' => $this->getChecksum(),
];
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch\Model;
final class DocumentAccess {
private string $viewerId = '';
public function __construct(
private string $ownerId = '',
private array $users = [],
private array $groups = [],
private array $circles = [],
private array $links = [],
) {
}
public function getViewerId(): string {
return $this->viewerId;
}
public function setViewerId(string $viewerId): DocumentAccess {
$this->viewerId = $viewerId;
return $this;
}
public function getLinks(): array {
return $this->links;
}
public function setLinks(array $links): DocumentAccess {
$this->links = $links;
return $this;
}
public function getCircles(): array {
return $this->circles;
}
public function setCircles(array $circles): DocumentAccess {
$this->circles = $circles;
return $this;
}
public function getGroups(): array {
return $this->groups;
}
public function setGroups(array $groups): DocumentAccess {
$this->groups = $groups;
return $this;
}
public function getUsers(): array {
return $this->users;
}
public function setUsers(array $users): DocumentAccess {
$this->users = $users;
return $this;
}
public function getOwnerId(): string {
return $this->ownerId;
}
public function setOwnerId(string $ownerId): DocumentAccess {
$this->ownerId = $ownerId;
return $this;
}
public function jsonSerialize(): array {
return [
'ownerId' => $this->getOwnerId(),
'users' => $this->getUsers(),
'groups' => $this->getGroups(),
'circles' => $this->getCircles(),
'links' => $this->getLinks(),
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\FullTextSearch\Model;
final readonly class UnindexedDocument {
public function __construct(
private string $id,
private int $lastModified,
) {
}
public function getId(): string {
return $this->id;
}
public function getLastModified(): int {
return $this->lastModified;
}
}