Skip to content

Commit dd70944

Browse files
authored
Merge pull request #15 from LibreCodeCoop/feat/unified-profile-search
feat: unified profile search
2 parents 3dbd983 + e70b0d1 commit dd70944

File tree

9 files changed

+810
-0
lines changed

9 files changed

+810
-0
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ Profile fields lets teams add organization-specific profile data that does not f
1515

1616
This makes the app useful for internal directories, support operations, partner programs and other corporate deployments that need richer account metadata without leaving Nextcloud.
1717

18+
## Features
19+
20+
- Central field catalog for organization-specific profile data.
21+
- Per-field governance for admin-managed and self-service updates.
22+
- Per-value visibility controls for public, authenticated-user, and private exposure.
23+
- User administration tools for reviewing and updating profile field values.
24+
- Workflow integration based on custom profile metadata.
25+
- Global search results built only from fields and values exposed to the current user.
26+
1827
## API documentation
1928

2029
The public API contract for this app is published as [openapi-full.json](https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json).

lib/AppInfo/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use OCA\ProfileFields\Listener\RegisterWorkflowOperationListener;
1717
use OCA\ProfileFields\Listener\UserDeletedCleanupListener;
1818
use OCA\ProfileFields\Notification\Notifier;
19+
use OCA\ProfileFields\Search\ProfileFieldSearchProvider;
1920
use OCP\AppFramework\App;
2021
use OCP\AppFramework\Bootstrap\IBootContext;
2122
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -42,6 +43,7 @@ public function __construct() {
4243
#[\Override]
4344
public function register(IRegistrationContext $context): void {
4445
$context->registerNotifierService(Notifier::class);
46+
$context->registerSearchProvider(ProfileFieldSearchProvider::class);
4547
$context->registerEventListener('\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent', BeforeTemplateRenderedListener::class);
4648
$context->registerEventListener(UserDeletedEvent::class, UserDeletedCleanupListener::class);
4749
$context->registerEventListener(RegisterEntitiesEvent::class, RegisterWorkflowEntityListener::class);
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace OCA\ProfileFields\Search;
11+
12+
use InvalidArgumentException;
13+
use OCA\ProfileFields\Db\FieldValue;
14+
use OCA\ProfileFields\Db\FieldValueMapper;
15+
use OCA\ProfileFields\Enum\FieldVisibility;
16+
use OCA\ProfileFields\Service\FieldDefinitionService;
17+
use OCP\IGroupManager;
18+
use OCP\IUser;
19+
use OCP\IUserManager;
20+
21+
class ProfileFieldDirectorySearchService {
22+
private const MAX_MATCHES_PER_USER = 3;
23+
24+
public function __construct(
25+
private FieldDefinitionService $fieldDefinitionService,
26+
private FieldValueMapper $fieldValueMapper,
27+
private IUserManager $userManager,
28+
private IGroupManager $groupManager,
29+
) {
30+
}
31+
32+
/**
33+
* @return array{total: int, items: list<array{
34+
* user_uid: string,
35+
* display_name: string,
36+
* matched_fields: list<array{
37+
* field_key: string,
38+
* field_label: string,
39+
* value: string
40+
* }>
41+
* }>}
42+
*/
43+
public function search(?IUser $actor, string $term, int $limit, int $offset): array {
44+
if ($limit < 1) {
45+
throw new InvalidArgumentException('limit must be greater than 0');
46+
}
47+
48+
if ($offset < 0) {
49+
throw new InvalidArgumentException('offset must be greater than or equal to 0');
50+
}
51+
52+
$normalizedTerm = trim(mb_strtolower($term));
53+
if ($normalizedTerm === '') {
54+
return ['total' => 0, 'items' => []];
55+
}
56+
57+
$actorUid = $actor?->getUID();
58+
$actorIsAdmin = $actorUid !== null && $this->groupManager->isAdmin($actorUid);
59+
$definitionsById = [];
60+
foreach ($this->fieldDefinitionService->findActiveOrdered() as $definition) {
61+
$definitionsById[$definition->getId()] = $definition;
62+
}
63+
64+
if ($definitionsById === []) {
65+
return ['total' => 0, 'items' => []];
66+
}
67+
68+
$matchesByUserUid = [];
69+
foreach ($this->fieldValueMapper->findAllOrdered() as $value) {
70+
$definition = $definitionsById[$value->getFieldDefinitionId()] ?? null;
71+
if ($definition === null) {
72+
continue;
73+
}
74+
75+
if (!$this->isSearchableForActor($definition->getUserVisible(), $value->getCurrentVisibility(), $actorIsAdmin, $actorUid !== null)) {
76+
continue;
77+
}
78+
79+
$scalarValue = $this->extractScalarValue($value);
80+
if ($scalarValue === null || !str_contains(mb_strtolower($scalarValue), $normalizedTerm)) {
81+
continue;
82+
}
83+
84+
$userUid = $value->getUserUid();
85+
if (!isset($matchesByUserUid[$userUid])) {
86+
$user = $this->userManager->get($userUid);
87+
$matchesByUserUid[$userUid] = [
88+
'user_uid' => $userUid,
89+
'display_name' => $this->resolveDisplayName($user, $userUid),
90+
'matched_fields' => [],
91+
];
92+
}
93+
94+
if (count($matchesByUserUid[$userUid]['matched_fields']) >= self::MAX_MATCHES_PER_USER) {
95+
continue;
96+
}
97+
98+
$matchesByUserUid[$userUid]['matched_fields'][] = [
99+
'field_key' => $definition->getFieldKey(),
100+
'field_label' => $definition->getLabel(),
101+
'value' => $scalarValue,
102+
];
103+
}
104+
105+
$matches = array_values($matchesByUserUid);
106+
usort($matches, static function (array $left, array $right): int {
107+
return [$left['display_name'], $left['user_uid']] <=> [$right['display_name'], $right['user_uid']];
108+
});
109+
110+
return [
111+
'total' => count($matches),
112+
'items' => array_slice($matches, $offset, $limit),
113+
];
114+
}
115+
116+
private function extractScalarValue(FieldValue $value): ?string {
117+
$decoded = json_decode($value->getValueJson(), true);
118+
$scalar = $decoded['value'] ?? null;
119+
if (is_array($scalar) || is_object($scalar) || $scalar === null) {
120+
return null;
121+
}
122+
123+
return trim((string)$scalar);
124+
}
125+
126+
private function isSearchableForActor(bool $fieldIsUserVisible, string $currentVisibility, bool $actorIsAdmin, bool $actorIsAuthenticated): bool {
127+
if ($actorIsAdmin) {
128+
return true;
129+
}
130+
131+
if (!$fieldIsUserVisible) {
132+
return false;
133+
}
134+
135+
return match (FieldVisibility::from($currentVisibility)) {
136+
FieldVisibility::PUBLIC => true,
137+
FieldVisibility::USERS => $actorIsAuthenticated,
138+
FieldVisibility::PRIVATE => false,
139+
};
140+
}
141+
142+
private function resolveDisplayName(?IUser $user, string $fallbackUserUid): string {
143+
if ($user === null) {
144+
return $fallbackUserUid;
145+
}
146+
147+
$displayName = trim($user->getDisplayName());
148+
return $displayName !== '' ? $displayName : $fallbackUserUid;
149+
}
150+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace OCA\ProfileFields\Search;
11+
12+
use OCP\IL10N;
13+
use OCP\IURLGenerator;
14+
use OCP\IUser;
15+
use OCP\Search\IProvider;
16+
use OCP\Search\ISearchQuery;
17+
use OCP\Search\SearchResult;
18+
use OCP\Search\SearchResultEntry;
19+
20+
class ProfileFieldSearchProvider implements IProvider {
21+
private const MIN_SEARCH_LENGTH = 2;
22+
23+
public function __construct(
24+
private IL10N $l10n,
25+
private IURLGenerator $urlGenerator,
26+
private ProfileFieldDirectorySearchService $searchService,
27+
) {
28+
}
29+
30+
#[\Override]
31+
public function getId(): string {
32+
return 'profile_fields.directory';
33+
}
34+
35+
#[\Override]
36+
public function getName(): string {
37+
return $this->l10n->t('Profile directory');
38+
}
39+
40+
#[\Override]
41+
public function getOrder(string $route, array $routeParameters): ?int {
42+
return str_starts_with($route, 'settings.Users.usersList') ? 35 : 65;
43+
}
44+
45+
#[\Override]
46+
public function search(IUser $user, ISearchQuery $query): SearchResult {
47+
$term = trim($query->getTerm());
48+
if (mb_strlen($term) < self::MIN_SEARCH_LENGTH) {
49+
return SearchResult::complete($this->getName(), []);
50+
}
51+
52+
$cursor = $this->normalizeCursor($query->getCursor());
53+
$result = $this->searchService->search($user, $term, $query->getLimit(), $cursor);
54+
$entries = array_map(fn (array $item): SearchResultEntry => $this->buildEntry($item), $result['items']);
55+
if ($cursor + count($entries) >= $result['total']) {
56+
return SearchResult::complete($this->getName(), $entries);
57+
}
58+
59+
return SearchResult::paginated(
60+
$this->getName(),
61+
$entries,
62+
$cursor + count($entries),
63+
);
64+
}
65+
66+
private function normalizeCursor(int|string|null $cursor): int {
67+
if ($cursor === null || $cursor === '') {
68+
return 0;
69+
}
70+
71+
if (is_int($cursor)) {
72+
return $cursor;
73+
}
74+
75+
if (preg_match('/^-?\d+$/', $cursor) === 1) {
76+
return (int)$cursor;
77+
}
78+
79+
return 0;
80+
}
81+
82+
/**
83+
* @param array{
84+
* user_uid: string,
85+
* display_name: string,
86+
* matched_fields: list<array{
87+
* field_key: string,
88+
* field_label: string,
89+
* value: string
90+
* }>
91+
* } $item
92+
*/
93+
private function buildEntry(array $item): SearchResultEntry {
94+
$thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', [
95+
'userId' => $item['user_uid'],
96+
'size' => 64,
97+
]);
98+
$resourceUrl = $this->urlGenerator->linkToRouteAbsolute('settings.Users.usersList') . '?search=' . rawurlencode($item['user_uid']);
99+
100+
return new SearchResultEntry(
101+
$thumbnailUrl,
102+
$item['display_name'],
103+
$this->buildSubline($item['matched_fields']),
104+
$resourceUrl,
105+
'icon-user',
106+
true,
107+
);
108+
}
109+
110+
/**
111+
* @param list<array{field_key: string, field_label: string, value: string}> $matchedFields
112+
*/
113+
private function buildSubline(array $matchedFields): string {
114+
$parts = array_map(
115+
static fn (array $match): string => sprintf('%s: %s', $match['field_label'], $match['value']),
116+
$matchedFields,
117+
);
118+
119+
return implode('', $parts);
120+
}
121+
}

src/views/AdminSettings.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
118118
<div class="profile-fields-admin__section-heading">
119119
<h4>Behavior</h4>
120120
<p>Choose how the field is stored, displayed and exposed by default.</p>
121+
<p>Fields visible to users can appear in global search when stored values are public or visible to authenticated users. Hidden fields and private values stay searchable only for administrators.</p>
121122
</div>
122123

123124
<div class="profile-fields-admin__grid">

0 commit comments

Comments
 (0)