From f71de2eacc6596a6fc740b5b2b4b8b34f8a1628a Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Sat, 27 Jun 2026 03:44:53 +0200 Subject: [PATCH] Zotero: allow collaborators to refresh files imported from Zotero; show importer's name in the header --- .../web/frontend/extracted-translations.json | 4 +- .../features/file-view/types/binary-file.ts | 7 ++ services/web/locales/de.json | 6 +- services/web/locales/en.json | 4 +- services/web/locales/ru.json | 4 +- .../zotero/app/src/ZoteroLinkedFileAgent.mjs | 70 +++++++++++-------- .../js/components/tpr-file-view-info.tsx | 40 ++++++----- .../tpr-file-view-not-original-importer.tsx | 48 +------------ .../tpr-file-view-refresh-button.tsx | 36 +++------- .../tpr-file-view-refresh-error.tsx | 34 ++++----- 10 files changed, 103 insertions(+), 150 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index be6e492e0c..d87aca0eaa 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1038,6 +1038,7 @@ "imported_from_readcube_at_date": "", "imported_from_the_output_of_another_project_at_date": "", "imported_from_zotero_at_date": "", + "imported_from_zotero_at_date_by": "", "importing": "", "importing_and_merging_changes_in_github": "", "in_editor_chat": "", @@ -2809,13 +2810,12 @@ "zotero_dynamic_sync_description": "", "zotero_groups_loading_error": "", "zotero_groups_relink": "", - "zotero_imported_by_collaborator": "", - "zotero_imported_by_unknown": "", "zotero_integration": "", "zotero_is_premium": "", "zotero_reference_loading_error": "", "zotero_reference_loading_error_expired": "", "zotero_reference_loading_error_forbidden": "", + "zotero_reference_loading_error_not_linked": "", "zotero_sync_changing": "", "zotero_sync_description": "", "zotero_sync_lost_access": "", diff --git a/services/web/frontend/js/features/file-view/types/binary-file.ts b/services/web/frontend/js/features/file-view/types/binary-file.ts index 4a4104615c..6e709d0932 100644 --- a/services/web/frontend/js/features/file-view/types/binary-file.ts +++ b/services/web/frontend/js/features/file-view/types/binary-file.ts @@ -15,6 +15,13 @@ export type LinkedFileData = { source_project_id: string source_output_file_path: string } + zotero: { + provider: 'zotero' + zoteroGroupId?: string + importedAt: Date | string + importedByUserId?: string + importedByName?: string + } } export type BinaryFile = diff --git a/services/web/locales/de.json b/services/web/locales/de.json index c1b088d38c..cfbac0214f 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -863,7 +863,8 @@ "imported_from_external_provider_at_date": "Importiert aus <0>__shortenedUrlHTML__ am __formattedDate__ __relativeDate__", "imported_from_mendeley_at_date": "Importiert von Mendeley am __formattedDate__ __relativeDate__", "imported_from_the_output_of_another_project_at_date": "Importiert aus der Ausgabe von <0>einem anderen Projekt: __sourceOutputFilePathHTML__, am __formattedDate__ __relativeDate__", - "imported_from_zotero_at_date": "Importiert von Zotero at __formattedDate__ __relativeDate__", + "imported_from_zotero_at_date": "Importiert aus Zotero am __formattedDate__ __relativeDate__", + "imported_from_zotero_at_date_by": "Importiert aus Zotero am __formattedDate__ __relativeDate__ von __importedByName__", "importing": "Importieren", "importing_and_merging_changes_in_github": "Änderungen werden in GitHub importiert und zusammengeführt.", "in_order_to_have_a_secure_account_make_sure_your_password": "Um dein Konto abzusichern, stelle sicher, dass dein Passwort", @@ -1899,12 +1900,11 @@ "zotero_account_linked_successfully": "Zotero-Konto erfolgreich verknüpft.", "zotero_groups_loading_error": "Beim Laden von Gruppen von Zotero ist ein Fehler aufgetreten", "zotero_groups_relink": "Beim Zugriff auf die Zotero-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.", - "zotero_imported_by_collaborator": "Diese Datei wurde aus Zotero von deinem Mitautor importiert. Du kannst sie nicht aktualisieren.", - "zotero_imported_by_unknown": "Diese Datei wurde aus Zotero von einem unbekannten Benutzer importiert. Du kannst sie nicht aktualisieren.", "zotero_integration": "Zotero-Integration", "zotero_is_premium": "Zotero-Integration ist eine Premiumfunktion", "zotero_reference_loading_error": "Fehler, Referenzen konnten nicht von Mendeley geladen werden", "zotero_reference_loading_error_expired": "Zotero-Token abgelaufen, bitte verknüpfe dein Konto neu", "zotero_reference_loading_error_forbidden": "Referenzen konnten nicht von Zotero geladen werden. Bitte verlinke dein Zotero-Konto erneut und versuche es nochmal", + "zotero_reference_loading_error_not_linked": "Aktualisierung fehlgeschlagen: Das zum Importieren der Datei verwendete Konto ist nicht mehr mit Zotero verknüpft oder wurde entfernt.", "zotero_sync_description": "Mit der Zotero-Integration kannst du deine Referenzen von Zotero in deine __appName__ Projekte importieren." } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 63730c131e..75050119be 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1362,6 +1362,7 @@ "imported_from_readcube_at_date": "Imported from ReadCube at __formattedDate__ __relativeDate__", "imported_from_the_output_of_another_project_at_date": "Imported from the output of <0>Another project: __sourceOutputFilePathHTML__, at __formattedDate__ __relativeDate__", "imported_from_zotero_at_date": "Imported from Zotero at __formattedDate__ __relativeDate__", + "imported_from_zotero_at_date_by": "Imported from Zotero at __formattedDate__ __relativeDate__ by __importedByName__", "importing": "Importing", "importing_and_merging_changes_in_github": "Importing and merging changes in GitHub", "in_editor_chat": "In-editor chat", @@ -3530,13 +3531,12 @@ "zotero_dynamic_sync_description": "With the Zotero integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Zotero library directly from __appName__.", "zotero_groups_loading_error": "There was an error loading groups from Zotero", "zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.", - "zotero_imported_by_collaborator": "This file was imported from Zotero by your collaborator. You cannot refresh it.", - "zotero_imported_by_unknown": "This file was imported from Zotero by an unknown user. You cannot refresh it.", "zotero_integration": "Zotero integration", "zotero_is_premium": "Zotero integration is a premium feature", "zotero_reference_loading_error": "Error, could not load references from Zotero", "zotero_reference_loading_error_expired": "Zotero token expired, please re-link your account", "zotero_reference_loading_error_forbidden": "Could not load references from Zotero, please re-link your account and try again", + "zotero_reference_loading_error_not_linked": "Refresh failed: the account used to import the file is no longer linked to Zotero or has been removed.", "zotero_sync_changing": "Your Zotero library is changing rapidly. Please try syncing again in a moment.", "zotero_sync_description": "With the Zotero integration you can import your references from Zotero into your __appName__ projects.", "zotero_sync_lost_access": "We’ve lost access to your Zotero library. <0>Reconnect your account to keep syncing your references.", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index afd34bb929..e29d5d46d4 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -367,6 +367,7 @@ "import_from_github": "Импорт с GitHub", "import_to_sharelatex": "Импортировать в __appName__", "imported_from_zotero_at_date": "Импортировано из Zotero в __formattedDate__ __relativeDate__", + "imported_from_zotero_at_date_by": "Импортировано из Zotero в __formattedDate__ __relativeDate__ пользователем __importedByName__", "importing": "Импорт", "importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub", "inactive_projects": "Неактивные проекты", @@ -856,10 +857,9 @@ "zh-CN": "Китайский", "zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.", "zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка", - "zotero_imported_by_collaborator": "Этот файл был импортирован из Zotero вашим соавтором. Вы не можете обновить файл.", - "zotero_imported_by_unknown": "Этот файл был импортирован из Zotero неизвестным пользователем. Вы не можете обновить файл.", "zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero", "zotero_reference_loading_error_expired": "Срок действия токена Zotero истёк, пожалуйста, повторно подключите аккаунт", "zotero_reference_loading_error_forbidden": "Не удалось загрузить ссылки из Zotero, пожалуйста, повторно подключите аккаунт и попробуйте снова", + "zotero_reference_loading_error_not_linked": "Не удалось обновить файл: учётная запись, использованная для импорта файла, больше не связана с Zotero или была удалена.", "zotero_sync_description": "Используя интеграцию с Zotero вы можете импортировать свои ссылки из Zotero в проекты __appName__." } diff --git a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs index cf5e0364a6..72bfdfba0a 100644 --- a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs +++ b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs @@ -1,23 +1,27 @@ import logger from '@overleaf/logger' import { callbackify } from '@overleaf/promise-utils' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' import LinkedFilesHandler from '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler.mjs' +import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs' import ZoteroApiClient from './ZoteroApiClient.mjs' import { ZoteroForbiddenError, ZoteroAccountNotLinkedError } from './ZoteroApiClient.mjs' -import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs' -import { Project } from '../../../../app/src/models/Project.mjs' const { - FeatureNotAvailableError, AccessDeniedError, RemoteServiceError, - NotOriginalImporterError, } = LinkedFilesErrors /** * Create a linked .bib file from Zotero (either My Library or a Group Library). * * linkedFileData shape: - * { provider: 'zotero', zoteroGroupId?: string, importedAt: string } + * { + * provider: 'zotero' + * zoteroGroupId?: string + * importedAt: Date | string + * importedByUserId?: string + * importedByName?: string + * } * * - If zoteroGroupId is present, export that group's library. * - Otherwise, export the user's personal library ("My Library"). @@ -29,14 +33,15 @@ async function createLinkedFile( parentFolderId, userId ) { - logger.info( + logger.debug( { projectId, userId, groupId: linkedFileData.zoteroGroupId }, 'creating Zotero linked file' ) linkedFileData.importedByUserId = userId + linkedFileData.importedByName = await _getUserName(userId) - const bibtex = await _getBibtex(userId, linkedFileData) + const bibtex = await _getBibtex(linkedFileData) const file = await LinkedFilesHandler.promises.importContent( projectId, @@ -59,29 +64,12 @@ async function refreshLinkedFile( parentFolderId, userId ) { - logger.info( + logger.debug( { projectId, userId, groupId: linkedFileData.zoteroGroupId }, 'refreshing Zotero linked file' ) - if (linkedFileData.importedByUserId) { - if (linkedFileData.importedByUserId !== userId) { - throw new NotOriginalImporterError( - 'Only the user who created the Zotero-linked file can refresh this file' - ) - } - } else { - // No owner metadata (legacy files). Only project owner may refresh. - const project = await Project.findById(projectId, 'owner_ref').lean() - if (!project || String(project.owner_ref) !== String(userId)) { - throw new NotOriginalImporterError( - 'Only the user who created the Zotero-linked file can refresh this file' - ) - } - linkedFileData.importedByUserId = userId - } - - const bibtex = await _getBibtex(userId, linkedFileData) + const bibtex = await _getBibtex(linkedFileData) const file = await LinkedFilesHandler.promises.importContent( projectId, @@ -94,7 +82,8 @@ async function refreshLinkedFile( return file._id } -async function _getBibtex(userId, linkedFileData) { +async function _getBibtex(linkedFileData) { + const userId = linkedFileData.importedByUserId try { if (linkedFileData.zoteroGroupId) { return await ZoteroApiClient.getGroupLibraryBibtex( @@ -118,14 +107,35 @@ async function _getBibtex(userId, linkedFileData) { function _sanitizeData(data) { return { provider: 'zotero', - zoteroGroupId: data.zoteroGroupId || undefined, + ...(data.zoteroGroupId && { + zoteroGroupId: data.zoteroGroupId, + }), importedAt: data.importedAt, - importedByUserId: data.importedByUserId || undefined, + ...(data.importedByUserId && { + importedByUserId: data.importedByUserId, + }), + importedByName: data.importedByName || 'Unknown', } } +async function _getUserName(userId) { + let user = null + try { + user = await UserGetter.promises.getUser(userId, {'email': 1, 'first_name': 1, 'last_name': 1}) + } + catch (err) { + logger.error({ userId, err }, 'failed to get user info') + } + if (!user) return 'Unknown' + + const { email, first_name, last_name } = user + const name = (first_name || last_name) ? + [first_name, last_name].filter(n => n != null).join(' ') : email + return name || 'Unknown' +} + export default { createLinkedFile: callbackify(createLinkedFile), refreshLinkedFile: callbackify(refreshLinkedFile), - promises: { createLinkedFile, refreshLinkedFile }, + promises: { createLinkedFile, refreshLinkedFile } } diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx index 995e128885..1bc362e1d5 100644 --- a/services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx @@ -1,10 +1,12 @@ import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' import { formatTime, relativeDate } from '@/features/utils/format-date' import { LinkedFileIcon } from '@/features/file-view/components/file-view-icons' -import type { BinaryFile } from '@/features/file-view/types/binary-file' +import { hasProvider } from '@/features/file-view/types/binary-file' +import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' type TPRFileViewInfoProps = { - file: BinaryFile + file: LinkedFile } /** @@ -15,31 +17,31 @@ type TPRFileViewInfoProps = { export function TPRFileViewInfo({ file }: TPRFileViewInfoProps) { const { t } = useTranslation() - if (file.linkedFileData?.provider !== 'zotero') { - return null - } + if (!hasProvider(file, 'zotero')) return null const importedAt = (file.linkedFileData as any)?.importedAt || file.created const formattedDate = formatTime(importedAt) const relative = relativeDate(importedAt) - const groupId = (file.linkedFileData as any)?.zoteroGroupId + const importedByUserId = (file.linkedFileData as any)?.importedByUserId + const importedByName = (file.linkedFileData as any)?.importedByName || 'Unknown' return (

-

- - -   - {t('imported_from_zotero_at_date', { - formattedDate, - relativeDate: relative, - })} - - {groupId && ( - (Group: {groupId}) - )} -
+ +   + {(importedByUserId === getMeta('ol-user_id')) ? ( + t('imported_from_zotero_at_date', { + formattedDate, + relativeDate: relative, + }) + ) : ( + t('imported_from_zotero_at_date_by', { + formattedDate, + relativeDate: relative, + importedByName, + }) + )}

) } diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx index fa571440e2..103b605e32 100644 --- a/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx @@ -1,55 +1,13 @@ -import { useTranslation } from 'react-i18next' import type { BinaryFile } from '@/features/file-view/types/binary-file' -import OLNotification from '@/shared/components/ol/ol-notification' -import getMeta from '@/utils/meta' +import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' -type TPRFileViewNotOriginalImporterProps = { - file: BinaryFile +type TPRFileViewInfoProps = { + file: LinkedFile } -/** - * Shows a warning if the current user is not the original importer of this Zotero file. - * Registered via overleafModuleImports.tprFileViewNotOriginalImporter. - */ export function TPRFileViewNotOriginalImporter({ file, }: TPRFileViewNotOriginalImporterProps) { - const provider = file.linkedFileData?.provider - const { t } = useTranslation() - - if (provider !== 'zotero') { - return null - } - - const currentUserId = - (getMeta('ol-user') as any)?._id || (getMeta('ol-user_id') as string) - const importedByUserId = (file.linkedFileData as any)?.importedByUserId - - if (provider !== 'zotero') { - return null - } - - if (!importedByUserId) { - return ( -
- -
- ) - } -// TODO: fetch the collaborator's name and show it - if (currentUserId && importedByUserId !== currentUserId) { - return ( -
- -
- ) - } return null } diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx index c20e88306a..55363997bb 100644 --- a/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx @@ -1,10 +1,10 @@ import { useTranslation } from 'react-i18next' import OLButton from '@/shared/components/ol/ol-button' -import type { BinaryFile } from '@/features/file-view/types/binary-file' -import getMeta from '@/utils/meta' +import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' +import { hasProvider } from '@/features/file-view/types/binary-file' type TPRFileViewRefreshButtonProps = { - file: BinaryFile + file: LinkedFile refreshFile: (isTPR: boolean | null) => void refreshing: boolean } @@ -20,35 +20,17 @@ export function TPRFileViewRefreshButton({ refreshing, }: TPRFileViewRefreshButtonProps) { const { t } = useTranslation() - const provider = (file.linkedFileData as Record)?.provider - const currentUserId = - (getMeta('ol-user') as any)?._id || (getMeta('ol-user_id') as string) const importedByUserId = (file.linkedFileData as any)?.importedByUserId - const isOriginalImporter = - provider === 'zotero' && - currentUserId && - importedByUserId === currentUserId + const zoteroIsProvider = hasProvider(file, 'zotero') + const disabled = (zoteroIsProvider && !importedByUserId) ? true : false - if (provider !== 'zotero' || isOriginalImporter) { - // Zotero or default refresh for originator only - return ( - refreshFile(provider === 'zotero' ? true : null)} - disabled={refreshing} - isLoading={refreshing} - loadingLabel={t('refreshing')} - > - {t('refresh')} - - ) - } - - // collaborator of Zotero file should not refresh return ( refreshFile(zoteroIsProvider ? true : null)} + disabled={refreshing || disabled} + isLoading={refreshing} + loadingLabel={t('refreshing')} > {t('refresh')} diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx index f35196923e..ca95ce8d90 100644 --- a/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx @@ -1,9 +1,10 @@ import { useTranslation } from 'react-i18next' import OLNotification from '@/shared/components/ol/ol-notification' -import type { BinaryFile } from '@/features/file-view/types/binary-file' +import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' +import { hasProvider } from '@/features/file-view/types/binary-file' type TPRFileViewRefreshErrorProps = { - file: BinaryFile + file: LinkedFile refreshError: string } @@ -17,25 +18,18 @@ export function TPRFileViewRefreshError({ }: TPRFileViewRefreshErrorProps) { const { t } = useTranslation() - if (file.linkedFileData?.provider !== 'zotero') { - // Not a Zotero file — fall back to default error display - return ( -
- -
- ) - } - let message = refreshError - if (refreshError === 'forbidden' || refreshError?.includes('403')) { - message = t('zotero_reference_loading_error_forbidden') - } else if ( - refreshError === 'expired' || - refreshError?.includes('token expired') - ) { - message = t('zotero_reference_loading_error_expired') - } else if (!message) { - message = t('zotero_reference_loading_error') + + if (hasProvider(file, 'zotero')) { + if (!refreshError) { + message = t('zotero_reference_loading_error') + } else if (refreshError?.includes('not linked')) { + message = t('zotero_reference_loading_error_not_linked') + } else if (refreshError === 'forbidden' || refreshError?.includes('403')) { + message = t('zotero_reference_loading_error_forbidden') + } else if (refreshError === 'expired' || refreshError?.includes('token expired')) { + message = t('zotero_reference_loading_error_expired') + } } return (