Skip to content
Open
74 changes: 28 additions & 46 deletions lib/Controller/WorkspaceController.php
Original file line number Diff line number Diff line change
@@ -1,33 +1,12 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);
/**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\Text\Controller;

use Exception;
Expand All @@ -43,7 +22,6 @@
use OCP\DirectEditing\IManager as IDirectEditingManager;
use OCP\DirectEditing\RegisterDirectEditorEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
Expand Down Expand Up @@ -73,12 +51,16 @@
}

/**
* Checks for available files in the current folder and returns required details to present
* the rich workspace
* Checks for available files in the current folder and returns required
* details to present the rich workspace.
*
* Returns 200 with file metadata and folder permissions if a README is found,
* or 404 with folder permissions if not (so the client can still offer to create one).
*
* @param string $path Path relative to the user's root folder
*/
#[NoAdminRequired]
public function folder(string $path = '/'): DataResponse {
/** */
try {
/** @psalm-suppress PossiblyNullArgument */
$userFolder = $this->rootFolder->getUserFolder($this->userId);
Expand Down Expand Up @@ -117,9 +99,11 @@
}

/**
* Checks for available files in the current folder and returns required details to present
* the rich workspace
* @api
* Checks for available files in a publicly shared folder and returns required
* details to present the rich workspace.
*
* @param string $shareToken Public share token
* @param string $path Path relative to the share root
*/
#[NoAdminRequired]
#[PublicPage]
Expand All @@ -140,7 +124,7 @@
}

$shareNode = $share->getNode();
$node = $shareNode instanceof File ? $shareNode : $shareNode->get($path);

Check failure on line 127 in lib/Controller/WorkspaceController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Controller/WorkspaceController.php:127:34: UndefinedClass: Class, interface or enum named OCA\Text\Controller\File does not exist (see https://psalm.dev/019)
if ($node instanceof Folder) {
$file = $this->workspaceService->getFile($node);
if ($file === null) {
Expand All @@ -166,6 +150,14 @@
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}

/**
* Returns a direct editing URL for the workspace README in the given folder.
*
* If a README file already exists, opens it for editing. If not, creates a new
* file using the first entry of getSupportedFilenames() as the default name.
*
* @param string $path Path to the folder, relative to the user's root folder
*/
#[NoAdminRequired]
public function direct(string $path): DataResponse {
$this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager));
Expand All @@ -174,9 +166,13 @@
/** @psalm-suppress PossiblyNullArgument */
$folder = $this->rootFolder->getUserFolder($this->userId)->get($path);
if ($folder instanceof Folder) {
$file = $this->getFile($folder);
$file = $this->workspaceService->getFile($folder);
if ($file === null) {
$token = $this->directEditingManager->create($path . '/' . $this->workspaceService->getSupportedFilenames()[0], Application::APP_NAME, TextDocumentCreator::CREATOR_ID);
$token = $this->directEditingManager->create(
$path . '/' . $this->workspaceService->getSupportedFilenames()[0],
Application::APP_NAME,
TextDocumentCreator::CREATOR_ID
);
} else {
$token = $this->directEditingManager->open($path . '/' . $file->getName(), Application::APP_NAME);
}
Expand All @@ -191,18 +187,4 @@

return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}

private function getFile(Folder $folder): ?File {
$file = null;
foreach ($this->workspaceService->getSupportedFilenames() as $filename) {
try {
$node = $folder->get($filename);
if ($node instanceof File) {
$file = $node;
}
} catch (NotFoundException) {
}
}
return $file;
}
}
15 changes: 12 additions & 3 deletions lib/Service/WorkspaceService.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
Expand All @@ -8,6 +9,7 @@

namespace OCA\Text\Service;

use OCP\Files\Cache\ICacheEntry;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\NotFoundException;
Expand All @@ -28,16 +30,23 @@ public function __construct(IL10N $l10n) {
}

public function getFile(Folder $folder): ?File {
try {
$cache = $folder->getStorage()->getCache();
$internalPath = $folder->getInternalPath();
} catch (StorageInvalidException) {
return null;
}

foreach ($this->getSupportedFilenames() as $filename) {
try {
$exists = $folder->getStorage()->getCache()->get($folder->getInternalPath() . '/' . $filename);
if ($exists) {
$cacheEntry = $cache->get($internalPath . '/' . $filename);
if ($cacheEntry !== false && $cacheEntry->getMimeType() !== ICacheEntry::DIRECTORY_MIMETYPE) {
$file = $folder->get($filename);
if ($file instanceof File) {
return $file;
}
}
} catch (NotFoundException|StorageInvalidException) {
} catch (NotFoundException) {
continue;
}
}
Expand Down
106 changes: 106 additions & 0 deletions tests/unit/Service/WorkspaceServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Text\Tests\Service;

use OCA\Text\Service\WorkspaceService;
use OCP\Files\Cache\ICache;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\Storage\IStorage;
use OCP\Files\StorageInvalidException;
use OCP\IL10N;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class WorkspaceServiceTest extends TestCase {
private IL10N&MockObject $l10n;
private WorkspaceService $workspaceService;

protected function setUp(): void {
parent::setUp();

$this->l10n = $this->createMock(IL10N::class);
$this->l10n->method('t')->with('Readme')->willReturn('Readme');

$this->workspaceService = new WorkspaceService($this->l10n);
}

public function testGetFileReturnsFirstMatchingFileInPriorityOrder(): void {
$folder = $this->createMock(Folder::class);
$storage = $this->createMock(IStorage::class);
$cache = $this->createMock(ICache::class);
$readmeFile = $this->createMock(File::class);

$folder->method('getStorage')->willReturn($storage);
$storage->method('getCache')->willReturn($cache);
$folder->method('getInternalPath')->willReturn('docs');

$readmeEntry = $this->createMock(ICacheEntry::class);
$readmeEntry->method('getMimeType')->willReturn('text/markdown');

$uppercaseEntry = $this->createMock(ICacheEntry::class);
$uppercaseEntry->method('getMimeType')->willReturn('text/markdown');

$cache->method('get')->willReturnMap([
['docs/Readme.md', $readmeEntry],
['docs/README.md', $uppercaseEntry],
['docs/readme.md', false],
]);

$folder->expects($this->once())
->method('get')
->with('Readme.md')
->willReturn($readmeFile);

$result = $this->workspaceService->getFile($folder);

$this->assertSame($readmeFile, $result);
}

public function testGetFileSkipsDirectoryCacheEntries(): void {
$folder = $this->createMock(Folder::class);
$storage = $this->createMock(IStorage::class);
$cache = $this->createMock(ICache::class);

$folder->method('getStorage')->willReturn($storage);
$storage->method('getCache')->willReturn($cache);
$folder->method('getInternalPath')->willReturn('docs');

$directoryEntry = $this->createMock(ICacheEntry::class);
$directoryEntry->method('getMimeType')->willReturn(ICacheEntry::DIRECTORY_MIMETYPE);

$cache->method('get')->willReturnMap([
['docs/Readme.md', $directoryEntry],
['docs/README.md', false],
['docs/readme.md', false],
]);

$folder->expects($this->never())->method('get');

$result = $this->workspaceService->getFile($folder);

$this->assertNull($result);
}

public function testGetFileReturnsNullWhenStorageIsInvalid(): void {
$folder = $this->createMock(Folder::class);

$folder->method('getStorage')
->willThrowException(new StorageInvalidException());

$folder->expects($this->never())->method('getInternalPath');
$folder->expects($this->never())->method('get');

$result = $this->workspaceService->getFile($folder);

$this->assertNull($result);
}
}
Loading