diff --git a/cypress/e2e/api/SessionApi.spec.js b/cypress/e2e/api/SessionApi.spec.js index 9ceb84a9f7b..8bcdd132dbb 100644 --- a/cypress/e2e/api/SessionApi.spec.js +++ b/cypress/e2e/api/SessionApi.spec.js @@ -37,7 +37,10 @@ describe('The session Api', function() { it('returns connection', function() { cy.openFileConnection({ fileId }).then(({ connection }) => { - cy.wrap(connection).its('documentId').should('be.greaterThan', 0) + cy.wrap(connection) + .its('documentId') + .then((id) => Number.parseInt(id)) + .should('be.greaterThan', 1_000_000) // snowflake ids have more than 20 bit. cy.closeConnection(connection) }) }) diff --git a/cypress/e2e/api/UsersApi.spec.js b/cypress/e2e/api/UsersApi.spec.js index c8ca4cda3d2..c7482f0f46e 100644 --- a/cypress/e2e/api/UsersApi.spec.js +++ b/cypress/e2e/api/UsersApi.spec.js @@ -22,7 +22,10 @@ describe('The user mention API', function() { }) it('has a valid connection', function() { - cy.get('@connection').its('documentId').should('be.greaterThan', 0) + cy.get('@connection') + .its('documentId') + .then((id) => Number.parseInt(id)) + .should('be.greaterThan', 1_000_000) // snowflake ids have more than 20 bit. cy.closeConnection(this.connection) }) diff --git a/lib/Command/ResetDocument.php b/lib/Command/ResetDocument.php index a008dd14391..a04c095aa90 100644 --- a/lib/Command/ResetDocument.php +++ b/lib/Command/ResetDocument.php @@ -75,7 +75,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($all) { $fileIds = []; foreach ($this->documentService->getAll() as $document) { - $fileIds[] = $document->getId(); + if ($document->getContextType() === 'file') { + $fileIds[] = $document->getContextId(); + } } } else { $fileIds = [$fileId]; diff --git a/lib/Controller/AttachmentController.php b/lib/Controller/AttachmentController.php index 69e659f495b..6af1ded8978 100644 --- a/lib/Controller/AttachmentController.php +++ b/lib/Controller/AttachmentController.php @@ -9,12 +9,16 @@ namespace OCA\Text\Controller; use Exception; +use OCA\Text\Context\ContextManager; +use OCA\Text\Context\IContext; +use OCA\Text\Db\DocumentMapper; use OCA\Text\Exception\InvalidSessionException; use OCA\Text\Exception\UploadException; use OCA\Text\Middleware\Attribute\RequireDocumentSession; use OCA\Text\Middleware\Attribute\RequireDocumentSessionOrUserOrShareToken; use OCA\Text\Service\AttachmentService; use OCP\AppFramework\ApiController; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -25,6 +29,7 @@ use OCP\Constants; use OCP\Files\IMimeTypeDetector; use OCP\Files\InvalidPathException; +use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; @@ -70,6 +75,8 @@ public function __construct( private IMimeTypeDetector $mimeTypeDetector, private AttachmentService $attachmentService, private ShareManager $shareManager, + private DocumentMapper $documentMapper, + private ContextManager $contextManager, ) { parent::__construct($appName, $request); } @@ -77,15 +84,15 @@ public function __construct( #[NoAdminRequired] #[PublicPage] #[RequireDocumentSessionOrUserOrShareToken] - public function getAttachmentList(string $shareToken = ''): DataResponse { - $documentId = $this->getDocumentId(); + public function getAttachmentList(int $fileId = 0, string $shareToken = ''): DataResponse { + $auth = $this->getAuth($shareToken, false); + $context = $this->getContext($auth, $fileId); try { $session = $this->getSession(); } catch (InvalidSessionException) { $session = null; } - $auth = $this->getAuth($shareToken, false); - $attachments = $this->attachmentService->getAttachmentList($documentId, $auth, $session); + $attachments = $this->attachmentService->getAttachmentList($context, $auth, $session); return new DataResponse($attachments); } @@ -94,9 +101,9 @@ public function getAttachmentList(string $shareToken = ''): DataResponse { #[RequireDocumentSession] public function insertAttachmentFile(string $filePath): DataResponse { $user = $this->getUser(); - + $context = $this->getContext($user); try { - $insertResult = $this->attachmentService->insertAttachmentFile($this->getSession()->getDocumentId(), $filePath, $user); + $insertResult = $this->attachmentService->insertAttachmentFile($context, $filePath, $user); if (isset($insertResult['error'])) { return new DataResponse($insertResult, Http::STATUS_BAD_REQUEST); } else { @@ -112,8 +119,6 @@ public function insertAttachmentFile(string $filePath): DataResponse { #[PublicPage] #[RequireDocumentSession] public function uploadAttachment(string $token = ''): DataResponse { - $documentId = $this->getSession()->getDocumentId(); - try { $file = $this->getUploadedFile('file'); if (isset($file['tmp_name'], $file['name'], $file['type'])) { @@ -123,7 +128,8 @@ public function uploadAttachment(string $token = ''): DataResponse { } $newFileName = $file['name']; $auth = $this->getAuth($token); - $uploadResult = $this->attachmentService->uploadAttachment($documentId, $newFileName, $newFileResource, $auth); + $context = $this->getContext($auth); + $uploadResult = $this->attachmentService->uploadAttachment($context, $newFileName, $newFileResource, $auth); if (isset($uploadResult['error'])) { return new DataResponse($uploadResult, Http::STATUS_BAD_REQUEST); } else { @@ -145,11 +151,11 @@ public function uploadAttachment(string $token = ''): DataResponse { #[PublicPage] #[RequireDocumentSession] public function createAttachment(): DataResponse { - $documentId = $this->getSession()->getDocumentId(); + $user = $this->getUser(); + $context = $this->getContext($user); try { - $user = $this->getUser(); $newFileName = $this->request->getParam('fileName', 'text.md'); - $createResult = $this->attachmentService->createAttachmentFile($documentId, $newFileName, $user); + $createResult = $this->attachmentService->createAttachmentFile($context, $newFileName, $user); if (isset($createResult['error'])) { return new DataResponse($createResult, Http::STATUS_BAD_REQUEST); } else { @@ -200,13 +206,16 @@ private function getUploadedFile(string $key): array { #[PublicPage] #[NoCSRFRequired] #[RequireDocumentSessionOrUserOrShareToken] - public function getImageFile(string $imageFileName, string $shareToken = '', - int $preferRawImage = 0): DataResponse|DataDownloadResponse { - $documentId = $this->getDocumentId(); - + public function getImageFile( + string $imageFileName, + string $shareToken = '', + int $preferRawImage = 0, + int $fileId = 0, + ): DataResponse|DataDownloadResponse { try { $auth = $this->getAuth($shareToken, false); - $imageFile = $this->attachmentService->getImageFile($documentId, $imageFileName, $auth, $preferRawImage === 1); + $context = $this->getContext($auth, $fileId); + $imageFile = $this->attachmentService->getImageFile($context, $imageFileName, $preferRawImage === 1); if ($imageFile !== null) { $response = new DataDownloadResponse( $imageFile->getContent(), @@ -235,12 +244,15 @@ public function getImageFile(string $imageFileName, string $shareToken = '', #[PublicPage] #[NoCSRFRequired] #[RequireDocumentSessionOrUserOrShareToken] - public function getMediaFile(string $mediaFileName, string $shareToken = ''): DataResponse|DataDownloadResponse { - $documentId = $this->getDocumentId(); - + public function getMediaFile( + string $mediaFileName, + string $shareToken = '', + int $fileId = 0, + ): DataResponse|DataDownloadResponse { try { $auth = $this->getAuth($shareToken, false); - $mediaFile = $this->attachmentService->getMediaFile($documentId, $mediaFileName, $auth); + $context = $this->getContext($auth, $fileId); + $mediaFile = $this->attachmentService->getMediaFile($context, $mediaFileName); return $mediaFile !== null ? new DataDownloadResponse( $mediaFile->getContent(), @@ -262,12 +274,11 @@ public function getMediaFile(string $mediaFileName, string $shareToken = ''): Da #[PublicPage] #[NoCSRFRequired] #[RequireDocumentSessionOrUserOrShareToken] - public function getMediaFilePreview(string $mediaFileName, string $shareToken = '') { - $documentId = $this->getDocumentId(); - + public function getMediaFilePreview(string $mediaFileName, string $shareToken = '', int $fileId = 0) { try { $auth = $this->getAuth($shareToken, false); - $preview = $this->attachmentService->getMediaFilePreview($documentId, $mediaFileName, $auth); + $context = $this->getContext($auth, $fileId); + $preview = $this->attachmentService->getMediaFilePreview($context, $mediaFileName); if ($preview === null) { return new DataResponse('', Http::STATUS_NOT_FOUND); } @@ -286,6 +297,25 @@ public function getMediaFilePreview(string $mediaFileName, string $shareToken = return new DataResponse('', Http::STATUS_NOT_FOUND); } + private function getContext(IShare|IUser $auth, int $fileId = 0): IContext { + try { + $documentId = $this->getDocumentId(); + } catch (InvalidSessionException $e) { + // Fallback for scenarios without a session. (MarkdownContentEditor with fileId) + return $this->contextManager->getContext('file', $fileId, $auth); + } + try { + $document = $this->documentMapper->find($documentId); + } catch (DoesNotExistException $e) { + throw new NotFoundException('Text file for document ' + . $documentId + . ' was not found.', 0, $e); + } + $type = $document->getContextType(); + $id = $document->getContextId(); + return $this->contextManager->getContext($type, $id, $auth); + } + private function getAuth(string $shareToken, bool $updatePermissionRequired = true): IShare|IUser { if ($shareToken !== '') { try { diff --git a/lib/Controller/ISessionAwareController.php b/lib/Controller/ISessionAwareController.php index 87ebd86289a..566ddc85c2f 100644 --- a/lib/Controller/ISessionAwareController.php +++ b/lib/Controller/ISessionAwareController.php @@ -14,8 +14,8 @@ interface ISessionAwareController { public function getSession(): Session; public function setSession(Session $session): void; - public function getDocumentId(): int; - public function setDocumentId(int $documentId): void; + public function getDocumentId(): string; + public function setDocumentId(string $documentId): void; public function getDocument(): Document; public function setDocument(Document $document): void; public function getUser(): IUser; diff --git a/lib/Controller/PublicSessionController.php b/lib/Controller/PublicSessionController.php index 1b645edc344..6332b7dd3cb 100644 --- a/lib/Controller/PublicSessionController.php +++ b/lib/Controller/PublicSessionController.php @@ -84,7 +84,7 @@ public function create(string $token, ?string $filePath = null, ?string $baseVer #[NoAdminRequired] #[PublicPage] - public function close(int $documentId, int $sessionId, string $sessionToken): DataResponse { + public function close(string $documentId, int $sessionId, string $sessionToken): DataResponse { return $this->apiService->close($documentId, $sessionId, $sessionToken, $this->getShare()); } diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index 1f66e72f3b0..c18ccb3ba1c 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -73,7 +73,7 @@ public function create(string $type, int $id, ?string $baseVersionEtag = null): #[NoAdminRequired] #[PublicPage] - public function close(int $documentId, int $sessionId, string $sessionToken): DataResponse { + public function close(string $documentId, int $sessionId, string $sessionToken): DataResponse { // We also want this to work with a session that has already been closed. // So we cannot rely on RequireDocumentSession to retrieve the user. $user = $this->userSession->getUser(); @@ -127,7 +127,7 @@ public function save(int $version, string $autosaveContent, string $documentStat #[RequireDocumentSession] #[UserRateLimit(limit: 5, period: 120)] public function mention(string $mention): DataResponse { - if ($this->getSession()->isGuest() && !$this->sessionService->isUserInDocument($this->getDocument()->getId(), $mention)) { + if ($this->getSession()->isGuest() && !$this->sessionService->isUserInDocument($this->getDocumentId(), $mention)) { return new DataResponse([], 403); } diff --git a/lib/Controller/TSessionAwareController.php b/lib/Controller/TSessionAwareController.php index 84afb600cb9..e3b9cf51aa6 100644 --- a/lib/Controller/TSessionAwareController.php +++ b/lib/Controller/TSessionAwareController.php @@ -16,7 +16,7 @@ trait TSessionAwareController { private ?Session $textSession = null; - private ?int $documentId = null; + private ?string $documentId = null; private ?Document $document = null; private ?IUser $user = null; @@ -24,7 +24,7 @@ public function setSession(?Session $session): void { $this->textSession = $session; } - public function setDocumentId(int $documentId): void { + public function setDocumentId(string $documentId): void { $this->documentId = $documentId; } @@ -50,7 +50,7 @@ public function getSession(): Session { /** * @throws InvalidSessionException */ - public function getDocumentId(): int { + public function getDocumentId(): string { if ($this->documentId === null) { throw new InvalidSessionException(); } diff --git a/lib/Cron/Cleanup.php b/lib/Cron/Cleanup.php index aadcb115a9e..bffe1dc37d6 100644 --- a/lib/Cron/Cleanup.php +++ b/lib/Cron/Cleanup.php @@ -34,7 +34,9 @@ public function __construct( protected function run($argument): void { $this->logger->debug('Run cleanup job for text documents'); foreach ($this->documentService->getAllWithNoActiveSession() as $document) { - $this->attachmentService->cleanupAttachments($document->getId()); + if ($document->getContextType() === 'file') { + $this->attachmentService->cleanupAttachments($document->getContextId()); + } } $this->logger->debug('Run cleanup job for text sessions'); diff --git a/lib/Db/Document.php b/lib/Db/Document.php index 58c0f227265..2e481cf36d4 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -1,5 +1,7 @@ addType('currentVersion', 'integer'); - $this->addType('lastSavedVersion', 'integer'); - $this->addType('lastSavedVersionTime', 'integer'); - $this->addType('initialVersion', 'integer'); - $this->addType('checksum', 'string'); - $this->addType('contextType', 'string'); - $this->addType('contextId', 'integer'); + $this->addType('id', Types::STRING); + $this->addType('currentVersion', Types::INTEGER); + $this->addType('lastSavedVersion', Types::INTEGER); + $this->addType('lastSavedVersionTime', Types::INTEGER); + $this->addType('initialVersion', Types::INTEGER); + $this->addType('checksum', Types::STRING); + $this->addType('contextType', Types::STRING); + $this->addType('contextId', Types::INTEGER); } public function jsonSerialize(): array { return [ - 'id' => $this->getId(), + 'id' => $this->id, 'lastSavedVersion' => $this->lastSavedVersion, 'lastSavedVersionTime' => $this->lastSavedVersionTime, 'baseVersionEtag' => $this->baseVersionEtag, diff --git a/lib/Db/DocumentMapper.php b/lib/Db/DocumentMapper.php index f94c0564faf..825958df6d9 100644 --- a/lib/Db/DocumentMapper.php +++ b/lib/Db/DocumentMapper.php @@ -24,7 +24,7 @@ public function __construct(IDBConnection $db) { * @return Document * @throws DoesNotExistException */ - public function find(int $documentId): Document { + public function find(string $documentId): Document { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); diff --git a/lib/Db/Session.php b/lib/Db/Session.php index 7bcf39d6c48..74b61fc1f0c 100644 --- a/lib/Db/Session.php +++ b/lib/Db/Session.php @@ -10,6 +10,7 @@ use JsonSerializable; use OCA\Text\Exception\InvalidSessionException; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method void setUserId(?string $userId) @@ -23,8 +24,8 @@ * @method void setLastAwarenessMessage(string $message) * @method int getLastContact() * @method void setLastContact(int $getTime) - * @method int getDocumentId() - * @method void setDocumentId(int $documentId) + * @method string getDocumentId() + * @method void setDocumentId(string $documentId) */ class Session extends Entity implements JsonSerializable { public $id; @@ -34,11 +35,11 @@ class Session extends Entity implements JsonSerializable { protected ?string $guestName = null; protected ?string $lastAwarenessMessage = ''; protected int $lastContact = 0; - protected int $documentId = 0; + protected string $documentId = ''; public function __construct() { - $this->addType('documentId', 'integer'); - $this->addType('lastContact', 'integer'); + $this->addType('documentId', Types::STRING); + $this->addType('lastContact', Types::INTEGER); } public function isGuest(): bool { diff --git a/lib/Db/SessionMapper.php b/lib/Db/SessionMapper.php index cfe596447d0..9feb630ccb7 100644 --- a/lib/Db/SessionMapper.php +++ b/lib/Db/SessionMapper.php @@ -37,7 +37,7 @@ public function findAllDocuments(): array { * @return Session * @throws DoesNotExistException */ - public function find(int $documentId, int $sessionId, string $token): Session { + public function find(string $documentId, int $sessionId, string $token): Session { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $result = $qb->select('*') @@ -60,7 +60,7 @@ public function find(int $documentId, int $sessionId, string $token): Session { * * @psalm-return array */ - public function findAll(int $documentId): array { + public function findAll(string $documentId): array { $qb = $this->db->getQueryBuilder(); $qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name') ->from($this->getTableName()) @@ -69,7 +69,7 @@ public function findAll(int $documentId): array { return $this->findEntities($qb); } - public function countAll(int $documentId): int { + public function countAll(string $documentId): int { $qb = $this->db->getQueryBuilder(); $qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name') ->from($this->getTableName()) @@ -85,7 +85,7 @@ public function countAll(int $documentId): int { * * @psalm-return array */ - public function findAllActive(int $documentId): array { + public function findAllActive(string $documentId): array { $qb = $this->db->getQueryBuilder(); $qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name') ->from($this->getTableName()) @@ -109,7 +109,7 @@ public function findAllInactive(): array { return $this->findEntities($qb); } - public function deleteInactiveWithoutSteps(?int $documentId = null): int { + public function deleteInactiveWithoutSteps(?string $documentId = null): int { $lastContact = time() - SessionService::SESSION_VALID_TIME; $inactiveSessionBuilder = $this->db->getQueryBuilder(); @@ -217,7 +217,7 @@ public function deleteOrphanedSteps(int $ageInSeconds): int { return $deletedCount; } - public function deleteByDocumentId(int $documentId): int { + public function deleteByDocumentId(string $documentId): int { $qb = $this->db->getQueryBuilder(); $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))); @@ -230,7 +230,7 @@ public function clearAll(): void { ->executeStatement(); } - public function isUserInDocument(int $documentId, string $userId): bool { + public function isUserInDocument(string $documentId, string $userId): bool { $qb = $this->db->getQueryBuilder(); $result = $qb->select('*') ->from($this->getTableName()) diff --git a/lib/Db/Step.php b/lib/Db/Step.php index 57b6b2bf2d3..c687974b28f 100644 --- a/lib/Db/Step.php +++ b/lib/Db/Step.php @@ -9,6 +9,7 @@ use JsonSerializable; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method getData(): string @@ -17,8 +18,8 @@ * @method setVersion(int $version): void * @method getSessionId(): int * @method setSessionId(int $sessionId): void - * @method getDocumentId(): int - * @method setDocumentId(int $documentId): void + * @method getDocumentId(): string + * @method setDocumentId(string $documentId): void * @method getTimestamp(): int * @method setTimestamp(int $timestam): void */ @@ -35,14 +36,14 @@ class Step extends Entity implements JsonSerializable { protected string $data = ''; protected int $version = 0; protected int $sessionId = 0; - protected int $documentId = 0; + protected string $documentId = ''; protected int $timestamp = 0; public function __construct() { - $this->addType('version', 'integer'); - $this->addType('documentId', 'integer'); - $this->addType('sessionId', 'integer'); - $this->addType('timestamp', 'integer'); + $this->addType('version', Types::INTEGER); + $this->addType('documentId', Types::STRING); + $this->addType('sessionId', Types::INTEGER); + $this->addType('timestamp', Types::INTEGER); } public function jsonSerialize(): array { diff --git a/lib/Db/StepMapper.php b/lib/Db/StepMapper.php index 26339b0912b..960428ac608 100644 --- a/lib/Db/StepMapper.php +++ b/lib/Db/StepMapper.php @@ -20,7 +20,7 @@ public function __construct(IDBConnection $db) { /** * @return Step[] */ - public function find(int $documentId, int $fromVersion): array { + public function find(string $documentId, int $fromVersion): array { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $qb->select('*') @@ -38,7 +38,7 @@ public function find(int $documentId, int $fromVersion): array { /** * @psalm-return ?positive-int */ - public function getLatestVersion(int $documentId): ?int { + public function getLatestVersion(string $documentId): ?int { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $result = $qb->select('id') @@ -56,7 +56,7 @@ public function getLatestVersion(int $documentId): ?int { return $data['id']; } - public function getBeforeVersion(int $documentId, int $version, int $offset): int { + public function getBeforeVersion(string $documentId, int $version, int $offset): int { $qb = $this->db->getQueryBuilder(); $result = $qb->select('id') ->from($this->getTableName()) @@ -75,7 +75,7 @@ public function getBeforeVersion(int $documentId, int $version, int $offset): in return $data['id']; } - public function deleteAll(int $documentId): void { + public function deleteAll(string $documentId): void { $qb = $this->db->getQueryBuilder(); $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) @@ -89,7 +89,7 @@ public function clearAll(): void { } // not in use right now - public function deleteBeforeVersion(int $documentId, int $version): int { + public function deleteBeforeVersion(string $documentId, int $version): int { $qb = $this->db->getQueryBuilder(); return $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) @@ -97,7 +97,7 @@ public function deleteBeforeVersion(int $documentId, int $version): int { ->executeStatement(); } - public function deleteAfterVersion(int $documentId, int $version): int { + public function deleteAfterVersion(string $documentId, int $version): int { $qb = $this->db->getQueryBuilder(); return $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) diff --git a/lib/Middleware/SessionMiddleware.php b/lib/Middleware/SessionMiddleware.php index 890d2d3f308..050cc9ede55 100644 --- a/lib/Middleware/SessionMiddleware.php +++ b/lib/Middleware/SessionMiddleware.php @@ -77,7 +77,7 @@ public function beforeController(Controller $controller, string $methodName): vo * @throws InvalidDocumentBaseVersionEtagException */ private function assertDocumentBaseVersionEtag(): void { - $documentId = (int)$this->request->getParam('documentId'); + $documentId = (string)$this->request->getParam('documentId'); $baseVersionEtag = $this->request->getParam('baseVersionEtag'); $document = $this->documentService->getDocument($documentId); @@ -91,7 +91,7 @@ private function assertDocumentBaseVersionEtag(): void { * @throws AccountDisabledException */ private function assertDocumentSession(ISessionAwareController $controller): void { - $documentId = (int)$this->request->getParam('documentId'); + $documentId = (string)$this->request->getParam('documentId'); $sessionId = (int)$this->request->getParam('sessionId'); $token = (string)$this->request->getParam('sessionToken'); @@ -124,7 +124,7 @@ private function assertDocumentSession(ISessionAwareController $controller): voi * @throws InvalidSessionException */ private function assertUserOrShareToken(ISessionAwareController $controller): void { - $documentId = (int)$this->request->getParam('documentId'); + $documentId = (string)$this->request->getParam('documentId'); $shareToken = (string)$this->request->getParam('shareToken'); $user = $this->userSession->getUser(); diff --git a/lib/Migration/Version090000Date20260817110024.php b/lib/Migration/Version090000Date20260817110024.php index 627d673b141..ea3de1160cb 100644 --- a/lib/Migration/Version090000Date20260817110024.php +++ b/lib/Migration/Version090000Date20260817110024.php @@ -40,12 +40,6 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt ]); } - $column = $table->getColumn('id'); - if (!$column->getAutoincrement()) { - $table->modifyColumn('id', [ - 'autoincrement' => true, - ]); - } return $schema; } } diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php index 8390d8f45dd..83ee665ced7 100644 --- a/lib/Service/ApiService.php +++ b/lib/Service/ApiService.php @@ -28,6 +28,7 @@ use OCP\IUser; use OCP\Share\IShare; use Psr\Log\LoggerInterface; +use RuntimeException; class ApiService { public function __construct( @@ -52,11 +53,15 @@ public function create(IContext $context, ?string $baseVersionEtag, ?string $gue try { $document = $this->documentService->getOrCreateDocument($document); + $documentId = $document->id; + if ($documentId === null) { + throw new RuntimeException('Persisted document must have an id.'); + } } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new DataResponse(['error' => 'Failed to create the document session'], Http::STATUS_INTERNAL_SERVER_ERROR); } - $documentData = $this->documentService->getDocumentData($document); + $documentData = $this->documentService->getDocumentData($documentId, $document); if ($baseVersionEtag !== null && $baseVersionEtag !== $document->getBaseVersionEtag()) { $error = $this->l10n->t('Editing session has expired. Please reload the page.'); @@ -64,8 +69,8 @@ public function create(IContext $context, ?string $baseVersionEtag, ?string $gue } $sessionInfo = $context->prepareSession($documentData); - $this->sessionService->removeInactiveSessionsWithoutSteps($document->id); - $session = $this->sessionService->initSession($document->id, $guestName); + $this->sessionService->removeInactiveSessionsWithoutSteps($documentId); + $session = $this->sessionService->initSession($documentId, $guestName); $displayName = $this->sessionService->getNameForSession($session); $newSession = new NewSessionData( @@ -80,7 +85,7 @@ public function create(IContext $context, ?string $baseVersionEtag, ?string $gue ); } - public function close(int $documentId, int $sessionId, string $sessionToken, IShare|IUser $auth): DataResponse { + public function close(string $documentId, int $sessionId, string $sessionToken, IShare|IUser $auth): DataResponse { $this->sessionService->closeSession($documentId, $sessionId, $sessionToken); $this->sessionService->removeInactiveSessionsWithoutSteps($documentId); $activeSessions = $this->sessionService->getActiveSessions($documentId); @@ -100,6 +105,9 @@ public function close(int $documentId, int $sessionId, string $sessionToken, ISh * @throws NotFoundException */ public function push(Session $session, Document $document, int $version, array $steps, string $awareness, ?int $recoveryAttempt, IShare|IUser $auth): DataResponse { + if ($document->id === null) { + throw new RuntimeException('Document needs to have an id to push.'); + } try { $session = $this->sessionService->updateSessionAwareness($session, $awareness); } catch (DoesNotExistException $e) { @@ -108,7 +116,7 @@ public function push(Session $session, Document $document, int $version, array $ } try { $result = $this->documentService->addStep($document, $session, $steps, $version, $recoveryAttempt, $auth); - $this->addToPushQueue($document, [$awareness, ...array_values($steps)]); + $this->addToPushQueue($document->id, [$awareness, ...array_values($steps)]); } catch (InvalidArgumentException $e) { return new DataResponse(['error' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); } catch (DoesNotExistException) { @@ -120,12 +128,12 @@ public function push(Session $session, Document $document, int $version, array $ return new DataResponse($result); } - private function addToPushQueue(Document $document, array $steps): void { + private function addToPushQueue(string $documentId, array $steps): void { if ($this->queue === null || !$this->configService->isNotifyPushSyncEnabled()) { return; } - $sessions = $this->sessionService->getActiveSessions($document->id); + $sessions = $this->sessionService->getActiveSessions($documentId); $userIds = array_values(array_filter(array_unique( array_map(fn ($session): ?string => $session['userId'], $sessions) ))); @@ -134,7 +142,7 @@ private function addToPushQueue(Document $document, array $steps): void { 'user' => $userId, 'message' => 'text_steps', 'body' => [ - 'documentId' => $document->getId(), + 'documentId' => $documentId, 'steps' => array_values(array_filter($steps)), ], ]); @@ -142,6 +150,9 @@ private function addToPushQueue(Document $document, array $steps): void { } public function sync(Document $document, IShare|IUser $auth, int $version = 0): DataResponse { + if ($document->id === null) { + throw new RuntimeException('Document needs to have an id to sync.'); + } $result = []; try { $result = [ diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index ad7685eddcb..bc3b2953211 100755 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -12,11 +12,9 @@ use OC\User\NoUserException; use OCA\DAV\Connector\Sabre\PublicAuth; use OCA\Files_Sharing\SharedStorage; -use OCA\Text\Context\ContextManager; +use OCA\Text\Context\IContext; use OCA\Text\Controller\AttachmentController; -use OCA\Text\Db\DocumentMapper; use OCA\Text\Db\Session; -use OCP\AppFramework\Db\DoesNotExistException; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IFilenameValidator; @@ -31,7 +29,6 @@ use OCP\ISession; use OCP\IURLGenerator; use OCP\IUser; -use OCP\IUserManager; use OCP\Lock\LockedException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager as ShareManager; @@ -48,9 +45,6 @@ public function __construct( private IFilenameValidator $filenameValidator, private IFilesMetadataManager $filesMetadataManager, private ISession $session, - private DocumentMapper $documentMapper, - private ContextManager $contextManager, - private IUserManager $userManager, ) { } @@ -62,8 +56,8 @@ public function __construct( * @throws NotFoundException * @throws NotPermittedException */ - public function getImageFile(int $documentId, string $imageFileName, IShare|IUser $auth, bool $preferRawImage): File|ISimpleFile|null { - $textFile = $this->getTextFile($documentId, $auth); + public function getImageFile(IContext $context, string $imageFileName, bool $preferRawImage): File|ISimpleFile|null { + $textFile = $this->getTextFile($context); return $this->getImageFileContent($imageFileName, $textFile, $preferRawImage); } @@ -102,8 +96,8 @@ private function getImageFileContent(string $imageFileName, File $textFile, bool * @throws NotPermittedException * @throws NoUserException */ - public function getMediaFile(int $documentId, string $mediaFileName, IShare|IUser $auth): ?File { - $textFile = $this->getTextFile($documentId, $auth); + public function getMediaFile(IContext $context, string $mediaFileName): ?File { + $textFile = $this->getTextFile($context); return $this->getMediaFullFile($mediaFileName, $textFile); } @@ -128,8 +122,8 @@ private function getMediaFullFile(string $mediaFileName, File $textFile): ?File * @throws InvalidPathException * @throws NoUserException */ - public function getMediaFilePreview(int $documentId, string $mediaFileName, IShare|IUser $auth): ?array { - $textFile = $this->getTextFile($documentId, $auth); + public function getMediaFilePreview(IContext $context, string $mediaFileName): ?array { + $textFile = $this->getTextFile($context); return $this->getMediaFilePreviewFile($mediaFileName, $textFile); } @@ -170,8 +164,8 @@ private function getMediaFilePreviewFile(string $mediaFileName, File $textFile): * @throws NotFoundException * @throws NotPermittedException */ - public function getAttachmentList(int $documentId, IShare|IUser $auth, ?Session $session = null): array { - $textFile = $this->getTextFile($documentId, $auth); + public function getAttachmentList(IContext $context, IShare|IUser $auth, ?Session $session = null): array { + $textFile = $this->getTextFile($context); try { $attachmentDir = $this->getAttachmentDirectoryForFile($textFile); @@ -183,8 +177,8 @@ public function getAttachmentList(int $documentId, IShare|IUser $auth, ?Session ? '&shareToken=' . rawurlencode($auth->getToken()) : ''; $urlParamsBase = $session - ? '?documentId=' . $documentId . '&sessionId=' . $session->getId() . '&sessionToken=' . rawurlencode($session->getToken()) . $shareTokenUrlString - : '?documentId=' . $documentId . $shareTokenUrlString; + ? '?documentId=' . $session->getDocumentId() . '&sessionId=' . $session->getId() . '&sessionToken=' . rawurlencode($session->getToken()) . $shareTokenUrlString + : '?fileId=' . $context->getId() . $shareTokenUrlString; $attachments = []; @@ -242,7 +236,7 @@ public function getAttachmentList(int $documentId, IShare|IUser $auth, ?Session * @throws InvalidPathException * @throws NoUserException */ - public function uploadAttachment(int $documentId, string $newFileName, $newFileResource, IShare|IUser $auth): array { + public function uploadAttachment(IContext $context, string $newFileName, $newFileResource, IShare|IUser $auth): array { if ($auth instanceof IShare && $auth->getPassword() !== null) { $key = PublicAuth::DAV_AUTHENTICATED; @@ -261,7 +255,7 @@ public function uploadAttachment(int $documentId, string $newFileName, $newFileR } } - $textFile = $this->getTextFile($documentId, $auth); + $textFile = $this->getTextFile($context); $saveDir = $this->getAttachmentDirectoryForFile($textFile, true); $fileName = self::getUniqueFileName($saveDir, $newFileName); $this->filenameValidator->validateFilename($fileName); @@ -270,7 +264,6 @@ public function uploadAttachment(int $documentId, string $newFileName, $newFileR 'name' => $fileName, 'dirname' => $saveDir->getName(), 'id' => $savedFile->getId(), - 'documentId' => $documentId, ]; } @@ -282,8 +275,8 @@ public function uploadAttachment(int $documentId, string $newFileName, $newFileR * @throws InvalidPathException * @throws NoUserException */ - public function insertAttachmentFile(int $documentId, string $path, IUser $user): array { - $textFile = $this->getTextFile($documentId, $user); + public function insertAttachmentFile(IContext $context, string $path, IUser $user): array { + $textFile = $this->getTextFile($context); if (!$textFile->isUpdateable()) { throw new NotPermittedException('No write permissions'); } @@ -296,7 +289,6 @@ public function insertAttachmentFile(int $documentId, string $path, IUser $user) 'name' => $fileName, 'dirname' => $saveDir->getName(), 'id' => $targetFile->getId(), - 'documentId' => $documentId, 'mimetype' => $targetFile->getMimetype(), ]; } @@ -309,8 +301,8 @@ public function insertAttachmentFile(int $documentId, string $path, IUser $user) * @throws InvalidPathException * @throws NoUserException */ - public function createAttachmentFile(int $documentId, string $newFileName, IUser $user): array { - $textFile = $this->getTextFile($documentId, $user); + public function createAttachmentFile(IContext $context, string $newFileName, IUser $user): array { + $textFile = $this->getTextFile($context); if (!$textFile->isUpdateable()) { throw new NotPermittedException('No write permissions'); } @@ -321,7 +313,6 @@ public function createAttachmentFile(int $documentId, string $newFileName, IUser 'name' => $newFile->getName(), 'dirname' => $saveDir->getName(), 'id' => $newFile->getId(), - 'documentId' => $documentId, 'mimetype' => $newFile->getMimetype(), ]; } @@ -420,24 +411,14 @@ private function isDownloadDisabled(File $file): bool { * @throws NotFoundException * @throws NotPermittedException */ - private function getTextFile(int $documentId, IShare|IUser $auth): File { - try { - $document = $this->documentMapper->find($documentId); - } catch (DoesNotExistException $e) { - throw new NotFoundException('Text file for document ' - . $documentId - . ' was not found.', 0, $e); - } - $type = $document->getContextType(); - $id = $document->getContextId(); - $context = $this->contextManager->getContext($type, $id, $auth); + private function getTextFile(IContext $context): File { $file = $context->getFile(); if ($file instanceof File && !$this->isDownloadDisabled($file)) { return $file; } - throw new NotFoundException('Text file for document ' - . $documentId - . ' was not found.' + throw new NotFoundException('Text file for ' + . $context->toString() + . ' was not found or download is disabled.' ); } diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index f3114fe9c7e..995ff1731d2 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -41,6 +41,7 @@ use OCP\Lock\LockedException; use OCP\Share\IShare; use Psr\Log\LoggerInterface; +use RuntimeException; use function json_encode; class DocumentService { @@ -80,7 +81,7 @@ public function __construct( } } - public function getDocument(int $id): ?Document { + public function getDocument(string $id): ?Document { try { return $this->documentMapper->find($id); } catch (DoesNotExistException|NotFoundException) { @@ -111,6 +112,7 @@ public function getOrCreateDocument(Document $document): Document { $this->logger->info('Create new document of ' . $document->toString()); try { + $document->generateId(); /** @var Document $document */ $document = $this->documentMapper->insert($document); $this->cache->set('document-version-' . $document->id, 0); @@ -127,12 +129,12 @@ public function getOrCreateDocument(Document $document): Document { return $document; } - public function getDocumentData(Document $document): DocumentData { + public function getDocumentData(string $documentId, Document $document): DocumentData { $documentState = null; if ($document->getLastSavedVersion() > 0) { $this->logger->debug('Loading saved document state for ' . $document->toString()); try { - $stateFile = $this->getStateFile($document->id); + $stateFile = $this->getStateFile($documentId); $documentState = $stateFile->getContent(); } catch (NotFoundException) { // If we have no state file we need to load the content from the file @@ -150,11 +152,11 @@ public function getDocumentData(Document $document): DocumentData { } /** - * @param int $documentId + * @param string $documentId * @return ISimpleFile * @throws NotFoundException */ - public function getStateFile(int $documentId): ISimpleFile { + public function getStateFile(string $documentId): ISimpleFile { $filename = $documentId . '.yjs'; if (!$this->ensureDocumentsFolder()) { throw new NotFoundException('No app data folder present for text documents'); @@ -190,21 +192,21 @@ public function updateDocument(Document $document): void { } /** - * @param int $documentId + * @param string $documentId * * @return ISimpleFile * @throws NotPermittedException */ - public function createStateFile(int $documentId): ISimpleFile { + public function createStateFile(string $documentId): ISimpleFile { $filename = $documentId . '.yjs'; return $this->appData->getFolder('documents')->newFile($filename); } /** - * @param int $documentId + * @param string $documentId * @param string $content */ - public function writeDocumentState(int $documentId, string $content): void { + public function writeDocumentState(string $documentId, string $content): void { try { $documentStateFile = $this->getStateFile($documentId); } catch (NotFoundException) { @@ -245,7 +247,7 @@ public function addStep(Document $document, Session $session, array $steps, int $id = $document->getContextId(); $context = $this->contextManager->getContext($type, $id, $auth); if (!$context->isReadOnly()) { - $this->insertSteps($document, $session, $stepsToInsert); + $this->insertSteps($documentId, $session, $stepsToInsert); } } @@ -289,7 +291,7 @@ public function addStep(Document $document, Session $session, array $steps, int } /** - * @param Document $document + * @param string $documentId * @param Session $session * @param Step[] $steps * @@ -298,34 +300,34 @@ public function addStep(Document $document, Session $session, array $steps, int * * @psalm-param non-empty-list $steps */ - private function insertSteps(Document $document, Session $session, array $steps): void { + private function insertSteps(string $documentId, Session $session, array $steps): void { $stepsVersion = null; try { $stepsJson = json_encode($steps, JSON_THROW_ON_ERROR); - $stepsVersion = $this->stepMapper->getLatestVersion($document->getId()); + $stepsVersion = $this->stepMapper->getLatestVersion($documentId); $step = new Step(); $step->setData($stepsJson); $step->setSessionId($session->getId()); - $step->setDocumentId($document->getId()); + $step->setDocumentId($documentId); $step->setVersion(Step::VERSION_STORED_IN_ID); $step->setTimestamp(time()); $step = $this->stepMapper->insert($step); $newVersion = $step->getId(); - $this->logger->debug('Adding steps to ' . $document->getId() . ": bumping version from $stepsVersion to $newVersion"); - $this->cache->set('document-version-' . $document->getId(), $newVersion); + $this->logger->debug('Adding steps to ' . $documentId . ": bumping version from $stepsVersion to $newVersion"); + $this->cache->set('document-version-' . $documentId, $newVersion); // TODO write steps to cache for quicker reading } catch (\Throwable $e) { if ($stepsVersion !== null) { $this->logger->error('This should never happen. An error occurred when storing the version, trying to recover the last stable one', ['exception' => $e]); - $this->cache->set('document-version-' . $document->getId(), $stepsVersion); - $this->stepMapper->deleteAfterVersion($document->getId(), $stepsVersion); + $this->cache->set('document-version-' . $documentId, $stepsVersion); + $this->stepMapper->deleteAfterVersion($documentId, $stepsVersion); } throw $e; } } /** @return Step[] */ - public function getSteps(int $documentId, int $lastVersion): array { + public function getSteps(string $documentId, int $lastVersion): array { if ($lastVersion === $this->cache->get('document-version-' . $documentId)) { return []; } @@ -349,6 +351,9 @@ public static function computeCheckSum(string $content): string { * @throws Exception */ public function autosave(Document $document, IContext $context, int $version, string $autoSaveDocument, string $documentState, bool $force = false, bool $manualSave = false): Document { + if ($document->id === null) { + throw new RuntimeException('Document needs an id to autosave'); + } if ($context->isReadOnly()) { throw new NotPermittedException('Read-only permission cannot save document changes. Please reload the page.'); } @@ -437,13 +442,15 @@ public function resetDocument(string $contextType, int $contextId, bool $force = $contextString = $contextType . '(' . $contextId . ')'; $document = $this->documentMapper->load($contextType, $contextId); - if (!$document) { + if (!$document || $document->id === null) { // no document found for the file in question - so nothing to reset. $this->logger->info('did not find document - document not reset.' . $contextString); return; } - if (!$force && $this->hasUnsavedChanges($document)) { + $stepsVersion = $this->stepMapper->getLatestVersion($document->id) ?: 0; + $docVersion = $document->getLastSavedVersion(); + if (!$force && $stepsVersion !== $docVersion) { $this->logger->debug('Did not reset document with unsaved changes for ' . $contextString); throw new DocumentHasUnsavedChangesException('Did not reset document, as it has unsaved changes'); } @@ -469,12 +476,6 @@ public function getAllWithNoActiveSession(): \Generator { return $this->documentMapper->findAllWithNoActiveSessions(); } - public function hasUnsavedChanges(Document $document): bool { - $stepsVersion = $this->stepMapper->getLatestVersion($document->getId()) ?: 0; - $docVersion = $document->getLastSavedVersion(); - return $stepsVersion !== $docVersion; - } - private function ensureDocumentsFolder(): bool { try { $this->appData->getFolder('documents'); diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index 6cf2f872ff9..6ebbb984f74 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -54,7 +54,7 @@ public function __construct( $this->cache = $cacheFactory->createDistributed('text_sessions'); } - public function initSession(int $documentId, ?string $guestName = null): Session { + public function initSession(string $documentId, ?string $guestName = null): Session { $session = new Session(); $session->setDocumentId($documentId); $session->setUserId($this->userId); @@ -71,7 +71,7 @@ public function initSession(int $documentId, ?string $guestName = null): Session return $session; } - public function closeSession(int $documentId, int $sessionId, string $token): void { + public function closeSession(string $documentId, int $sessionId, string $token): void { try { $session = $this->sessionMapper->find($documentId, $sessionId, $token); $this->cache->remove($token); @@ -80,7 +80,7 @@ public function closeSession(int $documentId, int $sessionId, string $token): vo } } - public function getAllSessions(int $documentId): array { + public function getAllSessions(string $documentId): array { $sessions = $this->sessionMapper->findAll($documentId); return array_map(function (Session $session) { $result = $session->jsonSerialize(); @@ -92,11 +92,11 @@ public function getAllSessions(int $documentId): array { }, $sessions); } - public function countAllSessions(int $documentId): int { + public function countAllSessions(string $documentId): int { return $this->sessionMapper->countAll($documentId); } - public function getActiveSessions(int $documentId): array { + public function getActiveSessions(string $documentId): array { $sessions = $this->sessionMapper->findAllActive($documentId); return array_map(function (Session $session) { $result = $session->jsonSerialize(); @@ -122,7 +122,7 @@ public function findAllInactive(): array { return $this->sessionMapper->findAllInactive(); } - public function removeInactiveSessionsWithoutSteps(?int $documentId = null): int { + public function removeInactiveSessionsWithoutSteps(?string $documentId = null): int { // No need to clear the cache here as we already set a TTL return $this->sessionMapper->deleteInactiveWithoutSteps($documentId); } @@ -135,7 +135,7 @@ public function removeOrphanedSteps(int $ageInSeconds = 604800): int { return $this->sessionMapper->deleteOrphanedSteps($ageInSeconds); } - public function getSession(int $documentId, int $sessionId, string $token): ?Session { + public function getSession(string $documentId, int $sessionId, string $token): ?Session { if ($this->session !== null) { return $this->session; } @@ -162,7 +162,7 @@ public function getSession(int $documentId, int $sessionId, string $token): ?Ses return $this->session; } - public function getValidSession(int $documentId, int $sessionId, string $token): ?Session { + public function getValidSession(string $documentId, int $sessionId, string $token): ?Session { $session = $this->getSession($documentId, $sessionId, $token); if ($session === null) { return null; @@ -241,7 +241,7 @@ private function generateRandomString(int $length = 64): string { return $randomizer->getBytesFromString(ISecureRandom::CHAR_ALPHANUMERIC . '+/', $length); } - public function isUserInDocument(int $documentId, string $mention): bool { + public function isUserInDocument(string $documentId, string $mention): bool { return $this->sessionMapper->isUserInDocument($documentId, $mention); } } diff --git a/src/EditorFactory.ts b/src/EditorFactory.ts index ab262591459..bfe5e119faf 100644 --- a/src/EditorFactory.ts +++ b/src/EditorFactory.ts @@ -5,7 +5,7 @@ import type { Extension } from '@tiptap/core' import type { Node } from '@tiptap/pm/model' -import type { Connection } from './composables/useConnection.ts' +import type { Connection } from './types/Connection.ts' import { Editor } from '@tiptap/core' import hljs from 'highlight.js/lib/core' diff --git a/src/apis/attach.ts b/src/apis/attach.ts index 9dcbab90903..1ecbf20a010 100644 --- a/src/apis/attach.ts +++ b/src/apis/attach.ts @@ -4,7 +4,7 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/apis/connect.ts b/src/apis/connect.ts index 9d21985c525..3b1a306f9fb 100644 --- a/src/apis/connect.ts +++ b/src/apis/connect.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Connection } from '../composables/useConnection.ts' -import type { Document, GuestSession, Session } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { GuestSession, Session } from '../types/Session.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/apis/mention.ts b/src/apis/mention.ts index de562b9c469..a742a5fd5e3 100644 --- a/src/apis/mention.ts +++ b/src/apis/mention.ts @@ -4,7 +4,7 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/apis/save.ts b/src/apis/save.ts index 554861736b3..fcdcf78d126 100644 --- a/src/apis/save.ts +++ b/src/apis/save.ts @@ -4,8 +4,8 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' -import type { Document } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' import { getRequestToken } from '@nextcloud/auth' import axios from '@nextcloud/axios' diff --git a/src/apis/sync.ts b/src/apis/sync.ts index 13bbedc0c5a..d17410ed3e3 100644 --- a/src/apis/sync.ts +++ b/src/apis/sync.ts @@ -4,8 +4,10 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' -import type { Document, Session, Step } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { Session } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index da54b53e49a..51334d566b1 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -115,8 +115,9 @@ import { CollaborationCaret } from '../extensions/index.js' import { exposeForDebugging, removeFromDebugging } from '../helpers/debug.ts' import { logger } from '../helpers/logger.ts' import { setInitialYjsState } from '../helpers/setInitialYjsState.ts' -import { ERROR_TYPE, IDLE_TIMEOUT } from '../services/SyncService.ts' +import { IDLE_TIMEOUT } from '../services/SyncService.ts' import { fetchNode } from '../services/WebdavClient.ts' +import { ERROR_TYPE } from '../types/ErrorType.ts' import { createPlainEditor, createRichEditor, diff --git a/src/components/Editor/DocumentStatus/SyncStatus.vue b/src/components/Editor/DocumentStatus/SyncStatus.vue index 7feaa069c82..083a6fc37ec 100644 --- a/src/components/Editor/DocumentStatus/SyncStatus.vue +++ b/src/components/Editor/DocumentStatus/SyncStatus.vue @@ -24,7 +24,7 @@ import { t } from '@nextcloud/l10n' import NcButton from '@nextcloud/vue/components/NcButton' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' -import { ERROR_TYPE } from '../../../services/SyncService.ts' +import { ERROR_TYPE } from '../../../types/ErrorType.ts' export default { name: 'SyncStatus', diff --git a/src/components/Editor/SessionStatus.vue b/src/components/Editor/SessionStatus.vue index 82fcbf242cb..15869675f67 100644 --- a/src/components/Editor/SessionStatus.vue +++ b/src/components/Editor/SessionStatus.vue @@ -41,7 +41,7 @@ import OfflineState from './OfflineState.vue' import { useNetworkState } from '../../composables/useNetworkState.ts' import { useSaveService } from '../../composables/useSaveService.ts' import refreshMoment from '../../mixins/refreshMoment.js' -import { ERROR_TYPE } from '../../services/SyncService.ts' +import { ERROR_TYPE } from '../../types/ErrorType.ts' import { useIsMobileMixin } from '../Editor.provider.ts' export default { diff --git a/src/composables/useConnection.ts b/src/composables/useConnection.ts index 925d2526a1e..81223ea8383 100644 --- a/src/composables/useConnection.ts +++ b/src/composables/useConnection.ts @@ -5,25 +5,14 @@ import type { InjectionKey, ShallowRef } from 'vue' import type { OpenData } from '../apis/connect.ts' -import type { Document, Session } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Context } from '../types/Context.ts' +import type { Document } from '../types/Document.ts' +import type { Session } from '../types/Session.ts' import { inject, provide, shallowRef } from 'vue' import * as api from '../apis/connect.ts' -export interface Context { - type: string - id: number -} - -export interface Connection { - documentId: number - sessionId: number - sessionToken: string - baseVersionEtag: string - filePath: string - shareToken?: string -} - export interface InitialData { document: Document session: Session & { token: string } diff --git a/src/composables/useEditorMethods.ts b/src/composables/useEditorMethods.ts index 7dfab0f7f7a..7cab77144d5 100644 --- a/src/composables/useEditorMethods.ts +++ b/src/composables/useEditorMethods.ts @@ -5,13 +5,18 @@ import type { Editor } from '@tiptap/core' import type { AwarenessUser } from '../extensions/CollaborationCaret.ts' -import type { Session } from '../services/SyncService.ts' +import type { Session } from '../types/Session.ts' import escapeHtml from 'escape-html' import Markdown from '../extensions/Markdown.js' import markdownit from '../markdownit/index.js' import { isUser } from '../services/SyncService.ts' +/** + * + * @param content + * @param markdown + */ export function renderEditorContent(content: string, markdown: boolean) { return markdown ? markdownit.render(content) + '

' diff --git a/src/composables/useFileProps.ts b/src/composables/useFileProps.ts index e6647a5528f..7f729742f47 100644 --- a/src/composables/useFileProps.ts +++ b/src/composables/useFileProps.ts @@ -4,7 +4,7 @@ */ import type { InjectionKey } from 'vue' -import type { Context } from './useConnection.ts' +import type { Context } from '../types/Context.ts' import { inject, provide } from 'vue' diff --git a/src/composables/useIndexedDbProvider.ts b/src/composables/useIndexedDbProvider.ts index 81dbc046e32..e178192f396 100644 --- a/src/composables/useIndexedDbProvider.ts +++ b/src/composables/useIndexedDbProvider.ts @@ -4,7 +4,7 @@ */ import type { Doc } from 'yjs' -import type { Context } from './useConnection.ts' +import type { Context } from '../types/Context.ts' import { readonly, ref } from 'vue' import { IndexeddbPersistence } from 'y-indexeddb' diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index 7d1b3bfa9d0..a2b02b8eb80 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -6,8 +6,9 @@ import type { InjectionKey, ShallowRef } from 'vue' import type { Doc } from 'yjs' import type { SaveData } from '../apis/save.ts' -import type { Document, SyncService } from '../services/SyncService.ts' -import type { Connection } from './useConnection.ts' +import type { SyncService } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' import { computed, inject, onUnmounted, provide, ref, watch } from 'vue' import { getDocumentState } from '../helpers/yjs.ts' diff --git a/src/composables/useSessions.ts b/src/composables/useSessions.ts index 3ea68e85e06..1db4f8035b2 100644 --- a/src/composables/useSessions.ts +++ b/src/composables/useSessions.ts @@ -6,7 +6,7 @@ import type { ShallowRef } from 'vue' import type { OpenData } from '../apis/connect.ts' import type { SyncService } from '../services/SyncService.ts' -import type { Session } from '../services/SyncService.ts' +import type { Session } from '../types/Session.ts' import { computed, diff --git a/src/composables/useSyncService.ts b/src/composables/useSyncService.ts index 3a612f08c46..1743f1cdbb1 100644 --- a/src/composables/useSyncService.ts +++ b/src/composables/useSyncService.ts @@ -4,7 +4,8 @@ */ import type { InjectionKey, ShallowRef } from 'vue' -import type { Connection, InitialData } from './useConnection.ts' +import type { Connection } from '../types/Connection.ts' +import type { InitialData } from './useConnection.ts' import { inject, provide } from 'vue' import { SyncService } from '../services/SyncService.ts' diff --git a/src/createEditor.ts b/src/createEditor.ts index c48db34373d..d966b07d6a3 100644 --- a/src/createEditor.ts +++ b/src/createEditor.ts @@ -5,8 +5,8 @@ import type { EventHandler } from '@nextcloud/event-bus' import type { App } from 'vue' -import type { Context } from './composables/useConnection.ts' import type { TextEditorEmbed } from './TextEditorEmbed.ts' +import type { Context } from './types/Context.ts' import { createApp, reactive, shallowRef } from 'vue' import { diff --git a/src/extensions/Autofocus.ts b/src/extensions/Autofocus.ts index 15dbdb30cdc..1ccb88c6525 100644 --- a/src/extensions/Autofocus.ts +++ b/src/extensions/Autofocus.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Context } from '../composables/useConnection.ts' +import type { Context } from '../types/Context.ts' import { Extension } from '@tiptap/core' diff --git a/src/extensions/RichText.ts b/src/extensions/RichText.ts index e1162e122ad..ce8dd5b37ba 100644 --- a/src/extensions/RichText.ts +++ b/src/extensions/RichText.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { AnyExtension, Extensions } from '@tiptap/core' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' import { t } from '@nextcloud/l10n' import { Extension } from '@tiptap/core' diff --git a/src/helpers/steps.ts b/src/helpers/steps.ts index 12c1a6bf3e0..7f563571533 100644 --- a/src/helpers/steps.ts +++ b/src/helpers/steps.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Session, Step } from '../services/SyncService.ts' +import type { Session } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import { COLLABORATOR_DISCONNECT_TIME } from '../services/SyncService.ts' diff --git a/src/helpers/yjs.ts b/src/helpers/yjs.ts index a6d926aaf96..a084d8c506f 100644 --- a/src/helpers/yjs.ts +++ b/src/helpers/yjs.ts @@ -4,7 +4,7 @@ */ import type { OpenData } from '../apis/connect.ts' -import type { Step } from '../services/SyncService.ts' +import type { Step } from '../types/Step.ts' import * as decoding from 'lib0/decoding.js' import * as encoding from 'lib0/encoding.js' diff --git a/src/services/AttachmentResolver.js b/src/services/AttachmentResolver.js index bc915c98156..3028dd55308 100644 --- a/src/services/AttachmentResolver.js +++ b/src/services/AttachmentResolver.js @@ -12,7 +12,7 @@ export default class AttachmentResolver { #user #shareToken #currentDirectory - #documentId + #fileId #initAttachmentListPromise #attachmentList = [] @@ -21,15 +21,16 @@ export default class AttachmentResolver { this.#user = user this.#shareToken = shareToken this.#currentDirectory = currentDirectory - this.#documentId = fileId ?? session.documentId + this.#fileId = fileId } async #updateAttachmentList() { const response = await axios.post(generateUrl('/apps/text/attachments'), { - documentId: this.#session?.documentId ?? this.#documentId, + documentId: this.#session?.documentId, sessionId: this.#session?.id, sessionToken: this.#session?.token, shareToken: this.#shareToken, + fileId: this.#fileId, }) this.#attachmentList = response.data } diff --git a/src/services/NotifyService.ts b/src/services/NotifyService.ts index 6fac7fe263d..03e2280f5ac 100644 --- a/src/services/NotifyService.ts +++ b/src/services/NotifyService.ts @@ -12,7 +12,7 @@ import mitt from 'mitt' export declare type EventTypes = { notify_push: { messageType: unknown - messageBody: { steps: string[], documentId: number } + messageBody: { steps: string[], documentId: string } } } @@ -29,7 +29,7 @@ if (!window._nc_text_notify) { 'text_steps', ( messageType: string, - messageBody: { steps: string[], documentId: number }, + messageBody: { steps: string[], documentId: string }, ) => { window._nc_text_notify?.emit('notify_push', { messageType, diff --git a/src/services/PollingBackend.ts b/src/services/PollingBackend.ts index ff6fe94e826..271bc145681 100644 --- a/src/services/PollingBackend.ts +++ b/src/services/PollingBackend.ts @@ -4,14 +4,17 @@ */ import type { Emitter } from 'mitt' import type { OpenData } from '../apis/connect.ts' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { Session } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import type { EventTypes } from './NotifyService.ts' -import type { Document, Session, Step, SyncService } from './SyncService.js' +import type { SyncService } from './SyncService.js' import { sync } from '../apis/sync.ts' import { logger } from '../helpers/logger.js' +import { ERROR_TYPE } from '../types/ErrorType.ts' import getNotifyBus from './NotifyService.ts' -import { ERROR_TYPE } from './SyncService.js' /** * Minimum inverval to refetch the document changes in ms. diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 0992ea12d71..fb070c69197 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -5,15 +5,15 @@ import type { Ref, ShallowRef } from 'vue' import type { SaveData } from '../apis/save.ts' -import type { Connection } from '../composables/useConnection.ts' -import type { Document } from './SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' import { showError } from '@nextcloud/dialogs' import debounce from 'debounce' import mitt from 'mitt' import { save, saveViaSendBeacon } from '../apis/save.ts' import { logger } from '../helpers/logger.js' -import { ERROR_TYPE } from './SyncService.ts' +import { ERROR_TYPE } from '../types/ErrorType.ts' // Time constants in seconds: // Only autosave after 1 second typing breaks diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 4f91c318c39..ffe95c8d07d 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -5,7 +5,11 @@ import type { ShallowRef } from 'vue' import type { OpenData } from '../apis/connect.ts' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { ErrorType } from '../types/ErrorType.ts' +import type { GuestSession, Session, UserSession } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import mitt from 'mitt' import { close } from '../apis/connect.ts' @@ -13,6 +17,7 @@ import { push } from '../apis/sync.ts' import { logger } from '../helpers/logger.js' import { awarenessSteps } from '../helpers/steps.ts' import { documentStateToStep } from '../helpers/yjs.ts' +import { ERROR_TYPE } from '../types/ErrorType.ts' import Outbox from './Outbox.ts' import PollingBackend from './PollingBackend.ts' @@ -27,58 +32,6 @@ const COLLABORATOR_IDLE_TIME = 60 const COLLABORATOR_DISCONNECT_TIME = 90 -const ERROR_TYPE = { - /** - * Failed to save collaborative document due to external change - * collision needs to be resolved manually - */ - SAVE_COLLISION: 0, - /** - * Failed to push changes for MAX_REBASE_RETRY times - */ - PUSH_FAILURE: 1, - - LOAD_ERROR: 2, - - CONNECTION_FAILED: 3, - - SOURCE_NOT_FOUND: 4, - - PUSH_FORBIDDEN: 5, -} as const - -type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE] - -/* - * Step as what we expect to be returned from the server right now. - */ -export interface Step { - data: string[] - version: number - sessionId: number -} - -export interface UserSession { - id: number - userId: string - color: string - lastAwarenessMessage: string - lastContact: number - documentId: number - displayName: string -} - -export interface GuestSession { - id: number - color: string - lastAwarenessMessage: string - lastContact: number - guestName: string - documentId: number -} - -export type Session = UserSession | GuestSession - /** * Test if a session is a guest session * @@ -97,14 +50,6 @@ export function isUser(session: Session): session is UserSession { return 'userId' in session && typeof session.userId === 'string' } -export interface Document { - id: number - lastSavedVersion: number - lastSavedVersionTime: number - baseVersionEtag: string - initialVersion: number -} - export declare type EventTypes = { /* Document state */ opened: OpenData diff --git a/src/services/WebSocketPolyfill.ts b/src/services/WebSocketPolyfill.ts index 1dcd3f90af7..bd217ac63f3 100644 --- a/src/services/WebSocketPolyfill.ts +++ b/src/services/WebSocketPolyfill.ts @@ -4,7 +4,8 @@ */ import type { OpenData } from '../apis/connect.ts' -import type { Step, SyncService } from './SyncService.ts' +import type { Step } from '../types/Step.ts' +import type { SyncService } from './SyncService.ts' import { decodeArrayBuffer, encodeArrayBuffer } from '../helpers/base64.ts' import { logger } from '../helpers/logger.js' @@ -38,7 +39,7 @@ export default function initWebSocketPolyfill(syncService: SyncService) { #onSync #onOpened #processingVersion = 0 - #documentId = 0 + #documentId: string | undefined constructor(url: string) { this.#notifyPushBus = getNotifyBus() @@ -130,7 +131,7 @@ export default function initWebSocketPolyfill(syncService: SyncService) { #onNotifyPush({ messageBody, }: { - messageBody: { documentId: number, steps: string[] } + messageBody: { documentId: string, steps: string[] } }) { debug('WebSocketPolyfill#onNotifyPush', messageBody) if (messageBody.documentId !== this.#documentId) { diff --git a/src/tests/helpers/updateFromContent.spec.ts b/src/tests/helpers/updateFromContent.spec.ts index a9ed551e850..990b49bcdcc 100644 --- a/src/tests/helpers/updateFromContent.spec.ts +++ b/src/tests/helpers/updateFromContent.spec.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Connection } from '../../composables/useConnection.js' +import type { Connection } from '../../types/Connection.ts' import { Collaboration } from '@tiptap/extension-collaboration' import { describe, expect, it } from 'vitest' @@ -83,7 +83,7 @@ What's that? */ function contentWithUpdates(...updates: Uint8Array[]) { const dummyConnection: Connection = { - documentId: 123, + documentId: '123', sessionId: 234, sessionToken: 'token', baseVersionEtag: 'etag', diff --git a/src/tests/services/SyncService.spec.ts b/src/tests/services/SyncService.spec.ts index c383b16d720..6baac3934f3 100644 --- a/src/tests/services/SyncService.spec.ts +++ b/src/tests/services/SyncService.spec.ts @@ -9,7 +9,7 @@ import { provideConnection } from '../../composables/useConnection.js' import { SyncService } from '../../services/SyncService.js' const connection = { - documentId: 123, + documentId: '123', sessionId: 345, sessionToken: 'sessionToken', filePath: './', @@ -22,13 +22,13 @@ const initialData = { token: 'shareToken', color: '#abcabc', lastContact: Date.now(), - documentId: 123, + documentId: '123', displayName: 'My Name', lastAwarenessMessage: 'hi', clientId: 1, }, document: { - id: 123, + id: '123', baseVersionEtag: 'etag', initialVersion: 0, lastSavedVersion: 345, diff --git a/src/types/Connection.ts b/src/types/Connection.ts new file mode 100644 index 00000000000..4ea6abef78a --- /dev/null +++ b/src/types/Connection.ts @@ -0,0 +1,13 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface Connection { + documentId: string + sessionId: number + sessionToken: string + baseVersionEtag: string + filePath: string + shareToken?: string +} diff --git a/src/types/Context.ts b/src/types/Context.ts new file mode 100644 index 00000000000..934e41d4e97 --- /dev/null +++ b/src/types/Context.ts @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface Context { + type: string + id: number +} diff --git a/src/types/Document.ts b/src/types/Document.ts new file mode 100644 index 00000000000..d95b6b22779 --- /dev/null +++ b/src/types/Document.ts @@ -0,0 +1,12 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface Document { + id: string + lastSavedVersion: number + lastSavedVersionTime: number + baseVersionEtag: string + initialVersion: number +} diff --git a/src/types/ErrorType.ts b/src/types/ErrorType.ts new file mode 100644 index 00000000000..32b623c5944 --- /dev/null +++ b/src/types/ErrorType.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const ERROR_TYPE = { + /** + * Failed to save collaborative document due to external change + * collision needs to be resolved manually + */ + SAVE_COLLISION: 0, + /** + * Failed to push changes for MAX_REBASE_RETRY times + */ + PUSH_FAILURE: 1, + + LOAD_ERROR: 2, + + CONNECTION_FAILED: 3, + + SOURCE_NOT_FOUND: 4, + + PUSH_FORBIDDEN: 5, +} as const +export type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE] diff --git a/src/types/Session.ts b/src/types/Session.ts new file mode 100644 index 00000000000..6d8cb8d668a --- /dev/null +++ b/src/types/Session.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface UserSession { + id: number + userId: string + color: string + lastAwarenessMessage: string + lastContact: number + documentId: string + displayName: string +} + +export interface GuestSession { + id: number + color: string + lastAwarenessMessage: string + lastContact: number + guestName: string + documentId: string +} + +export type Session = UserSession | GuestSession diff --git a/src/types/Step.ts b/src/types/Step.ts new file mode 100644 index 00000000000..99c13001f0a --- /dev/null +++ b/src/types/Step.ts @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* + * Step as what we expect to be returned from the server right now. + */ + +export interface Step { + data: string[] + version: number + sessionId: number +} diff --git a/src/views/CollaborativeEditorApp.vue b/src/views/CollaborativeEditorApp.vue index 6a10582e3e6..c686b2d3f98 100644 --- a/src/views/CollaborativeEditorApp.vue +++ b/src/views/CollaborativeEditorApp.vue @@ -24,7 +24,7 @@