Skip to content

Commit b31e436

Browse files
feat: activity support
Signed-off-by: Luka Trovic <[email protected]>
1 parent 5c257be commit b31e436

25 files changed

+1239
-56
lines changed

appinfo/info.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ Have a good time and manage whatever you want.
7171
<command>OCA\Tables\Command\CleanLegacy</command>
7272
<command>OCA\Tables\Command\TransferLegacyRows</command>
7373
</commands>
74+
<activity>
75+
<settings>
76+
<setting>OCA\Tables\Activity\SettingChanges</setting>
77+
</settings>
78+
<filters>
79+
<filter>OCA\Tables\Activity\Filter</filter>
80+
</filters>
81+
<providers>
82+
<provider>OCA\Tables\Activity\TablesProvider</provider>
83+
</providers>
84+
</activity>
7485
<navigations>
7586
<navigation>
7687
<name>Tables</name>

lib/Activity/ActivityManager.php

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Tables\Activity;
9+
10+
use OCA\Tables\AppInfo\Application;
11+
use OCA\Tables\Db\ColumnMapper;
12+
use OCA\Tables\Db\Row2;
13+
use OCA\Tables\Db\Row2Mapper;
14+
use OCA\Tables\Db\Table;
15+
use OCA\Tables\Db\TableMapper;
16+
use OCA\Tables\Service\ShareService;
17+
use OCP\Activity\IEvent;
18+
use OCP\Activity\IManager;
19+
use OCP\L10N\IFactory;
20+
use OCP\Server;
21+
use Psr\Log\LoggerInterface;
22+
23+
class ActivityManager {
24+
public const SUBJECT_PARAMS_MAX_LENGTH = 4000;
25+
public const SHORTENED_MAX_LENGTH = 2000;
26+
public const ROW_NAME_MAX_LENGTH = 20;
27+
28+
public const TABLES_OBJECT_TABLE = 'tables_table';
29+
public const TABLES_OBJECT_ROW = 'tables_row';
30+
31+
public const SUBJECT_TABLE_CREATE = 'table_create';
32+
public const SUBJECT_TABLE_UPDATE = 'table_update';
33+
public const SUBJECT_TABLE_UPDATE_TITLE = 'table_update_title';
34+
public const SUBJECT_TABLE_UPDATE_DESCRIPTION = 'table_update_description';
35+
public const SUBJECT_TABLE_DELETE = 'table_delete';
36+
37+
public const SUBJECT_ROW_CREATE = 'row_create';
38+
public const SUBJECT_ROW_UPDATE = 'row_update';
39+
public const SUBJECT_ROW_DELETE = 'row_delete';
40+
41+
public function __construct(
42+
private IManager $manager,
43+
private IFactory $l10nFactory,
44+
private TableMapper $tableMapper,
45+
private Row2Mapper $rowMapper,
46+
private ColumnMapper $columnMapper,
47+
private ShareService $shareService,
48+
private ?string $userId,
49+
) {
50+
}
51+
52+
public function triggerEvent($objectType, $object, $subject, $additionalParams = [], $author = null) {
53+
if ($author === null) {
54+
$author = $this->userId;
55+
}
56+
57+
try {
58+
$event = $this->createEvent($objectType, $object, $subject, $additionalParams, $author);
59+
60+
if ($event !== null) {
61+
$this->sendToUsers($event, $object);
62+
}
63+
} catch (\Exception $e) {
64+
// Ignore exception for undefined activities on update events
65+
}
66+
}
67+
68+
public function triggerUpdateEvents($objectType, ChangeSet $changeSet, $subject) {
69+
$previousEntity = $changeSet->getBefore();
70+
$entity = $changeSet->getAfter();
71+
$events = [];
72+
73+
if ($previousEntity !== null) {
74+
foreach ($entity->getUpdatedFields() as $field => $value) {
75+
$getter = 'get' . ucfirst($field);
76+
$subjectComplete = $subject . '_' . $field;
77+
$changes = [
78+
'before' => $previousEntity->$getter(),
79+
'after' => $entity->$getter()
80+
];
81+
if ($changes['before'] !== $changes['after']) {
82+
try {
83+
$event = $this->createEvent($objectType, $entity, $subjectComplete, $changes);
84+
if ($event !== null) {
85+
$events[] = $event;
86+
}
87+
} catch (\Exception $e) {
88+
// Ignore exception for undefined activities on update events
89+
}
90+
}
91+
}
92+
} else {
93+
try {
94+
$events = [$this->createEvent($objectType, $entity, $subject)];
95+
} catch (\Exception $e) {
96+
// Ignore exception for undefined activities on update events
97+
}
98+
}
99+
100+
foreach ($events as $event) {
101+
$this->sendToUsers($event, $entity);
102+
}
103+
}
104+
105+
private function createEvent($objectType, $object, $subject, $additionalParams = [], $author = null) {
106+
$objectTitle = '';
107+
108+
if ($object instanceof Table) {
109+
$objectTitle = $object->getTitle();
110+
$table = $object;
111+
} elseif ($object instanceof Row2) {
112+
$objectTitle = '#' . $object->getId();
113+
$table = $this->tableMapper->find($object->getTableId());
114+
} else {
115+
Server::get(LoggerInterface::class)->error('Could not create activity entry for ' . $subject . '. Invalid object.', (array)$object);
116+
return null;
117+
}
118+
119+
/**
120+
* Automatically fetch related details for subject parameters
121+
* depending on the subject
122+
*/
123+
$eventType = 'tables';
124+
$subjectParams = [
125+
'author' => $author === null ? $this->userId : $author,
126+
'table' => $table
127+
];
128+
switch ($subject) {
129+
// No need to enhance parameters since entity already contains the required data
130+
case self::SUBJECT_TABLE_CREATE:
131+
case self::SUBJECT_TABLE_UPDATE_TITLE:
132+
case self::SUBJECT_TABLE_UPDATE_DESCRIPTION:
133+
case self::SUBJECT_TABLE_DELETE:
134+
break;
135+
case self::SUBJECT_ROW_CREATE:
136+
case self::SUBJECT_ROW_UPDATE:
137+
case self::SUBJECT_ROW_DELETE:
138+
$subjectParams['row'] = $object;
139+
break;
140+
default:
141+
throw new \Exception('Unknown subject for activity.');
142+
break;
143+
}
144+
145+
if ($subject === self::SUBJECT_ROW_UPDATE) {
146+
$subjectParams['changeCols'] = [];
147+
foreach ($additionalParams['before'] as $index => $colData) {
148+
if ($additionalParams['after'][$index] === $colData) {
149+
continue; // No change, skip
150+
} else {
151+
try {
152+
$column = $this->columnMapper->find($colData['columnId']);
153+
$subjectParams['changeCols'][] = [
154+
'id' => $column->getId(),
155+
'name' => $column->getTitle(),
156+
'before' => $colData,
157+
'after' => $additionalParams['after'][$index]
158+
];
159+
} catch (\Exception $e) {
160+
Server::get(LoggerInterface::class)->error('Could not find column for activity entry.', [
161+
'columnId' => $colData['columnId'],
162+
'exception' => $e->getMessage()
163+
]);
164+
continue; // Skip if column not found
165+
}
166+
}
167+
}
168+
unset($additionalParams['before'], $additionalParams['after']);
169+
}
170+
171+
$event = $this->manager->generateEvent();
172+
$event->setApp('tables')
173+
->setType($eventType)
174+
->setAuthor($subjectParams['author'])
175+
->setObject($objectType, (int)$object->getId(), $objectTitle)
176+
->setSubject($subject, $subjectParams)
177+
->setTimestamp(time());
178+
179+
return $event;
180+
}
181+
182+
private function sendToUsers(IEvent $event, $object) {
183+
if ($object instanceof Table) {
184+
$tableId = $object->getId();
185+
$owner = $object->getOwnership();
186+
} elseif ($object instanceof Row2) {
187+
$tableId = $object->getTableId();
188+
$owner = $this->tableMapper->find($tableId)->getOwnership();
189+
} else {
190+
Server::get(LoggerInterface::class)->error('Could not send activity notify. Invalid object.', (array)$object);
191+
return null;
192+
}
193+
194+
$event->setAffectedUser($owner);
195+
$this->manager->publish($event);
196+
197+
foreach ($this->shareService->findSharedWithUserIds($tableId, 'table') as $userId) {
198+
$event->setAffectedUser($userId);
199+
200+
/** @noinspection DisconnectedForeachInstructionInspection */
201+
$this->manager->publish($event);
202+
}
203+
}
204+
205+
public function getActivityFormat($language, $subjectIdentifier, $subjectParams = [], $ownActivity = false) {
206+
$subject = '';
207+
$l = $this->l10nFactory->get(Application::APP_ID, $language);
208+
209+
switch ($subjectIdentifier) {
210+
case self::SUBJECT_TABLE_CREATE:
211+
$subject = $ownActivity ? $l->t('You have created a new table {table}'): $l->t('{user} has created a new table {table}');
212+
break;
213+
case self::SUBJECT_TABLE_DELETE:
214+
$subject = $ownActivity ? $l->t('You have deleted the table {table}') : $l->t('{user} has deleted the table {table}');
215+
break;
216+
case self::SUBJECT_TABLE_UPDATE_TITLE:
217+
$subject = $ownActivity ? $l->t('You have renamed the table {before} to {table}') : $l->t('{user} has renamed the table {before} to {table}');
218+
break;
219+
case self::SUBJECT_TABLE_UPDATE_DESCRIPTION:
220+
$subject = $ownActivity ? $l->t('You have updated the description of table {table} to {after}') : $l->t('{user} has updated the description of table {table} to {after}');
221+
break;
222+
case self::SUBJECT_ROW_CREATE:
223+
$subject = $ownActivity ? $l->t('You have created a new row {row} in table {table}') : $l->t('{user} has created a new row {row} in table {table}');
224+
break;
225+
case self::SUBJECT_ROW_UPDATE:
226+
$columns = '';
227+
foreach ($subjectParams['changeCols'] as $index => $changeCol) {
228+
$columns .= '{col-' . $changeCol['id'] . '}';
229+
if ($index < count($subjectParams['changeCols']) - 1) {
230+
$columns .= ', ';
231+
}
232+
}
233+
$subject = $ownActivity ? $l->t('You have updated cell(s) ' . $columns . ' on row {row} in table {table}') : $l->t('{user} has updated cell(s) ' . $columns . ' on row {row} in table {table}');
234+
break;
235+
case self::SUBJECT_ROW_DELETE:
236+
$subject = $ownActivity ? $l->t('You have deleted the row {row} in table {table}') : $l->t('{user} has deleted the row {row} in table {table}');
237+
break;
238+
default:
239+
break;
240+
}
241+
242+
return $subject;
243+
}
244+
}

lib/Activity/ChangeSet.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Tables\Activity;
9+
10+
use OCP\AppFramework\Db\Entity;
11+
12+
class ChangeSet implements \JsonSerializable {
13+
14+
public function __construct(
15+
private ?Entity $before = null,
16+
private ?Entity $after = null,
17+
) {
18+
if ($before !== null) {
19+
$this->setBefore($before);
20+
}
21+
if ($after !== null) {
22+
$this->setAfter($after);
23+
}
24+
}
25+
26+
public function setBefore($before) {
27+
$this->before = clone $before;
28+
}
29+
30+
public function setAfter($after) {
31+
$this->after = clone $after;
32+
}
33+
34+
public function getBefore() {
35+
return $this->before;
36+
}
37+
38+
public function getAfter() {
39+
return $this->after;
40+
}
41+
42+
public function jsonSerialize(): array {
43+
return [
44+
'before' => $this->getBefore(),
45+
'after' => $this->getAfter()
46+
];
47+
}
48+
}

lib/Activity/Filter.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Tables\Activity;
9+
10+
use OCP\Activity\IFilter;
11+
use OCP\IL10N;
12+
use OCP\IURLGenerator;
13+
14+
class Filter implements IFilter {
15+
private $l10n;
16+
private $urlGenerator;
17+
18+
public function __construct(
19+
IL10N $l10n,
20+
IURLGenerator $urlGenerator,
21+
) {
22+
$this->l10n = $l10n;
23+
$this->urlGenerator = $urlGenerator;
24+
}
25+
26+
/**
27+
* @return string Lowercase a-z and underscore only identifier
28+
* @since 11.0.0
29+
*/
30+
public function getIdentifier(): string {
31+
return 'tables';
32+
}
33+
34+
/**
35+
* @return string A translated string
36+
* @since 11.0.0
37+
*/
38+
public function getName(): string {
39+
return $this->l10n->t('Tables');
40+
}
41+
42+
/**
43+
* @return int whether the filter should be rather on the top or bottom of
44+
* the admin section. The filters are arranged in ascending order of the
45+
* priority values. It is required to return a value between 0 and 100.
46+
* @since 11.0.0
47+
*/
48+
public function getPriority(): int {
49+
return 90;
50+
}
51+
52+
/**
53+
* @return string Full URL to an icon, empty string when none is given
54+
* @since 11.0.0
55+
*/
56+
public function getIcon(): string {
57+
return $this->urlGenerator->imagePath('tables', 'app-dark.svg');
58+
}
59+
60+
/**
61+
* @param string[] $types
62+
* @return string[] An array of allowed apps from which activities should be displayed
63+
* @since 11.0.0
64+
*/
65+
public function filterTypes(array $types): array {
66+
return $types;
67+
}
68+
69+
/**
70+
* @return string[] An array of allowed apps from which activities should be displayed
71+
* @since 11.0.0
72+
*/
73+
public function allowedApps(): array {
74+
return ['tables'];
75+
}
76+
}

0 commit comments

Comments
 (0)