Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/phpunit-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:

- name: Get version matrix
id: versions
uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.0.0
uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.0.0

unit-tests:
runs-on: ubuntu-latest
Expand Down
6 changes: 6 additions & 0 deletions lib/Controller/Helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ public function getNotesAndCategories(
// if the chunk does not contain all remaining notes, then generate new chunk cursor
$newChunkCursor = $numPendingNotes ? ChunkCursor::fromNote($lastUpdate, end($chunkedNotes)) : null;

$notesById = [];
foreach ($chunkedNotes as $chunked) {
$notesById[$chunked->note->getId()] = $chunked->note->getFile();
}
$this->notesService->preloadShareTypes($data['folders'], $notesById);

// load data for the current chunk
$notesData = array_map(function (MetaNote $m) use ($exclude) {
return $this->getNoteData($m->note, $exclude, $m->meta);
Expand Down
141 changes: 128 additions & 13 deletions lib/Service/NoteUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -324,22 +324,137 @@ public function ensureNoteIsWritable(Node $node) : void {
}
}

/**
* Share types a note is reported as shared through, in this order.
*
* TYPE_USERGROUP is left out: it is the per-user half of a group share and
* would double-report one share.
*
* @var list<int>
* @psalm-suppress DeprecatedConstant TYPE_SCIENCEMESH is reported for shares
* that already exist, so it cannot be dropped from the list
*/
private const SHARE_TYPES = [
IShare::TYPE_USER,
IShare::TYPE_GROUP,
IShare::TYPE_LINK,
IShare::TYPE_REMOTE,
IShare::TYPE_EMAIL,
IShare::TYPE_ROOM,
IShare::TYPE_DECK,
IShare::TYPE_SCIENCEMESH,
];

/**
* Share types per file id, or null when nothing has been preloaded.
*
* A file id present with an empty list means "looked up, not shared".
*
* @var array<int, list<int>>|null
*/
private ?array $cachedShareTypes = null;

/**
* Preload the share types for a whole notes tree.
*
* Every folder has to be passed in: getSharesInFolder() only looks at a
* folder's direct children and the server rejects $shallow = false. It
* reports what one user has shared, so a lookup answers for a note only
* when that note is a direct child of the folder and carries the same
* owner the lookup ran as. A note the walk found elsewhere β€” one shared
* into the tree on its own, beside a folder from the same owner β€” is left
* out of the cache and falls back to a per-note lookup.
*
* @param list<Folder> $folders every folder of the notes tree, the notes folder included
* @param array<int, File> $files the notes the caller is going to ask about, by id
*/
public function loadShareTypes(array $folders, array $files): void {
$this->cachedShareTypes = null;

// one getSharesInFolder() call costs about what one note costs through
// the eight getSharesBy() calls it replaces
if (count($files) < count($folders)) {
return;
}

$byOwnerAndParent = self::indexByParent($files);
$collected = [];

foreach ($folders as $folder) {
$owner = $folder->getOwner();
if ($owner === null) {
return;
}

$uid = $owner->getUID();
$covered = $byOwnerAndParent[$uid][rtrim($folder->getPath(), '/')] ?? [];
if ($covered === []) {
continue;
}

$collected += array_fill_keys(array_keys($covered), []);
$sharesByFileId = $this->shareManager->getSharesInFolder($uid, $folder, false);
foreach ($sharesByFileId as $fileId => $shares) {
if (!isset($covered[$fileId])) {
continue;
}
foreach ($shares as $share) {
$collected[$fileId][$share->getShareType()] = true;
}
}
}

$this->cachedShareTypes = array_map(
static fn (array $present): array
=> array_values(array_intersect(self::SHARE_TYPES, array_keys($present))),
$collected,
);
}

/**
* The notes grouped by the lookup that can answer for them: their owner,
* then the folder they sit directly in.
*
* @param array<int, File> $files
* @return array<string, array<string, array<int, true>>>
*/
private static function indexByParent(array $files): array {
$byOwnerAndParent = [];
foreach ($files as $fileId => $file) {
$owner = $file->getOwner()?->getUID();
if ($owner !== null) {
$byOwnerAndParent[$owner][dirname($file->getPath())][$fileId] = true;
}
}
return $byOwnerAndParent;
}

/**
* @return list<int> share types of $file, in SHARE_TYPES order
*/
public function getShareTypes(File $file): array {
$userId = $file->getOwner()->getUID();
$requestedShareTypes = [
IShare::TYPE_USER,
IShare::TYPE_GROUP,
IShare::TYPE_LINK,
IShare::TYPE_REMOTE,
IShare::TYPE_EMAIL,
IShare::TYPE_ROOM,
IShare::TYPE_DECK,
// FIXME: Move to constant once Nextcloud 26 is the minimum supported version
15, // IShare::TYPE_SCIENCEMESH,
];
$fileId = $file->getId();
if ($this->cachedShareTypes !== null && array_key_exists($fileId, $this->cachedShareTypes)) {
return $this->cachedShareTypes[$fileId];
}
return $this->fetchShareTypes($file);
}

/**
* Per-file fallback for callers that have not preloaded a tree.
*
* @return list<int>
*/
private function fetchShareTypes(File $file): array {
$owner = $file->getOwner();
if ($owner === null) {
return [];
}

$userId = $owner->getUID();
$shareTypes = [];

foreach ($requestedShareTypes as $shareType) {
foreach (self::SHARE_TYPES as $shareType) {
$shares = $this->shareManager->getSharesBy($userId, $shareType, $file, false, 1, 0);

if (count($shares)) {
Expand Down
18 changes: 15 additions & 3 deletions lib/Service/NotesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,17 @@ public function getAll(string $userId, bool $autoCreateNotesFolder = false) : ar
}, $data['files']);
} catch (NotesFolderException $e) {
$notes = [];
$data = [ 'categories' => [] ];
$data = [ 'categories' => [], 'folders' => [] ];
}
return [ 'notes' => $notes, 'categories' => $data['categories'] ];
return [ 'notes' => $notes, 'categories' => $data['categories'], 'folders' => $data['folders'] ];
}

/**
* @param list<Folder> $folders the walked folders, from getAll()
* @param array<int, File> $notes the notes that will be serialised, by id
*/
public function preloadShareTypes(array $folders, array $notes) : void {
$this->noteUtil->loadShareTypes($folders, $notes);
}

public function getTopNotes(string $userId) : array {
Expand Down Expand Up @@ -244,6 +252,8 @@ private function getNotesFolder(string $userId, bool $create = true) : Folder {

/**
* gather note files in given directory and all subdirectories
*
* @return array{files: array<int, File>, categories: array<int, string>, folders: list<Folder>}
*/
private static function gatherNoteFiles(
string $customExtension,
Expand All @@ -254,6 +264,7 @@ private static function gatherNoteFiles(
$data = [
'files' => [],
'categories' => [],
'folders' => [$folder],
];
$nodes = $folder->getDirectoryListing();
foreach ($nodes as $node) {
Expand All @@ -271,7 +282,8 @@ private static function gatherNoteFiles(
$data_sub = self::gatherNoteFiles($customExtension, $node, $showHidden, $subCategory . '/');
$data['files'] = $data['files'] + $data_sub['files'];
$data['categories'] = array_merge($data['categories'], $data_sub['categories']);
} elseif (self::isNote($node, $customExtension)) {
$data['folders'] = array_merge($data['folders'], $data_sub['folders']);
} elseif ($node instanceof File && self::isNote($node, $customExtension)) {
$data['files'][$node->getId()] = $node;
}
}
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/NotesTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
use Psr\Log\LoggerInterface;

abstract class NotesTestCase extends TestCase {
protected function createNoteUtil(): NoteUtil {
protected function createNoteUtil(?IManager $shareManager = null): NoteUtil {
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnArgument(0);

Expand All @@ -34,7 +34,7 @@ protected function createNoteUtil(): NoteUtil {
$this->createMock(IRootFolder::class),
$db,
$this->createMock(TagService::class),
$this->createMock(IManager::class),
$shareManager ?? $this->createMock(IManager::class),
$this->createMock(IUserSession::class),
$this->createMock(SettingsService::class),
);
Expand Down
Loading
Loading