Zotero: allow collaborators to refresh files imported from Zotero; show importer's name in the header

This commit is contained in:
yu-i-i
2026-09-22 02:41:42 +02:00
parent d5e240b4b5
commit f71de2eacc
10 changed files with 103 additions and 150 deletions
@@ -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": "",
@@ -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<T extends keyof LinkedFileData = keyof LinkedFileData> =
+3 -3
View File
@@ -863,7 +863,8 @@
"imported_from_external_provider_at_date": "Importiert aus <0>__shortenedUrlHTML__</0> 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</0>: __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."
}
+2 -2
View File
@@ -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</0>: __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</0> to keep syncing your references.",
+2 -2
View File
@@ -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__."
}
@@ -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 }
}
@@ -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<keyof LinkedFileData>
}
/**
@@ -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 (
<p>
<div>
<span>
<LinkedFileIcon />
&nbsp;
{t('imported_from_zotero_at_date', {
formattedDate,
relativeDate: relative,
})}
</span>
{groupId && (
<span className="text-muted small"> (Group: {groupId})</span>
)}
</div>
<LinkedFileIcon />
&nbsp;
{(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,
})
)}
</p>
)
}
@@ -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<keyof LinkedFileData>
}
/**
* 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 (
<div className="file-view-error">
<OLNotification
type="warning"
content={t('zotero_imported_by_unknown')}
/>
</div>
)
}
// TODO: fetch the collaborator's name and show it
if (currentUserId && importedByUserId !== currentUserId) {
return (
<div className="file-view-error">
<OLNotification
type="warning"
content={t('zotero_imported_by_collaborator')}
/>
</div>
)
}
return null
}
@@ -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<keyof LinkedFileData>
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<string, unknown>)?.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 (
<OLButton
variant="primary"
onClick={() => refreshFile(provider === 'zotero' ? true : null)}
disabled={refreshing}
isLoading={refreshing}
loadingLabel={t('refreshing')}
>
{t('refresh')}
</OLButton>
)
}
// collaborator of Zotero file should not refresh
return (
<OLButton
variant="primary"
disabled
onClick={() => refreshFile(zoteroIsProvider ? true : null)}
disabled={refreshing || disabled}
isLoading={refreshing}
loadingLabel={t('refreshing')}
>
{t('refresh')}
</OLButton>
@@ -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<keyof LinkedFileData>
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 (
<div className="file-view-error">
<OLNotification type="error" content={refreshError} />
</div>
)
}
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 (