From 8dfe44b43eff1266bba564019bc6d281ed25c8d7 Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:16:12 +0100 Subject: [PATCH 01/25] feat(photos): replace legacy Photos API with Google Photos Picker API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the deprecated Google Photos Library API (which required the broad `photoslibrary.readonly` scope) with the new Google Photos Picker API. Users now explicitly select photos in a first-party Google-hosted popup before any data is accessed by Nextcloud. ## New OAuth scope Adds `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` in place of the old library read scope. Existing users must disconnect and re-authenticate to obtain this scope. ## Backend (PHP) ### GooglePhotosAPIService (new) - POST /v1/sessions — create a Picker session and return the hosted picker URI - GET /v1/sessions/{id} — poll until mediaItemsSet becomes true - GET /v1/mediaItems?sessionId= — paginate picked items (100 per page) - DELETE /v1/sessions/{id} — clean up session after import or cancellation - importFromPickerSession(): paginated download loop capped at 500 MB per run; persists nextPageToken in user config so large libraries resume across job runs without re-fetching already-processed pages; updates nb_imported_photos and nb_photos_seen after each item/page for live frontend progress - Cross-session deduplication: downloaded item IDs stored in imported_photo_ids user config key and skipped on re-import; ID list is scoped to target folder - downloadPickerItem(): full-quality download URL (=d images, =dv video); deletes orphaned empty file on LockedException or fopen failure; closes resource before unlocking/deleting on download error - startImportPhotos(): resets all progress counters including photo_next_page_token - cancelImport(): removes pending job and deletes active picker session via API - deletePickerSession(): DELETE /v1/sessions/{id} + clears picker_session_id ### ImportPhotosJob (new background job) - Extends QueuedJob; runs inside user and filesystem scope - Timeout guard to prevent overlapping runs - Downloads up to 500 MB per run then re-queues; sends Nextcloud notification on completion, then cleans up the picker session ### GoogleAPIController (extended) - createPickerSession — POST /picker-session - getPickerSession — GET /picker-session?sessionId= - deletePickerSession — DELETE /picker-session?sessionId= - importPhotos — GET /import-photos?sessionId= - getImportPhotosInformation — GET /import-photos-info ### ConfigController (extended) - GET /config returns current user config; used by OAuth popup to refresh parent page without full reload - setConfig clears user_scopes on disconnect so stale scope flags do not persist ### Notifier (extended) - import_photos_finished: pluralised notification with item count and target path ### Routes - POST/GET/DELETE /picker-session - GET /import-photos, /import-photos-info ## Frontend (Vue) ### AdminSettings.vue - Updated API list to show "Google Photos Picker API" (not "Photos Library API") ### PersonalSettings.vue Picker flow (replaces old library-read UI entirely): 1. "Open Google Photos picker" creates a session and immediately opens the picker in a popup — no second confirmation step 2. setInterval polls GET /picker-session every ~4 s; when mediaItemsSet is true the import is triggered automatically 3. While picker is open: hint about 2000-photo limit, location data warning, "auto-import" hint, Open Picker and Cancel buttons 4. Import-in-progress UI: spinner, queued/progress messages, progress bar, cancel button; progress polled every 5 s - postMessage origin validated against window.location.origin to prevent spoofed messages from cross-origin iframes/popups; listener removed after first valid message to avoid leaks - onCancelPickerSession calls DELETE /picker-session backend before clearing local state so the session is cleaned up on Google's side - Removed unused pickerSessionDone data property and lastPhotoImportDate computed property Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- appinfo/routes.php | 6 + lib/BackgroundJob/ImportPhotosJob.php | 39 +++ lib/Controller/ConfigController.php | 32 +- lib/Controller/GoogleAPIController.php | 95 ++++++ lib/Notification/Notifier.php | 12 + lib/Service/GooglePhotosAPIService.php | 422 +++++++++++++++++++++++++ lib/Settings/Personal.php | 3 + src/components/AdminSettings.vue | 2 +- src/components/PersonalSettings.vue | 330 ++++++++++++++++++- 9 files changed, 936 insertions(+), 5 deletions(-) create mode 100644 lib/BackgroundJob/ImportPhotosJob.php create mode 100644 lib/Service/GooglePhotosAPIService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index ec357a78..a3cb0a2b 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' => 'GET'], + ['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..7fff27d8 --- /dev/null +++ b/lib/BackgroundJob/ImportPhotosJob.php @@ -0,0 +1,39 @@ +service->importPhotosJob($userId); + } +} diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index 35b70752..756e7749 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_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,7 @@ 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'); $result['user_name'] = ''; } else { if (isset($values['drive_output_dir'])) { @@ -100,6 +126,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 +221,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..24eedfee 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,99 @@ 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); + } + return new DataResponse([ + 'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1', + 'last_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_import_timestamp', lazy: true), + 'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true), + 'nb_photos_seen' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_photos_seen', lazy: true), + ]); + } + + /** + * @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/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php new file mode 100644 index 00000000..abdd4a3a --- /dev/null +++ b/lib/Service/GooglePhotosAPIService.php @@ -0,0 +1,422 @@ +googleApiService->request( + $userId, + 'v1/sessions', + ['pickingConfig' => ['maxItemCount' => '2000']], + 'POST', + self::PICKER_BASE_URL, + ); + if (isset($result['error'])) { + return $result; + } + // persist session id so the background job can use it + if (isset($result['id'])) { + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $result['id'], lazy: true); + } + // append /autoclose so Google Photos closes its window after selection is done + if (isset($result['pickerUri'])) { + $result['pickerUri'] .= '/autoclose'; + } + 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) + * + * @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, + ); + $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} + */ + public function startImportPhotos(string $userId, string $sessionId): array { + $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) { + return ['targetPath' => $targetPath]; + } + + // 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, 'nb_photos_seen', 0, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_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]); + 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]); + $sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true); + if ($sessionId !== '') { + $this->deletePickerSession($userId, $sessionId); + } + } + + /** + * 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) { + 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'])) { + if (isset($result['finished']) && $result['finished']) { + $this->googleApiService->sendNCNotification($userId, 'import_photos_finished', [ + 'nbImported' => $alreadyImported + ($result['nbDownloaded'] ?? 0), + 'targetPath' => $targetPath, + ]); + // Clean up the picker session now that we have all items + if ($sessionId !== '') { + $this->deletePickerSession($userId, $sessionId); + } + } + if (isset($result['error'])) { + $this->logger->error('Google Photo import error: ' . $result['error'], ['app' => Application::APP_ID]); + } + $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, 'nb_photos_seen', 0, lazy: true); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true); + } else { + $ts = (new DateTime())->getTimestamp(); + $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_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']; + } + } + + // Load cross-session dedup state, scoped to the current target folder. + // If the user has changed the output folder, reset the ID list so photos + // can be imported again into the new (empty) location. + $dedupTargetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_dedup_target_path', '', lazy: true); + if ($dedupTargetPath !== $targetPath) { + $importedIds = []; + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_dedup_target_path', $targetPath, lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true); + } else { + $importedIdsRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true); + $importedIds = json_decode($importedIdsRaw, true) ?? []; + } + + // Page through all picked media items + $downloadedSize = 0; + $nbDownloaded = 0; + $totalSeenNumber = 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) { + $totalSeenNumber++; + $itemId = $item['id'] ?? ''; + // Skip photos already imported in a previous session + if ($itemId !== '' && array_key_exists($itemId, $importedIds)) { + continue; + } + $size = $this->downloadPickerItem($userId, $item, $folder); + if ($size !== null) { + $nbDownloaded++; + if ($itemId !== '') { + $importedIds[$itemId] = 1; + } + $this->userConfig->setValueInt( + $userId, Application::APP_ID, 'nb_imported_photos', + $alreadyImported + $nbDownloaded, lazy: true, + ); + $downloadedSize += $size; + if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) { + $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); return [ + 'nbDownloaded' => $nbDownloaded, + 'targetPath' => $targetPath, + 'finished' => false, + 'totalSeen' => $totalSeenNumber, + ]; + } + } + } + // Update progress counters after each page + $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); + $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, + 'totalSeen' => $totalSeenNumber, + ]; + } + + /** + * 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'); + $fileName = $this->fileUtils->sanitizeFilename($rawName, (string)($item['id'] ?? '')); + + // Avoid duplicate filenames + if ($folder->nodeExists($fileName)) { + $fileName = ($item['id'] ?? 'dup') . '_' . $fileName; + } + if ($folder->nodeExists($fileName)) { + return null; // already imported + } + + // 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'); + + $savedFile = $folder->newFile($fileName); + try { + $resource = $savedFile->fopen('w'); + } catch (LockedException $e) { + $this->logger->warning('Google Photo, error opening target file: file is locked', ['app' => Application::APP_ID]); + if ($savedFile->isDeletable()) { + $savedFile->delete(); + } + return null; + } + if ($resource === false) { + $this->logger->warning('Google Photo, error opening target file', ['app' => Application::APP_ID]); + if ($savedFile->isDeletable()) { + $savedFile->delete(); + } + return null; + } + + $res = $this->googleApiService->simpleDownload($userId, $downloadUrl, $resource); + if (!isset($res['error'])) { + if (is_resource($resource)) { + fclose($resource); + } + if (isset($item['createTime'])) { + $d = new DateTime($item['createTime']); + $savedFile->touch($d->getTimestamp()); + } else { + $savedFile->touch(); + } + $stat = $savedFile->stat(); + return (int)($stat['size'] ?? 0); + } else { + $this->logger->warning('Google API error downloading photo: ' . $res['error'], ['app' => Application::APP_ID]); + if (is_resource($resource)) { + fclose($resource); + } + if ($savedFile->isDeletable()) { + $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE); + $savedFile->delete(); + } + } + return null; + } +} 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/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..6fd3851f 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -105,6 +105,103 @@
+
+

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

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

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

+ + + {{ t('integration_google', 'Cancel photo import') }} + +
+ +
+
+ + + + + +
+
+ +
+

+ + {{ 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') }} + +
+
+

+

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

@@ -212,6 +309,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 +331,8 @@ 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 NcProgressBar from '@nextcloud/vue/components/NcProgressBar' import { humanFileSize } from '../utils.js' import GoogleIconColor from './icons/GoogleIconColor.vue' @@ -245,6 +347,9 @@ export default { NcCheckboxRadioSwitch, CloseIcon, GoogleDriveIcon, + ImageMultipleOutlineIcon, + InformationOutlineIcon, + AlertOutlineIcon, PencilOutlineIcon, AccountMultipleOutlineIcon, TrayArrowDownIcon, @@ -254,6 +359,8 @@ export default { FileOutlineIcon, CheckIcon, AccountGroupOutlineIcon, + NcLoadingIcon, + NcProgressBar, }, props: [], @@ -274,6 +381,16 @@ export default { selectedAddressBook: 0, newAddressBookName: 'Google Contacts import', importingContacts: false, + // photos (Picker API) + creatingPickerSession: false, + pickerSessionId: null, + pickerUri: null, + pickerPollTimer: null, + importingPhotos: false, + lastPhotoImportTimestamp: 0, + nbImportedPhotos: 0, + nbPhotosSeen: 0, + photoImportLoop: null, // drive driveSize: 0, gettingDriveInfo: false, @@ -353,6 +470,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 +515,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) @@ -420,11 +541,31 @@ export default { 'toolbar=no, menubar=no, width=600, height=700', ) ssoWindow.focus() - window.addEventListener('message', (event) => { + const messageListener = (event) => { + if (event.origin !== window.location.origin) { + return + } + if (!event.data?.username) { + return + } + window.removeEventListener('message', messageListener) console.debug('Child window message received', event) this.state.user_name = event.data.username - this.loadData() - }) + // Fetch the full config (including user_scopes) so the page + // updates without requiring a manual refresh + const configUrl = generateUrl('/apps/integration_google/config') + axios.get(configUrl) + .then((response) => { + if (response.data) { + Object.assign(this.state, response.data) + } + this.loadData() + }) + .catch(() => { + this.loadData() + }) + } + window.addEventListener('message', messageListener) } else { window.location.replace(requestUrl) } @@ -592,6 +733,160 @@ 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_import_timestamp + this.nbImportedPhotos = response.data.nb_imported_photos + this.nbPhotosSeen = response.data.nb_photos_seen ?? 0 + this.importingPhotos = response.data.importing_photos + if (!this.importingPhotos) { + clearInterval(this.photoImportLoop) + } else if (launchLoop) { + 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', + ) + 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', + ) + if (pickerWindow) { + pickerWindow.focus() + } + // Start polling for selection completion + const pollInterval = parseFloat(response.data.pollingConfig?.pollInterval ?? '5s') * 1000 + this.pickerPollTimer = setInterval(() => this.pollPickerSession(), Math.max(pollInterval, 4000)) + }) + .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) { + clearInterval(this.pickerPollTimer) + this.onImportPhotos() + } + }) + .catch((error) => { + console.debug('Picker poll error', error) + }) + }, + /** + * Step 3 – User confirmed selection; trigger the background import job. + */ + onImportPhotos() { + const url = generateUrl('/apps/integration_google/import-photos') + axios.get(url, { params: { sessionId: this.pickerSessionId } }) + .then((response) => { + 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.pickerSessionId = null + this.pickerUri = null + this.getPhotoImportValues(true) + }) + .catch((error) => { + showError( + t('integration_google', 'Failed to start importing Google Photos') + + ': ' + error.response?.request?.responseText, + ) + }) + }, + /** + * Cancel an in-progress picker session or background import. + */ + onCancelPickerSession() { + clearInterval(this.pickerPollTimer) + 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) + const req = { + values: { + importing_photos: '0', + last_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 +1076,35 @@ 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; + } + + .cancel-session-btn { + margin-top: 8px; + } + #google-contacts { select { width: 300px; From 693ecff50b142374a664f53aa39571bc2ea9ba5d Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:18:31 +0100 Subject: [PATCH 02/25] chore: bump version to 4.3.2 Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 45ae1fac..4b2af0de 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,7 +4,7 @@ Google integration Import Google data into Nextcloud - 4.3.1 + 4.3.2 agpl Julien Veyssier Google From fce8b2fb5ad4fac088aafe6800125abb4e292b30 Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:48:18 +0100 Subject: [PATCH 03/25] chore: revert version to 4.3.1 Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 4b2af0de..45ae1fac 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,7 +4,7 @@ Google integration Import Google data into Nextcloud - 4.3.2 + 4.3.1 agpl Julien Veyssier Google From 19a71c63ab539368196ee53745f4e5591941cd7e Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:49:44 +0100 Subject: [PATCH 04/25] fix(photos): address remaining Copilot review comments - startImportPhotos: validate sessionId is non-empty before proceeding - importFromPickerSession: fix multi-statement collapse on cap-hit early return - importPhotosJob: delete picker session on error path too, not just on success - revert appinfo/info.xml version back to 4.3.1 Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- lib/Service/GooglePhotosAPIService.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index abdd4a3a..dcddfec9 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -117,6 +117,10 @@ public function deletePickerSession(string $userId, string $sessionId): array { * @return array{targetPath?:string, error?:string} */ 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'; @@ -219,6 +223,10 @@ public function importPhotosJob(string $userId): void { } if (isset($result['error'])) { $this->logger->error('Google Photo import error: ' . $result['error'], ['app' => Application::APP_ID]); + // Clean up the picker session on error to avoid stale sessions + if ($sessionId !== '') { + $this->deletePickerSession($userId, $sessionId); + } } $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); @@ -317,7 +325,9 @@ public function importFromPickerSession( $downloadedSize += $size; if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) { $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true); - $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); return [ + $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); + $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); + return [ 'nbDownloaded' => $nbDownloaded, 'targetPath' => $targetPath, 'finished' => false, From bb64738c6ea5cfc06f40c5534a2f037280391e21 Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:14:21 +0100 Subject: [PATCH 05/25] fix(photos): fix polling interval leak and progress text pluralization check; also null-out the reference after clearInterval so the guard works correctly - Progress text: use t() with '{imported} of {total} photos imported' instead of n() so plural is always 'photos' regardless of imported count vs total Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- src/components/PersonalSettings.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 6fd3851f..c4280a51 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -116,7 +116,7 @@ {{ t('integration_google', 'Import queued, starting soon…') }} - {{ n('integration_google', '{imported} of {total} photo imported', '{imported} of {total} photos imported', nbImportedPhotos, { imported: nbImportedPhotos, total: nbPhotosSeen }) }} + {{ t('integration_google', '{imported} of {total} photos imported', { imported: nbImportedPhotos, total: nbPhotosSeen }) }} {{ n('integration_google', '{amount} photo imported', '{amount} photos imported', nbImportedPhotos, { amount: nbImportedPhotos }) }} @@ -744,7 +744,8 @@ export default { this.importingPhotos = response.data.importing_photos if (!this.importingPhotos) { clearInterval(this.photoImportLoop) - } else if (launchLoop) { + this.photoImportLoop = null + } else if (launchLoop && !this.photoImportLoop) { this.photoImportLoop = setInterval(() => this.getPhotoImportValues(), 5000) } } From 5c07f421bce16e3c66325a0bb90f66038c084230 Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:19:03 +0100 Subject: [PATCH 06/25] fix(photos): address Copilot review comments round 3 - Seed $totalSeenNumber from stored nb_photos_seen when resuming a paginated import so the progress counter stays monotonic across re-queued jobs instead of resetting to 0 each run - Add beforeUnmount hook to clear pickerPollTimer and photoImportLoop intervals so they don't fire against a destroyed component - Wrap folder->newFile() in downloadPickerItem() with a NotPermittedException|InvalidPathException catch so a single bad file path skips that item rather than aborting the whole import job Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- lib/Service/GooglePhotosAPIService.php | 15 +++++++++++++-- src/components/PersonalSettings.vue | 7 +++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index dcddfec9..ece78a71 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -20,7 +20,9 @@ use OCP\BackgroundJob\IJobList; use OCP\Config\IUserConfig; use OCP\Files\Folder; +use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; use Psr\Log\LoggerInterface; @@ -285,9 +287,12 @@ public function importFromPickerSession( // Page through all picked media items $downloadedSize = 0; $nbDownloaded = 0; - $totalSeenNumber = 0; $params = ['sessionId' => $sessionId, 'pageSize' => 100]; $resumeToken = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); + // Seed seen counter from stored value when resuming so progress stays monotonic + $totalSeenNumber = $resumeToken !== '' + ? $this->userConfig->getValueInt($userId, Application::APP_ID, 'nb_photos_seen', 0, lazy: true) + : 0; if ($resumeToken !== '') { $params['pageToken'] = $resumeToken; } @@ -386,7 +391,13 @@ private function downloadPickerItem(string $userId, array $item, Folder $folder) $isVideo = str_starts_with($mimeType, 'video/'); $downloadUrl = $isVideo ? ($baseUrl . '=dv') : ($baseUrl . '=d'); - $savedFile = $folder->newFile($fileName); + try { + $savedFile = $folder->newFile($fileName); + } catch (NotPermittedException|InvalidPathException $e) { + $this->logger->warning('Google Photo, skipping file creation for picker item: ' . $e->getMessage(), ['app' => Application::APP_ID]); + return null; + } + try { $resource = $savedFile->fopen('w'); } catch (LockedException $e) { diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index c4280a51..f253f04d 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -445,6 +445,13 @@ export default { watch: { }, + beforeUnmount() { + clearInterval(this.pickerPollTimer) + this.pickerPollTimer = null + clearInterval(this.photoImportLoop) + this.photoImportLoop = null + }, + mounted() { const paramString = window.location.search.slice(1) // eslint-disable-next-line From b645069536e80c504ba9b88c654aa1ba17142446 Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:43:53 +0100 Subject: [PATCH 07/25] fix(photos): address Copilot review comments round 4 - Replace window.opener.postMessage with BroadcastChannel in the OAuth popup flow so noopener,noreferrer can be added to all window.open() calls (mitigates reverse-tabnabbing) - Clear imported_photo_ids when a session finishes completely so the dedup map does not grow unboundedly across future sessions - Fix maxItemCount sent as string '2000' to integer 2000 to match the API's expected JSON type Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- lib/Service/GooglePhotosAPIService.php | 5 ++++- src/components/PersonalSettings.vue | 19 +++++++++---------- src/popupSuccess.js | 8 ++++---- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index ece78a71..465eb929 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -57,7 +57,7 @@ public function createPickerSession(string $userId): array { $result = $this->googleApiService->request( $userId, 'v1/sessions', - ['pickingConfig' => ['maxItemCount' => '2000']], + ['pickingConfig' => ['maxItemCount' => 2000]], 'POST', self::PICKER_BASE_URL, ); @@ -348,6 +348,9 @@ public function importFromPickerSession( } while (isset($result['nextPageToken'])); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); + // Session is done; clear the dedup map so it does not grow unboundedly + // across future sessions (a new session always picks a fresh selection). + $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true); return [ 'nbDownloaded' => $nbDownloaded, 'targetPath' => $targetPath, diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index f253f04d..e8df2819 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -545,17 +545,17 @@ 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() - const messageListener = (event) => { - if (event.origin !== window.location.origin) { - return - } + if (ssoWindow) { + ssoWindow.focus() + } + const bc = new BroadcastChannel('integration_google_oauth') + bc.onmessage = (event) => { if (!event.data?.username) { return } - window.removeEventListener('message', messageListener) + bc.close() console.debug('Child window message received', event) this.state.user_name = event.data.username // Fetch the full config (including user_scopes) so the page @@ -572,7 +572,6 @@ export default { this.loadData() }) } - window.addEventListener('message', messageListener) } else { window.location.replace(requestUrl) } @@ -770,7 +769,7 @@ export default { const pickerWindow = window.open( this.pickerUri, t('integration_google', 'Google Photos Picker'), - 'toolbar=no, menubar=no, width=900, height=700', + 'toolbar=no, menubar=no, width=900, height=700,noopener,noreferrer', ) if (pickerWindow) { pickerWindow.focus() @@ -787,7 +786,7 @@ export default { const pickerWindow = window.open( response.data.pickerUri, t('integration_google', 'Google Photos Picker'), - 'toolbar=no, menubar=no, width=900, height=700', + 'toolbar=no, menubar=no, width=900, height=700,noopener,noreferrer', ) if (pickerWindow) { pickerWindow.focus() diff --git a/src/popupSuccess.js b/src/popupSuccess.js index 4008c71c..6dcda6a5 100644 --- a/src/popupSuccess.js +++ b/src/popupSuccess.js @@ -3,7 +3,7 @@ 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 }) - window.close() -} +const bc = new BroadcastChannel('integration_google_oauth') +bc.postMessage({ username }) +bc.close() +window.close() From bb3a6301136b92b5331582731ef6ba2959d670df Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:57:15 +0100 Subject: [PATCH 08/25] refactor(photos): replace ID-map dedup with filesystem existence check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the imported_photo_ids user-config map and photo_dedup_target_path tracking entirely; they added unbounded storage complexity for no real gain - downloadPickerItem() already calls $folder->nodeExists($fileName) before creating a file, and falls back to {id}_{name} on collision then skips — the filesystem is the correct source of truth - The Picker API provides stable filenames per media item, so a file on disk reliably indicates a photo has already been imported Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- lib/Service/GooglePhotosAPIService.php | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index 465eb929..68d04099 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -271,19 +271,6 @@ public function importFromPickerSession( } } - // Load cross-session dedup state, scoped to the current target folder. - // If the user has changed the output folder, reset the ID list so photos - // can be imported again into the new (empty) location. - $dedupTargetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_dedup_target_path', '', lazy: true); - if ($dedupTargetPath !== $targetPath) { - $importedIds = []; - $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_dedup_target_path', $targetPath, lazy: true); - $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true); - } else { - $importedIdsRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true); - $importedIds = json_decode($importedIdsRaw, true) ?? []; - } - // Page through all picked media items $downloadedSize = 0; $nbDownloaded = 0; @@ -312,17 +299,9 @@ public function importFromPickerSession( $items = $result['mediaItems'] ?? []; foreach ($items as $item) { $totalSeenNumber++; - $itemId = $item['id'] ?? ''; - // Skip photos already imported in a previous session - if ($itemId !== '' && array_key_exists($itemId, $importedIds)) { - continue; - } $size = $this->downloadPickerItem($userId, $item, $folder); if ($size !== null) { $nbDownloaded++; - if ($itemId !== '') { - $importedIds[$itemId] = 1; - } $this->userConfig->setValueInt( $userId, Application::APP_ID, 'nb_imported_photos', $alreadyImported + $nbDownloaded, lazy: true, @@ -330,7 +309,6 @@ public function importFromPickerSession( $downloadedSize += $size; if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) { $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true); - $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); return [ 'nbDownloaded' => $nbDownloaded, @@ -343,14 +321,10 @@ public function importFromPickerSession( } // Update progress counters after each page $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true); - $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); $params['pageToken'] = $result['nextPageToken'] ?? ''; } while (isset($result['nextPageToken'])); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); - // Session is done; clear the dedup map so it does not grow unboundedly - // across future sessions (a new session always picks a fresh selection). - $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true); return [ 'nbDownloaded' => $nbDownloaded, 'targetPath' => $targetPath, From b5b2ba60bf1d390a98383c023fcd4b8e5b41dad9 Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:11:32 +0100 Subject: [PATCH 09/25] refactor(photos): use file metadata for cross-session dedup - Inject IFilesMetadataManager and store the Google media ID as 'integration_google_photo_id' metadata on each downloaded file - On filename collision, read the existing file's metadata: if the stored ID matches, the exact photo is already imported (skip). If the ID differs (two different photos with the same filename), fall back to the ID-prefixed filename as before - Happy-path files now keep their original name (e.g. photo.jpg) instead of always being prefixed with the Google ID Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- lib/Service/GooglePhotosAPIService.php | 47 +++++++++++++++++++++----- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index 68d04099..ed702aba 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -23,6 +23,7 @@ use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; +use OCP\FilesMetadata\IFilesMetadataManager; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; use Psr\Log\LoggerInterface; @@ -34,6 +35,7 @@ class GooglePhotosAPIService { private const PICKER_BASE_URL = 'https://photospicker.googleapis.com/'; + private const METADATA_KEY = 'integration_google_photo_id'; public function __construct( string $appName, @@ -44,6 +46,7 @@ public function __construct( private UserScopeService $userScopeService, private GoogleAPIService $googleApiService, private FileUtils $fileUtils, + private IFilesMetadataManager $metadataManager, ) { } @@ -354,14 +357,31 @@ private function downloadPickerItem(string $userId, array $item, Folder $folder) $mimeType = $mediaFile['mimeType'] ?? ''; $rawName = $mediaFile['filename'] ?? ($item['id'] ?? 'unknown'); - $fileName = $this->fileUtils->sanitizeFilename($rawName, (string)($item['id'] ?? '')); - - // Avoid duplicate filenames - if ($folder->nodeExists($fileName)) { - $fileName = ($item['id'] ?? 'dup') . '_' . $fileName; - } - if ($folder->nodeExists($fileName)) { - return null; // already imported + $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 @@ -403,6 +423,17 @@ private function downloadPickerItem(string $userId, array $item, Folder $folder) } else { $savedFile->touch(); } + // Store the Google media ID in file metadata so future imports can + // identify this file by ID rather than filename alone. + if ($itemId !== '') { + try { + $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]); + } + } $stat = $savedFile->stat(); return (int)($stat['size'] ?? 0); } else { From f8dc716122df8ee141700d24b3ad0da7186b815c Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:43:37 +0100 Subject: [PATCH 10/25] fix(photos): address Copilot review comments round 5 - Change /import-photos route from GET to POST; update frontend call to axios.post so the state-changing operation is not a GET request - Drop nb_photos_seen tracking entirely: the Picker API provides no upfront total count so the 'X of Y' display was misleading and caused mid-page double-counting on re-queued jobs. Progress now shows 'X photos imported' - Wrap new DateTime($item['createTime']) in try/catch so an invalid timestamp from Google skips to a touch() fallback rather than aborting the whole import batch - Add BroadcastChannel fallback in popupSuccess.js and PersonalSettings: if BroadcastChannel is unavailable, fall back to same-origin window.opener.postMessage with strict origin validation Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- appinfo/routes.php | 2 +- lib/Controller/GoogleAPIController.php | 1 - lib/Service/GooglePhotosAPIService.php | 21 +++++--------- src/components/PersonalSettings.vue | 39 ++++++++++++++++++-------- src/popupSuccess.js | 27 ++++++++++++++++-- 5 files changed, 59 insertions(+), 31 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index a3cb0a2b..be023e86 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -26,7 +26,7 @@ ['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' => 'GET'], + ['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/Controller/GoogleAPIController.php b/lib/Controller/GoogleAPIController.php index 24eedfee..5cbfe53e 100644 --- a/lib/Controller/GoogleAPIController.php +++ b/lib/Controller/GoogleAPIController.php @@ -56,7 +56,6 @@ public function getImportPhotosInformation(): DataResponse { 'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1', 'last_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_import_timestamp', lazy: true), 'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true), - 'nb_photos_seen' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_photos_seen', lazy: true), ]); } diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index ed702aba..95e38c9c 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -148,7 +148,6 @@ public function startImportPhotos(string $userId, string $sessionId): array { $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, 'nb_photos_seen', 0, lazy: true); $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); @@ -235,7 +234,6 @@ public function importPhotosJob(string $userId): void { } $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, 'nb_photos_seen', 0, lazy: true); $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true); } else { $ts = (new DateTime())->getTimestamp(); @@ -279,10 +277,6 @@ public function importFromPickerSession( $nbDownloaded = 0; $params = ['sessionId' => $sessionId, 'pageSize' => 100]; $resumeToken = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true); - // Seed seen counter from stored value when resuming so progress stays monotonic - $totalSeenNumber = $resumeToken !== '' - ? $this->userConfig->getValueInt($userId, Application::APP_ID, 'nb_photos_seen', 0, lazy: true) - : 0; if ($resumeToken !== '') { $params['pageToken'] = $resumeToken; } @@ -301,7 +295,6 @@ public function importFromPickerSession( } $items = $result['mediaItems'] ?? []; foreach ($items as $item) { - $totalSeenNumber++; $size = $this->downloadPickerItem($userId, $item, $folder); if ($size !== null) { $nbDownloaded++; @@ -311,19 +304,15 @@ public function importFromPickerSession( ); $downloadedSize += $size; if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) { - $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); return [ 'nbDownloaded' => $nbDownloaded, 'targetPath' => $targetPath, 'finished' => false, - 'totalSeen' => $totalSeenNumber, ]; } } } - // Update progress counters after each page - $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true); $params['pageToken'] = $result['nextPageToken'] ?? ''; } while (isset($result['nextPageToken'])); @@ -332,7 +321,6 @@ public function importFromPickerSession( 'nbDownloaded' => $nbDownloaded, 'targetPath' => $targetPath, 'finished' => true, - 'totalSeen' => $totalSeenNumber, ]; } @@ -418,8 +406,13 @@ private function downloadPickerItem(string $userId, array $item, Folder $folder) fclose($resource); } if (isset($item['createTime'])) { - $d = new DateTime($item['createTime']); - $savedFile->touch($d->getTimestamp()); + try { + $d = new DateTime($item['createTime']); + $savedFile->touch($d->getTimestamp()); + } catch (Exception $e) { + $this->logger->warning('Google Photo, invalid createTime, using current time: ' . $e->getMessage(), ['app' => Application::APP_ID]); + $savedFile->touch(); + } } else { $savedFile->touch(); } diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index e8df2819..44218511 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -115,16 +115,10 @@ {{ t('integration_google', 'Import queued, starting soon…') }} - - {{ t('integration_google', '{imported} of {total} photos imported', { imported: nbImportedPhotos, total: nbPhotosSeen }) }} - {{ n('integration_google', '{amount} photo imported', '{amount} photos imported', nbImportedPhotos, { amount: nbImportedPhotos }) }}
-

{{ t('integration_google', 'You can close this page. You will be notified when the import finishes.') }} @@ -389,7 +383,6 @@ export default { importingPhotos: false, lastPhotoImportTimestamp: 0, nbImportedPhotos: 0, - nbPhotosSeen: 0, photoImportLoop: null, // drive driveSize: 0, @@ -550,12 +543,10 @@ export default { if (ssoWindow) { ssoWindow.focus() } - const bc = new BroadcastChannel('integration_google_oauth') - bc.onmessage = (event) => { + const handleOAuthMessage = (event) => { if (!event.data?.username) { return } - bc.close() console.debug('Child window message received', event) this.state.user_name = event.data.username // Fetch the full config (including user_scopes) so the page @@ -572,6 +563,31 @@ export default { this.loadData() }) } + try { + if (typeof BroadcastChannel !== 'undefined') { + const bc = new BroadcastChannel('integration_google_oauth') + bc.onmessage = (event) => { + bc.close() + handleOAuthMessage(event) + } + } else { + window.addEventListener('message', function listener(event) { + if (event.origin !== window.location.origin || !event.data?.username) { + return + } + window.removeEventListener('message', listener) + handleOAuthMessage(event) + }) + } + } catch (e) { + window.addEventListener('message', function listener(event) { + if (event.origin !== window.location.origin || !event.data?.username) { + return + } + window.removeEventListener('message', listener) + handleOAuthMessage(event) + }) + } } else { window.location.replace(requestUrl) } @@ -746,7 +762,6 @@ export default { if (response.data && Object.keys(response.data).length > 0) { this.lastPhotoImportTimestamp = response.data.last_import_timestamp this.nbImportedPhotos = response.data.nb_imported_photos - this.nbPhotosSeen = response.data.nb_photos_seen ?? 0 this.importingPhotos = response.data.importing_photos if (!this.importingPhotos) { clearInterval(this.photoImportLoop) @@ -829,7 +844,7 @@ export default { */ onImportPhotos() { const url = generateUrl('/apps/integration_google/import-photos') - axios.get(url, { params: { sessionId: this.pickerSessionId } }) + axios.post(url, { sessionId: this.pickerSessionId }) .then((response) => { const targetPath = response.data.targetPath showSuccess( diff --git a/src/popupSuccess.js b/src/popupSuccess.js index 6dcda6a5..250e6297 100644 --- a/src/popupSuccess.js +++ b/src/popupSuccess.js @@ -3,7 +3,28 @@ import { loadState } from '@nextcloud/initial-state' const state = loadState('integration_google', 'popup-data') const username = state.user_name -const bc = new BroadcastChannel('integration_google_oauth') -bc.postMessage({ username }) -bc.close() +const notifyOpener = (message) => { + try { + if (typeof BroadcastChannel !== 'undefined') { + const bc = new BroadcastChannel('integration_google_oauth') + try { + bc.postMessage(message) + return + } finally { + bc.close() + } + } + } catch (e) { + // fall through to same-origin opener fallback + } + try { + if (window.opener && window.opener.location?.origin === window.location.origin) { + window.opener.postMessage(message, window.location.origin) + } + } catch (e) { + // ignore cross-origin/access errors + } +} + +notifyOpener({ username }) window.close() From a47d6efcb5b69cd545a60f03b047cbf4b361bef2 Mon Sep 17 00:00:00 2001 From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:55:44 +0100 Subject: [PATCH 11/25] feat(photos): support queueing multiple picker sessions - If startImportPhotos is called while an import is already running, push the session ID onto a picker_session_queue JSON array in user config and return queued:true - importPhotosJob dequeues and starts the next session automatically after each successful completion - cancelImport clears the queue - Import status endpoint exposes nb_queued_sessions - Frontend: queue count shown during active import; picker can be opened and queued while import is running; queued start shows a different toast - Remove unused NcProgressBar import Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com> --- lib/Controller/GoogleAPIController.php | 1 + lib/Service/GooglePhotosAPIService.php | 24 ++++++++++- src/components/PersonalSettings.vue | 59 ++++++++++++++++++++++---- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/lib/Controller/GoogleAPIController.php b/lib/Controller/GoogleAPIController.php index 5cbfe53e..67bf4bc8 100644 --- a/lib/Controller/GoogleAPIController.php +++ b/lib/Controller/GoogleAPIController.php @@ -56,6 +56,7 @@ public function getImportPhotosInformation(): DataResponse { 'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1', 'last_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_import_timestamp', lazy: true), 'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true), + 'nb_queued_sessions' => count(json_decode($this->userConfig->getValueString($this->userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true), true) ?? []), ]); } diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index 95e38c9c..35b4b71a 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -131,7 +131,12 @@ public function startImportPhotos(string $userId, string $sessionId): array { $alreadyImporting = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1'; if ($alreadyImporting) { - return ['targetPath' => $targetPath]; + // 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) ?? []; + $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 @@ -150,6 +155,7 @@ public function startImportPhotos(string $userId, string $sessionId): array { $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true); $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_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]; @@ -167,6 +173,7 @@ public function cancelImport(string $userId): void { if ($sessionId !== '') { $this->deletePickerSession($userId, $sessionId); } + $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true); } /** @@ -235,6 +242,21 @@ public function importPhotosJob(string $userId): void { $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_import_timestamp', 0, lazy: true); + // On successful completion, start the next queued session if any + 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 (!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, 'importing_photos', '1', 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_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 { $ts = (new DateTime())->getTimestamp(); $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', $ts, lazy: true); diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 44218511..1641d548 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -119,10 +119,46 @@ {{ 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') }} + +