diff --git a/appinfo/routes.php b/appinfo/routes.php index ec357a78..be023e86 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -12,6 +12,7 @@ return [ 'routes' => [ ['name' => 'config#oauthRedirect', 'url' => '/oauth-redirect', 'verb' => 'GET'], + ['name' => 'config#getConfig', 'url' => '/config', 'verb' => 'GET'], ['name' => 'config#setConfig', 'url' => '/config', 'verb' => 'PUT'], ['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'], ['name' => 'config#getLocalAddressBooks', 'url' => '/local-addressbooks', 'verb' => 'GET'], @@ -22,6 +23,11 @@ ['name' => 'googleAPI#getContactNumber', 'url' => '/contact-number', 'verb' => 'GET'], ['name' => 'googleAPI#importCalendar', 'url' => '/import-calendar', 'verb' => 'GET'], ['name' => 'googleAPI#importContacts', 'url' => '/import-contacts', 'verb' => 'GET'], + ['name' => 'googleAPI#createPickerSession', 'url' => '/picker-session', 'verb' => 'POST'], + ['name' => 'googleAPI#getPickerSession', 'url' => '/picker-session', 'verb' => 'GET'], + ['name' => 'googleAPI#deletePickerSession', 'url' => '/picker-session', 'verb' => 'DELETE'], + ['name' => 'googleAPI#importPhotos', 'url' => '/import-photos', 'verb' => 'POST'], + ['name' => 'googleAPI#getImportPhotosInformation', 'url' => '/import-photos-info', 'verb' => 'GET'], ['name' => 'googleAPI#importDrive', 'url' => '/import-files', 'verb' => 'GET'], ['name' => 'googleAPI#getImportDriveInformation', 'url' => '/import-files-info', 'verb' => 'GET'], ] diff --git a/lib/BackgroundJob/ImportPhotosJob.php b/lib/BackgroundJob/ImportPhotosJob.php new file mode 100644 index 00000000..01610b2e --- /dev/null +++ b/lib/BackgroundJob/ImportPhotosJob.php @@ -0,0 +1,38 @@ +service->importPhotosJob($userId); + } +} diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index 35b70752..b2763b8c 100644 --- a/lib/Controller/ConfigController.php +++ b/lib/Controller/ConfigController.php @@ -17,6 +17,7 @@ use OCA\Google\AppInfo\Application; use OCA\Google\Service\GoogleAPIService; use OCA\Google\Service\GoogleDriveAPIService; +use OCA\Google\Service\GooglePhotosAPIService; use OCA\Google\Service\SecretService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -41,8 +42,9 @@ class ConfigController extends Controller { public const CONTACTS_OTHER_SCOPE = 'https://www.googleapis.com/auth/contacts.other.readonly'; public const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'; public const CALENDAR_EVENTS_SCOPE = 'https://www.googleapis.com/auth/calendar.events.readonly'; + public const PHOTOS_SCOPE = 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly'; - public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start']; + public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start', 'nb_imported_photos', 'last_photo_import_timestamp', 'photo_import_job_last_start']; public function __construct( string $appName, @@ -55,6 +57,7 @@ public function __construct( private IInitialState $initialStateService, private GoogleAPIService $googleApiService, private GoogleDriveAPIService $googleDriveApiService, + private GooglePhotosAPIService $googlePhotosApiService, private ?string $userId, private ICrypto $crypto, private SecretService $secretService, @@ -62,6 +65,28 @@ public function __construct( parent::__construct($appName, $request); } + /** + * @NoAdminRequired + * Get current user config values (used after OAuth popup to refresh state) + * + * @return DataResponse + */ + public function getConfig(): DataResponse { + if ($this->userId === null) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + $userName = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_name', lazy: true); + $userScopesString = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_scopes', '{}', lazy: true); + $userScopes = json_decode($userScopesString, true); + if (!is_array($userScopes)) { + $userScopes = []; + } + return new DataResponse([ + 'user_name' => $userName, + 'user_scopes' => $userScopes, + ]); + } + /** * @NoAdminRequired * Set config values @@ -90,6 +115,11 @@ public function setConfig(array $values): DataResponse { $this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'refresh_token'); $this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token_expires_at'); $this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token'); + $this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'user_scopes'); + // Cancel any in-progress imports so background jobs don't keep re-queuing + // and failing against the now-deleted tokens + $this->googleDriveApiService->cancelImport($this->userId); + $this->googlePhotosApiService->cancelImport($this->userId); $result['user_name'] = ''; } else { if (isset($values['drive_output_dir'])) { @@ -100,6 +130,9 @@ public function setConfig(array $values): DataResponse { if (isset($values['importing_drive']) && $values['importing_drive'] === '0') { $this->googleDriveApiService->cancelImport($this->userId); } + if (isset($values['importing_photos']) && $values['importing_photos'] === '0') { + $this->googlePhotosApiService->cancelImport($this->userId); + } } return new DataResponse($result); } @@ -192,6 +225,7 @@ public function oauthRedirect(string $code = '', string $state = '', string $sco 'can_access_contacts' => in_array(self::CONTACTS_SCOPE, $scopes) ? 1 : 0, 'can_access_other_contacts' => in_array(self::CONTACTS_OTHER_SCOPE, $scopes) ? 1 : 0, 'can_access_calendar' => (in_array(self::CALENDAR_SCOPE, $scopes) && in_array(self::CALENDAR_EVENTS_SCOPE, $scopes)) ? 1 : 0, + 'can_access_photos' => in_array(self::PHOTOS_SCOPE, $scopes) ? 1 : 0, ]; $this->userConfig->setValueString($this->userId, Application::APP_ID, 'user_scopes', json_encode($scopesArray), lazy: true); diff --git a/lib/Controller/GoogleAPIController.php b/lib/Controller/GoogleAPIController.php index 1cf88d50..1b3472f2 100644 --- a/lib/Controller/GoogleAPIController.php +++ b/lib/Controller/GoogleAPIController.php @@ -16,6 +16,7 @@ use OCA\Google\Service\GoogleCalendarAPIService; use OCA\Google\Service\GoogleContactsAPIService; use OCA\Google\Service\GoogleDriveAPIService; +use OCA\Google\Service\GooglePhotosAPIService; use OCA\Google\Service\SecretService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\DataResponse; @@ -30,6 +31,7 @@ public function __construct( string $appName, IRequest $request, private IUserConfig $userConfig, + private GooglePhotosAPIService $googlePhotosAPIService, private GoogleContactsAPIService $googleContactsAPIService, private GoogleDriveAPIService $googleDriveAPIService, private GoogleCalendarAPIService $googleCalendarAPIService, @@ -40,6 +42,106 @@ public function __construct( $this->accessToken = $this->userId !== null ? $this->secretService->getEncryptedUserValue($this->userId, 'token') : ''; } + + /** + * @NoAdminRequired + * + * @return DataResponse + */ + public function getImportPhotosInformation(): DataResponse { + if ($this->accessToken === '') { + return new DataResponse([], 400); + } + $pickerSessionQueue = json_decode( + $this->userConfig->getValueString($this->userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true), + true, + ); + if (!is_array($pickerSessionQueue)) { + $pickerSessionQueue = []; + } + return new DataResponse([ + 'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1', + 'last_photo_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_photo_import_timestamp', lazy: true), + 'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true), + 'nb_queued_sessions' => count($pickerSessionQueue), + ]); + } + + /** + * @NoAdminRequired + * + * Create a new Google Photos Picker session (Picker API) + * + * @return DataResponse + */ + public function createPickerSession(): DataResponse { + if ($this->accessToken === '' || $this->userId === null) { + return new DataResponse([], 400); + } + $result = $this->googlePhotosAPIService->createPickerSession($this->userId); + if (isset($result['error'])) { + return new DataResponse($result['error'], 401); + } + return new DataResponse($result); + } + + /** + * @NoAdminRequired + * + * Poll a Google Photos Picker session + * + * @param string $sessionId + * @return DataResponse + */ + public function getPickerSession(string $sessionId): DataResponse { + if ($this->accessToken === '' || $this->userId === null) { + return new DataResponse([], 400); + } + $result = $this->googlePhotosAPIService->getPickerSession($this->userId, $sessionId); + if (isset($result['error'])) { + return new DataResponse($result['error'], 401); + } + return new DataResponse($result); + } + + /** + * @NoAdminRequired + * + * Delete a Google Photos Picker session + * + * @param string $sessionId + * @return DataResponse + */ + public function deletePickerSession(string $sessionId): DataResponse { + if ($this->accessToken === '' || $this->userId === null) { + return new DataResponse([], 400); + } + $result = $this->googlePhotosAPIService->deletePickerSession($this->userId, $sessionId); + if (isset($result['error'])) { + return new DataResponse($result['error'], 401); + } + return new DataResponse($result); + } + + /** + * @NoAdminRequired + * + * Start downloading photos from a completed Picker session + * + * @param string $sessionId + * @return DataResponse + */ + public function importPhotos(string $sessionId = ''): DataResponse { + if ($this->accessToken === '' || $this->userId === null) { + return new DataResponse([], 400); + } + $result = $this->googlePhotosAPIService->startImportPhotos($this->userId, $sessionId); + if (isset($result['error'])) { + return new DataResponse($result['error'], 401); + } + return new DataResponse($result); + } + /** * @NoAdminRequired * diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 8c06fc95..4740787d 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -62,6 +62,18 @@ public function prepare(INotification $notification, string $languageCode): INot $l = $this->factory->get('integration_google', $languageCode); switch ($notification->getSubject()) { + case 'import_photos_finished': + /** @var array{nbImported?:string, targetPath: string} $p */ + $p = $notification->getSubjectParameters(); + $nbImported = (int)($p['nbImported'] ?? 0); + $targetPath = $p['targetPath']; + $content = $l->n('%n photo was imported from Google.', '%n photos were imported from Google.', $nbImported); + + $notification->setParsedSubject($content) + ->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'))) + ->setLink($this->url->linkToRouteAbsolute('files.view.index', ['dir' => $targetPath])); + return $notification; + case 'import_drive_finished': /** @var array{nbImported?:string, targetPath: string} $p */ $p = $notification->getSubjectParameters(); diff --git a/lib/Service/GoogleAPIService.php b/lib/Service/GoogleAPIService.php index ee2b6cc8..247baaf9 100644 --- a/lib/Service/GoogleAPIService.php +++ b/lib/Service/GoogleAPIService.php @@ -20,9 +20,14 @@ use OCA\Google\AppInfo\Application; use OCP\AppFramework\Services\IAppConfig; use OCP\Config\IUserConfig; +use OCP\Files\Folder; +use OCP\Files\InvalidPathException; +use OCP\Files\NotPermittedException; use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; use OCP\IL10N; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; use OCP\Notification\IManager as INotificationManager; use Psr\Log\LoggerInterface; use Throwable; @@ -353,6 +358,83 @@ public function simpleDownload(string $userId, string $url, $resource, array $pa } } + /** + * Download content from a URL and save it as a new file in a folder. + * Handles resource management, timestamp setting, and cleanup on failure. + * + * @param string $userId + * @param Folder $saveFolder Target Nextcloud folder + * @param string $fileName Name of the file to create + * @param string $fileUrl URL to download from + * @param int|null $mtime Unix timestamp for the file modification time; uses current time if null + * @param array $params Additional HTTP query parameters + * @return int|null downloaded file size in bytes, or null on failure + */ + public function downloadAndSaveFile( + string $userId, + Folder $saveFolder, + string $fileName, + string $fileUrl, + ?int $mtime = null, + array $params = [], + ): ?int { + try { + $savedFile = $saveFolder->newFile($fileName); + } catch (NotPermittedException|InvalidPathException $e) { + return null; + } + + try { + $resource = $savedFile->fopen('w'); + } catch (LockedException $e) { + if ($savedFile->isDeletable()) { + try { + $savedFile->delete(); + } catch (\Throwable $e) { + } + } + return null; + } + if ($resource === false) { + if ($savedFile->isDeletable()) { + try { + $savedFile->delete(); + } catch (\Throwable $e) { + } + } + return null; + } + + $res = $this->simpleDownload($userId, $fileUrl, $resource, $params); + if (!isset($res['error'])) { + if (is_resource($resource)) { + fclose($resource); + } + if ($mtime !== null) { + $savedFile->touch($mtime); + } else { + $savedFile->touch(); + } + $stat = $savedFile->stat(); + return (int)($stat['size'] ?? 0); + } else { + if (is_resource($resource)) { + fclose($resource); + } + if ($savedFile->isDeletable()) { + try { + $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE); + } catch (\Throwable $e) { + } + try { + $savedFile->delete(); + } catch (\Throwable $e) { + } + } + } + return null; + } + private function checkTokenExpiration(string $userId): void { $refreshToken = $this->secretService->getEncryptedUserValue($userId, 'refresh_token'); $expireAt = $this->userConfig->getValueInt($userId, Application::APP_ID, 'token_expires_at', lazy: true); diff --git a/lib/Service/GoogleDriveAPIService.php b/lib/Service/GoogleDriveAPIService.php index 39e0d678..5744f3c0 100644 --- a/lib/Service/GoogleDriveAPIService.php +++ b/lib/Service/GoogleDriveAPIService.php @@ -25,7 +25,6 @@ use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; -use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; use OCP\PreConditionNotMetException; use Psr\Log\LoggerInterface; @@ -155,6 +154,8 @@ public function startImportDrive(string $userId): array { public function cancelImport(string $userId): void { $this->jobList->remove(ImportDriveJob::class, ['user_id' => $userId]); + $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_drive', '0', lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'drive_import_running', '0', lazy: true); } /** @@ -550,62 +551,6 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder, /** * Create new file in the given folder with given filename - * Download contents of the file from Google Drive and save it into the created file - * @param Folder $saveFolder - * @param string $fileName - * @param string $userId - * @param string $fileUrl - * @param array $fileItem - * @param array $params - * @return ?int downloaded size, null if error during file creation or download - * @throws InvalidPathException - * @throws LockedException - * @throws NotFoundException - * @throws NotPermittedException - */ - private function downloadAndSaveFile( - Folder $saveFolder, string $fileName, string $userId, - string $fileUrl, array $fileItem, array $params = [], - ): ?int { - try { - $savedFile = $saveFolder->newFile($fileName); - } catch (NotPermittedException $e) { - return null; - } - - try { - $resource = $savedFile->fopen('w'); - } catch (LockedException $e) { - return null; - } - if ($resource === false) { - return null; - } - - $res = $this->googleApiService->simpleDownload($userId, $fileUrl, $resource, $params); - if (!isset($res['error'])) { - if (is_resource($resource)) { - fclose($resource); - } - if (isset($fileItem['modifiedTime'])) { - $d = new DateTime($fileItem['modifiedTime']); - $ts = $d->getTimestamp(); - $savedFile->touch($ts); - } else { - $savedFile->touch(); - } - $stat = $savedFile->stat(); - return $stat['size'] ?? 0; - } else { - if ($savedFile->isDeletable()) { - $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE); - $savedFile->delete(); - } - } - return null; - } - - /** * @param array $fileItem * @param string $userId * @param bool $hasNameConflict @@ -692,12 +637,21 @@ private function getDocumentRequestParams(string $mimeType, string $documentForm * @throws NotPermittedException */ private function getFile(string $userId, array $fileItem, Folder $saveFolder, string $fileName): ?int { + $mtime = null; + if (isset($fileItem['modifiedTime'])) { + try { + $mtime = (new DateTime($fileItem['modifiedTime']))->getTimestamp(); + } catch (Exception $e) { + // fall through: use null (current time) + } + } + if (in_array($fileItem['mimeType'], array_values(self::DOCUMENT_MIME_TYPES))) { $documentFormat = $this->getUserDocumentFormat($userId); // potentially a doc $params = $this->getDocumentRequestParams($fileItem['mimeType'], $documentFormat); $fileUrl = 'https://www.googleapis.com/drive/v3/files/' . urlencode((string)$fileItem['id']) . '/export'; - $result = $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem, $params); + $result = $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime, $params); if ($result !== null) { return $result; } @@ -727,11 +681,11 @@ private function getFile(string $userId, array $fileItem, Folder $saveFolder, st } } $this->logger->debug('Document export succeeded', ['fileItem' => $fileItem, 'fileUrl' => $fileUrl]); - return $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem); + return $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime); } elseif (isset($fileItem['webContentLink'])) { // classic file $fileUrl = 'https://www.googleapis.com/drive/v3/files/' . urlencode((string)$fileItem['id']) . '?alt=media'; - return $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem); + return $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime); } return null; } diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php new file mode 100644 index 00000000..69f2d2dd --- /dev/null +++ b/lib/Service/GooglePhotosAPIService.php @@ -0,0 +1,454 @@ +, expireTime?:string, error?:mixed} + */ + public function createPickerSession(string $userId): array { + $result = $this->googleApiService->request( + $userId, + 'v1/sessions', + ['pickingConfig' => ['maxItemCount' => 2000]], + 'POST', + self::PICKER_BASE_URL, + ); + if (isset($result['error'])) { + /** @psalm-suppress InvalidReturnStatement */ + return $result; + } + // append /autoclose so Google Photos closes its window after selection is done + if (isset($result['pickerUri'])) { + $result['pickerUri'] .= '/autoclose'; + } + /** @psalm-suppress InvalidReturnStatement */ + return $result; + } + + /** + * Poll an existing Picker session to check if the user has finished selecting + * + * @param string $userId + * @param string $sessionId + * @return array{mediaItemsSet?:bool, pollingConfig?:array, expireTime?:string, error?:string} + */ + public function getPickerSession(string $userId, string $sessionId): array { + return $this->googleApiService->request( + $userId, + 'v1/sessions/' . urlencode($sessionId), + [], + 'GET', + self::PICKER_BASE_URL, + ); + } + + /** + * Delete a Picker session (cleanup after import) + * + * Only clears the stored picker_session_id when it matches the session being deleted, + * so deleting a UI/queued session does not corrupt the active import session ID. + * + * @param string $userId + * @param string $sessionId + * @return array + */ + public function deletePickerSession(string $userId, string $sessionId): array { + $result = $this->googleApiService->request( + $userId, + 'v1/sessions/' . urlencode($sessionId), + [], + 'DELETE', + self::PICKER_BASE_URL, + ); + $storedSessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true); + if ($storedSessionId === $sessionId) { + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true); + } + return $result; + } + + /** + * Start a background import job for the given Picker session + * + * @param string $userId + * @param string $sessionId + * @return array{targetPath?:string, error?:string, queued?:true} + */ + public function startImportPhotos(string $userId, string $sessionId): array { + if (trim($sessionId) === '') { + return ['error' => 'No picker session ID provided']; + } + + $targetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true); + $targetPath = $targetPath ?: '/Google Photos'; + + $alreadyImporting = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1'; + if ($alreadyImporting) { + // Queue this session to run after the current one finishes + $queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true); + $queue = json_decode($queueRaw, true); + if (!is_array($queue)) { + $queue = []; + } + $queue[] = $sessionId; + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true); + return ['targetPath' => $targetPath, 'queued' => true]; + } + + // create root folder + $userFolder = $this->root->getUserFolder($userId); + if (!$userFolder->nodeExists($targetPath)) { + $userFolder->newFolder($targetPath); + } else { + $folder = $userFolder->get($targetPath); + if (!($folder instanceof Folder)) { + return ['error' => 'Impossible to create Google Photos folder']; + } + } + + $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '1', lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $sessionId, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true); + + $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]); + return ['targetPath' => $targetPath]; + } + + /** + * Cancel any pending import and optionally delete the active picker session + * + * @param string $userId + * @return void + */ + public function cancelImport(string $userId): void { + $this->jobList->remove(ImportPhotosJob::class, ['user_id' => $userId]); + $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true); + $sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true); + if ($sessionId !== '') { + $this->deletePickerSession($userId, $sessionId); + } + // Also delete all queued sessions so they don't remain as stale open sessions in Google + $queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true); + $queue = json_decode($queueRaw, true); + if (is_array($queue)) { + foreach ($queue as $queuedSessionId) { + if (is_string($queuedSessionId) && $queuedSessionId !== '') { + $this->deletePickerSession($userId, $queuedSessionId); + } + } + } + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true); + } + + /** + * Background job entry point: import a batch of photos then re-queue if unfinished + * + * @param string $userId + * @return void + */ + public function importPhotosJob(string $userId): void { + $this->logger->debug('Importing photos (Picker API) for ' . $userId); + + $this->userScopeService->setUserScope($userId); + $this->userScopeService->setFilesystemScope($userId); + + $importingPhotos = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1'; + if (!$importingPhotos) { + // Clear the concurrency guard so it does not delay future imports + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', 0, lazy: true); + return; + } + + $jobRunning = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true) === '1'; + $nowTs = (new DateTime())->getTimestamp(); + if ($jobRunning) { + $lastJobStart = $this->userConfig->getValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', lazy: true); + if ($lastJobStart !== 0 && ($nowTs - $lastJobStart < Application::IMPORT_JOB_TIMEOUT)) { + $this->logger->info( + 'Last job execution (' . strval($nowTs - $lastJobStart) . ') is less than ' + . strval(Application::IMPORT_JOB_TIMEOUT) . ' seconds ago, delaying execution', + ); + $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]); + return; + } + } + + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '1', lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', $nowTs, lazy: true); + + $targetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true); + $targetPath = $targetPath ?: '/Google Photos'; + $sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true); + $alreadyImported = $this->userConfig->getValueInt($userId, Application::APP_ID, 'nb_imported_photos', lazy: true); + + try { + $result = $this->importFromPickerSession($userId, $sessionId, $targetPath, 500000000, $alreadyImported); + } catch (Exception|Throwable $e) { + $result = ['error' => 'Unknown job failure. ' . $e->getMessage()]; + } + + if (isset($result['error']) || (isset($result['finished']) && $result['finished'])) { + // Clean up the picker session in both success and error cases + if ($sessionId !== '') { + $this->deletePickerSession($userId, $sessionId); + } + if (isset($result['finished']) && $result['finished']) { + $this->googleApiService->sendNCNotification($userId, 'import_photos_finished', [ + 'nbImported' => $alreadyImported + ($result['nbDownloaded'] ?? 0), + 'targetPath' => $targetPath, + ]); + } + if (isset($result['error'])) { + $this->logger->error('Google Photo import error: ' . $result['error'], ['app' => Application::APP_ID]); + // Clear the queue and page token so queued sessions are not left stuck after a failure + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); + } + // On successful completion, atomically transition to the next queued session if any, + // so importing_photos never has a transient '0' that would stop the polling client. + if (isset($result['finished']) && $result['finished']) { + $queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true); + $queue = json_decode($queueRaw, true); + if (!is_array($queue)) { + $queue = []; + } + if (!empty($queue)) { + $nextSessionId = array_shift($queue); + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $nextSessionId, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); + $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]); + } else { + $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true); + } + } else { + $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true); + } + } else { + $ts = (new DateTime())->getTimestamp(); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', $ts, lazy: true); + $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]); + } + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true); + } + + /** + * Download picked media items from a Picker session into a Nextcloud folder. + * Processes up to $maxDownloadSize bytes per call, then returns so the job can re-queue. + * + * @param string $userId + * @param string $sessionId + * @param string $targetPath + * @param int|null $maxDownloadSize + * @param int $alreadyImported + * @return array + */ + public function importFromPickerSession( + string $userId, string $sessionId, string $targetPath, + ?int $maxDownloadSize = null, int $alreadyImported = 0, + ): array { + if ($sessionId === '') { + return ['error' => 'No picker session ID stored']; + } + + $userFolder = $this->root->getUserFolder($userId); + if (!$userFolder->nodeExists($targetPath)) { + $folder = $userFolder->newFolder($targetPath); + } else { + $folder = $userFolder->get($targetPath); + if (!$folder instanceof Folder) { + return ['error' => 'Impossible to create Google Photos folder']; + } + } + + // Page through all picked media items + $downloadedSize = 0; + $nbDownloaded = 0; + $params = ['sessionId' => $sessionId, 'pageSize' => 100]; + $resumeToken = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); + if ($resumeToken !== '') { + $params['pageToken'] = $resumeToken; + } + + do { + $currentPageToken = $params['pageToken'] ?? ''; + $result = $this->googleApiService->request( + $userId, + 'v1/mediaItems', + $params, + 'GET', + self::PICKER_BASE_URL, + ); + if (isset($result['error'])) { + return $result; + } + $items = $result['mediaItems'] ?? []; + foreach ($items as $item) { + $size = $this->downloadPickerItem($userId, $item, $folder); + if ($size !== null) { + $nbDownloaded++; + $this->userConfig->setValueInt( + $userId, Application::APP_ID, 'nb_imported_photos', + $alreadyImported + $nbDownloaded, lazy: true, + ); + $downloadedSize += $size; + if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) { + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); + return [ + 'nbDownloaded' => $nbDownloaded, + 'targetPath' => $targetPath, + 'finished' => false, + ]; + } + } + } + $params['pageToken'] = $result['nextPageToken'] ?? ''; + } while (isset($result['nextPageToken'])); + + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); + return [ + 'nbDownloaded' => $nbDownloaded, + 'targetPath' => $targetPath, + 'finished' => true, + ]; + } + + /** + * Download a single PickedMediaItem into a Nextcloud folder. + * + * @param string $userId + * @param array{id:string, mediaFile?:array, createTime?:string} $item + * @param Folder $folder + * @return int|null downloaded byte count, or null if skipped/error + */ + private function downloadPickerItem(string $userId, array $item, Folder $folder): ?int { + $mediaFile = $item['mediaFile'] ?? null; + if ($mediaFile === null) { + return null; + } + + $baseUrl = $mediaFile['baseUrl'] ?? ''; + if ($baseUrl === '') { + return null; + } + + $mimeType = $mediaFile['mimeType'] ?? ''; + $rawName = $mediaFile['filename'] ?? ($item['id'] ?? 'unknown'); + $itemId = (string)($item['id'] ?? ''); + $baseName = $this->fileUtils->sanitizeFilename($rawName, $itemId); + + // Determine the target filename. + // Happy path: use the original name. If a file with that name already exists, + // check its metadata: same Google ID means this exact photo was already + // imported, so skip it. A different ID (name collision with another photo) + // falls back to an ID-prefixed filename, which is then the definitive name + // for this item across all future sessions. + if ($folder->nodeExists($baseName)) { + try { + $existing = $folder->get($baseName); + $meta = $this->metadataManager->getMetadata($existing->getId()); + if ($meta->hasKey(self::METADATA_KEY) && $meta->getString(self::METADATA_KEY) === $itemId) { + return null; // already imported + } + } catch (\Throwable) { + // fall through to ID-prefixed name on any unexpected error + } + $fileName = $itemId . '_' . $baseName; + if ($folder->nodeExists($fileName)) { + return null; // ID-prefixed version already imported too + } + } else { + $fileName = $baseName; + } + + // Build the download URL: images get =d (full quality + EXIF), videos get =dv + $isVideo = str_starts_with($mimeType, 'video/'); + $downloadUrl = $isVideo ? ($baseUrl . '=dv') : ($baseUrl . '=d'); + + $mtime = null; + if (isset($item['createTime'])) { + try { + $mtime = (new DateTime($item['createTime']))->getTimestamp(); + } catch (Exception $e) { + $this->logger->warning('Google Photo, invalid createTime, using current time: ' . $e->getMessage(), ['app' => Application::APP_ID]); + } + } + + $size = $this->googleApiService->downloadAndSaveFile($userId, $folder, $fileName, $downloadUrl, $mtime); + if ($size === null) { + return null; + } + + // Store the Google media ID in file metadata so future imports can + // identify this file by ID rather than filename alone. + if ($itemId !== '') { + try { + $savedFile = $folder->get($fileName); + $meta = $this->metadataManager->getMetadata($savedFile->getId(), true); + $meta->setString(self::METADATA_KEY, $itemId); + $this->metadataManager->saveMetadata($meta); + } catch (\Throwable $e) { + $this->logger->warning('Google Photo, could not save file metadata: ' . $e->getMessage(), ['app' => Application::APP_ID]); + } + } + return $size; + } +} diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php index 50c85112..af2867ca 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -45,6 +45,8 @@ public function getForm(): TemplateResponse { $userName = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_name', lazy: true); $driveOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'drive_output_dir', '/Google Drive', lazy: true); $driveOutputDir = $driveOutputDir ?: '/Google Drive'; + $photoOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true); + $photoOutputDir = $photoOutputDir ?: '/Google Photos'; $driveSharedWithMeOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'drive_shared_with_me_output_dir', '/Google Drive/Shared with me', lazy: true); $driveSharedWithMeOutputDir = $driveSharedWithMeOutputDir ?: '/Google Drive/Shared with me'; $considerAllEvents = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'consider_all_events', '1', lazy: true) === '1'; @@ -94,6 +96,7 @@ public function getForm(): TemplateResponse { 'document_format' => $documentFormat, 'drive_output_dir' => $driveOutputDir, 'drive_shared_with_me_output_dir' => $driveSharedWithMeOutputDir, + 'photo_output_dir' => $photoOutputDir, 'user_scopes' => $userScopes, ]; $this->initialStateService->provideInitialState('user-config', $userConfig); diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 9bc0d5f7..21a07d29 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -11,6 +11,11 @@ + + + + + diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index e35d6977..e5408675 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -25,7 +25,7 @@

{{ t('integration_google', 'Put the "Client ID" and "Client secret" below.') }}
- {{ t('integration_google', 'Finally, go to "APIs & Services" => "Library" and add the following APIs: "Google Drive API", "Google Calendar API", and "People API".') }} + {{ t('integration_google', 'Finally, go to "APIs & Services" => "Library" and add the following APIs: "Google Drive API", "Google Calendar API", "People API" and "Google Photos Picker API".') }}
{{ t('integration_google', 'Your Nextcloud users will then see a "Connect to Google" button in their personal settings.') }}

diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index f2701fbe..bd389c3e 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -105,6 +105,133 @@
+
+

{{ t('integration_google', 'Photos') }}

+ +
+
+ + + {{ t('integration_google', 'Import queued, starting soon…') }} + + + {{ n('integration_google', '{amount} photo imported', '{amount} photos imported', nbImportedPhotos, { amount: nbImportedPhotos }) }} + +
+

+ + {{ n('integration_google', '{count} session queued', '{count} sessions queued', queuedSessions, { count: queuedSessions }) }} +

+

+ + {{ t('integration_google', 'You can close this page. You will be notified when the import finishes.') }} +

+ +
+ + + {{ t('integration_google', 'Queue another session') }} + +
+
+

+ + {{ t('integration_google', 'Waiting for you to finish your selection in the Google Photos window…') }} +

+ + + {{ t('integration_google', 'Open Google Photos picker') }} + + + + {{ t('integration_google', 'Cancel photo picking') }} + +
+ + + {{ t('integration_google', 'Cancel importing all photos') }} + +
+ +
+
+ + + + + +
+
+ +
+

+ + {{ t('integration_google', 'Up to 2,000 photos can be imported per session. Hold Shift and click to select many photos at once in the Google Photos picker.') }} +

+

+ + {{ t('integration_google', 'Warning: Google does not provide location data in imported photos.') }} +

+ + + {{ t('integration_google', 'Open Google Photos picker') }} + +
+ +
+

+ + {{ t('integration_google', 'Waiting for you to finish your selection in the Google Photos window…') }} +

+

+ + {{ t('integration_google', 'Import will start automatically once you confirm your selection.') }} +

+ + + {{ t('integration_google', 'Open Google Photos picker') }} + + + + {{ t('integration_google', 'Cancel photo picking') }} + +
+
+

+

{{ t('integration_google', 'Drive') }}

@@ -212,6 +339,9 @@ import CheckIcon from 'vue-material-design-icons/Check.vue' import AccountGroupOutlineIcon from 'vue-material-design-icons/AccountGroupOutline.vue' import FileDocumentOutlineIcon from 'vue-material-design-icons/FileDocumentOutline.vue' +import ImageMultipleOutlineIcon from 'vue-material-design-icons/ImageMultipleOutline.vue' +import InformationOutlineIcon from 'vue-material-design-icons/InformationOutline.vue' +import AlertOutlineIcon from 'vue-material-design-icons/AlertOutline.vue' import FileOutlineIcon from 'vue-material-design-icons/FileOutline.vue' import FolderOutlineIcon from 'vue-material-design-icons/FolderOutline.vue' import CloseIcon from 'vue-material-design-icons/Close.vue' @@ -231,6 +361,7 @@ import { showSuccess, showError } from '@nextcloud/dialogs' import NcAppNavigationIconBullet from '@nextcloud/vue/components/NcAppNavigationIconBullet' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcButton from '@nextcloud/vue/components/NcButton' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import { humanFileSize } from '../utils.js' import GoogleIconColor from './icons/GoogleIconColor.vue' @@ -245,6 +376,9 @@ export default { NcCheckboxRadioSwitch, CloseIcon, GoogleDriveIcon, + ImageMultipleOutlineIcon, + InformationOutlineIcon, + AlertOutlineIcon, PencilOutlineIcon, AccountMultipleOutlineIcon, TrayArrowDownIcon, @@ -254,6 +388,7 @@ export default { FileOutlineIcon, CheckIcon, AccountGroupOutlineIcon, + NcLoadingIcon, }, props: [], @@ -274,6 +409,18 @@ export default { selectedAddressBook: 0, newAddressBookName: 'Google Contacts import', importingContacts: false, + // photos (Picker API) + creatingPickerSession: false, + startingPhotoImport: false, + pickerSessionId: null, + pickerUri: null, + pickerPollTimer: null, + importingPhotos: false, + lastPhotoImportTimestamp: 0, + nbImportedPhotos: 0, + queuedSessions: 0, + photoImportLoop: null, + oauthBroadcastChannel: null, // drive driveSize: 0, gettingDriveInfo: false, @@ -328,6 +475,19 @@ export default { watch: { }, + beforeUnmount() { + clearInterval(this.pickerPollTimer) + this.pickerPollTimer = null + clearInterval(this.photoImportLoop) + this.photoImportLoop = null + clearInterval(this.driveImportLoop) + this.driveImportLoop = null + if (this.oauthBroadcastChannel) { + this.oauthBroadcastChannel.close() + this.oauthBroadcastChannel = null + } + }, + mounted() { const paramString = window.location.search.slice(1) // eslint-disable-next-line @@ -353,6 +513,9 @@ export default { if (this.state.user_scopes.can_access_contacts) { this.getNbGoogleContacts() } + if (this.state.user_scopes.can_access_photos) { + this.getPhotoImportValues(true) + } if (this.state.user_scopes.can_access_drive) { this.getGoogleDriveInfo() this.getDriveImportValues(true) @@ -395,6 +558,7 @@ export default { 'https://www.googleapis.com/auth/contacts.readonly', 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly', ] const requestUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' + 'client_id=' + encodeURIComponent(this.state.client_id) @@ -417,14 +581,32 @@ export default { const ssoWindow = window.open( requestUrl, t('integration_google', 'Sign in with Google'), - 'toolbar=no, menubar=no, width=600, height=700', + 'toolbar=no, menubar=no, width=600, height=700,noopener,noreferrer', ) - ssoWindow.focus() - window.addEventListener('message', (event) => { + if (ssoWindow) { + ssoWindow.focus() + } + const handleOAuthMessage = (event) => { + if (!event.data?.username) { + return + } console.debug('Child window message received', event) this.state.user_name = event.data.username this.loadData() - }) + } + // Close any previous channel before creating a new one + if (this.oauthBroadcastChannel) { + this.oauthBroadcastChannel.close() + } + this.oauthBroadcastChannel = new BroadcastChannel('integration_google_oauth') + this.oauthBroadcastChannel.onmessage = (event) => { + if (!event.data?.username) { + return + } + this.oauthBroadcastChannel.close() + this.oauthBroadcastChannel = null + handleOAuthMessage(event) + } } else { window.location.replace(requestUrl) } @@ -592,6 +774,180 @@ export default { this.importingCalendar[calId] = false }) }, + getPhotoImportValues(launchLoop = false) { + const url = generateUrl('/apps/integration_google/import-photos-info') + axios.get(url) + .then((response) => { + if (response.data && Object.keys(response.data).length > 0) { + this.lastPhotoImportTimestamp = response.data.last_photo_import_timestamp + this.nbImportedPhotos = response.data.nb_imported_photos + this.queuedSessions = response.data.nb_queued_sessions ?? 0 + this.importingPhotos = response.data.importing_photos + if (!this.importingPhotos) { + clearInterval(this.photoImportLoop) + this.photoImportLoop = null + } else if (launchLoop && !this.photoImportLoop) { + this.photoImportLoop = setInterval(() => this.getPhotoImportValues(), 5000) + } + } + }) + .catch((error) => { + console.debug(error) + }) + }, + /** + * Step 1 – Create a Picker session and open the Google Photos picker window. + */ + onOpenPicker() { + // If a session is already open, just reopen the picker popup + if (this.pickerSessionId && this.pickerUri) { + const pickerWindow = window.open( + this.pickerUri, + t('integration_google', 'Google Photos Picker'), + 'toolbar=no, menubar=no, width=900, height=700,noopener,noreferrer', + ) + if (pickerWindow) { + pickerWindow.focus() + } + return + } + this.creatingPickerSession = true + const url = generateUrl('/apps/integration_google/picker-session') + axios.post(url) + .then((response) => { + this.pickerSessionId = response.data.id + this.pickerUri = response.data.pickerUri + // Open the picker in a popup window immediately + const pickerWindow = window.open( + response.data.pickerUri, + t('integration_google', 'Google Photos Picker'), + 'toolbar=no, menubar=no, width=900, height=700,noopener,noreferrer', + ) + if (pickerWindow) { + pickerWindow.focus() + } + // Start polling for selection completion + const defaultPollInterval = 5000 + const parsedPollInterval = parseFloat(response.data.pollingConfig?.pollInterval ?? '5s') * 1000 + const pollInterval = Number.isFinite(parsedPollInterval) ? Math.max(parsedPollInterval, 4000) : defaultPollInterval + this.pickerPollTimer = setInterval(() => this.pollPickerSession(), pollInterval) + }) + .catch((error) => { + showError( + t('integration_google', 'Failed to create Google Photos picker session') + + ': ' + error.response?.request?.responseText, + ) + }) + .finally(() => { + this.creatingPickerSession = false + }) + }, + /** + * Poll the picker session until the user confirms their selection, then auto-import. + */ + pollPickerSession() { + if (!this.pickerSessionId) { + return + } + const url = generateUrl('/apps/integration_google/picker-session') + axios.get(url, { params: { sessionId: this.pickerSessionId } }) + .then((response) => { + if (response.data.mediaItemsSet === true && !this.startingPhotoImport) { + this.onImportPhotos() + } + }) + .catch((error) => { + console.debug('Picker poll error', error) + }) + }, + /** + * Step 3 – User confirmed selection; trigger the background import job. + */ + onImportPhotos() { + this.startingPhotoImport = true + const url = generateUrl('/apps/integration_google/import-photos') + axios.post(url, { sessionId: this.pickerSessionId }) + .then((response) => { + this.startingPhotoImport = false + clearInterval(this.pickerPollTimer) + this.pickerPollTimer = null + this.pickerSessionId = null + this.pickerUri = null + if (response.data.queued) { + showSuccess( + t('integration_google', 'Session queued, it will start automatically after the current import finishes'), + ) + this.getPhotoImportValues() + } else { + const targetPath = response.data.targetPath + showSuccess( + t('integration_google', 'Starting importing photos in {targetPath} directory', { targetPath }), + ) + // Reset picker state; import progress tracked via polling + this.getPhotoImportValues(true) + } + }) + .catch((error) => { + this.startingPhotoImport = false + showError( + t('integration_google', 'Failed to start importing Google Photos') + + ': ' + error.response?.request?.responseText, + ) + // Restart polling so the user can retry once the session is ready + if (this.pickerSessionId && !this.pickerPollTimer) { + this.pickerPollTimer = setInterval(() => this.pollPickerSession(), 5000) + } + }) + }, + /** + * Cancel an in-progress picker session or background import. + */ + onCancelPickerSession() { + clearInterval(this.pickerPollTimer) + this.pickerPollTimer = null + const sessionId = this.pickerSessionId + this.pickerSessionId = null + this.pickerUri = null + if (sessionId) { + const url = generateUrl('/apps/integration_google/picker-session') + axios.delete(url, { params: { sessionId } }) + .catch((error) => { + console.debug('Failed to delete picker session', error) + }) + } + }, + onCancelPhotoImport() { + this.importingPhotos = false + clearInterval(this.photoImportLoop) + this.photoImportLoop = null + const req = { + values: { + importing_photos: '0', + last_photo_import_timestamp: '0', + nb_imported_photos: '0', + }, + } + const url = generateUrl('/apps/integration_google/config') + axios.put(url, req) + .catch((error) => { + console.debug(error) + }) + }, + onPhotoOutputChange() { + OC.dialogs.filepicker( + t('integration_google', 'Choose where to write imported photos'), + (targetPath) => { + if (targetPath === '') { + targetPath = '/' + } + this.state.photo_output_dir = targetPath + this.saveOptions({ photo_output_dir: this.state.photo_output_dir }) + }, + false, + 'httpd/unix-directory', + true, + ) + }, getDriveImportValues(launchLoop = false) { const url = generateUrl('/apps/integration_google/import-files-info') axios.get(url) @@ -781,6 +1137,41 @@ export default { width: calc(300px - 3px - var(--default-clickable-area)); } + #google-photos input { + width: calc(300px - 3px - var(--default-clickable-area)); + } + + #google-photos button { + margin-top: 4px; + } + + .photo-import-status { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .photo-import-info { + display: flex; + align-items: center; + gap: 8px; + } + + .photo-progress-bar { + width: 300px; + } + + .picker-session-buttons { + display: flex; + flex-direction: column; + gap: 8px; + } + + .cancel-session-btn { + margin-top: 0; + } + #google-contacts { select { width: 300px; diff --git a/src/popupSuccess.js b/src/popupSuccess.js index 4008c71c..0f76e698 100644 --- a/src/popupSuccess.js +++ b/src/popupSuccess.js @@ -3,7 +3,12 @@ import { loadState } from '@nextcloud/initial-state' const state = loadState('integration_google', 'popup-data') const username = state.user_name -if (window.opener) { - window.opener.postMessage({ username }) +try { + if (typeof BroadcastChannel !== 'undefined') { + const bc = new BroadcastChannel('integration_google_oauth') + bc.postMessage({ username }) + bc.close() + } +} finally { window.close() }