diff --git a/libraries/overleaf-editor-core/lib/schemas.js b/libraries/overleaf-editor-core/lib/schemas.js
index f613e638de..7684cd877b 100644
--- a/libraries/overleaf-editor-core/lib/schemas.js
+++ b/libraries/overleaf-editor-core/lib/schemas.js
@@ -139,6 +139,10 @@ const rawLinkedFileData = z.discriminatedUnion('provider', [
// default this to 'bibtex' (ZoteroAgent `_getFormat`)
format: z.enum(['bibtex', 'biblatex']).optional(),
group_id: zz.routeSegment().nullish(),
+ collection_key: z.union([
+ z.literal('publications'),
+ z.string().regex(/^[A-Za-z0-9]{8}$/),
+ ]).optional(),
importer_id: z.string().optional(),
importer_name: z.string().optional(),
v1_importer_id: z.number().optional(),
diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
index 9c68858859..ea8c896d09 100644
--- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
+++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
@@ -112,6 +112,10 @@ const createLinkedFileSchema = z.object({
data: z.strictObject({
format: z.enum(['bibtex', 'biblatex']).optional(),
group_id: zz.routeSegment().optional(),
+ collection_key: z.union([
+ z.literal('publications'),
+ z.string().regex(/^[A-Za-z0-9]{8}$/),
+ ]).optional(),
}),
}),
z.strictObject({
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 0fe4d9620f..4ed000b6bb 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -160,6 +160,7 @@
"ai_shortcut_on_empty_lines": "",
"alignment": "",
"all_borders": "",
+ "all_collections": "",
"all_events": "",
"all_features_in_group_standard_plus": "",
"all_logs": "",
@@ -368,6 +369,7 @@
"collabs_per_proj_plus": "",
"collabs_per_proj_plus_plural": "",
"collapse": "",
+ "collection": "",
"column_width": "",
"column_width_is_custom_click_to_resize": "",
"column_width_is_x_click_to_resize": "",
@@ -1346,6 +1348,7 @@
"move_to_the_left": "",
"move_to_the_right": "",
"my_library": "",
+ "my_publications": "",
"n_items": "",
"n_items_plural": "",
"n_more_collaborators": "",
@@ -2811,6 +2814,7 @@
"zoom_to": "",
"zotero": "",
"zotero_account_linked_successfully": "",
+ "zotero_could_not_load_collections": "",
"zotero_dynamic_sync_description": "",
"zotero_groups_loading_error": "",
"zotero_groups_relink": "",
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 40a27d8e57..e4bd1a5ab0 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
@@ -18,6 +18,7 @@ export type LinkedFileData = {
zotero: {
provider: 'zotero'
group_id: string | null
+ collection_key?: string | null
format: 'bibtex' | 'biblatex'
importedAt: Date | string
importer_id: string
diff --git a/services/web/locales/de.json b/services/web/locales/de.json
index 09f798989a..6082d7ae0c 100644
--- a/services/web/locales/de.json
+++ b/services/web/locales/de.json
@@ -154,6 +154,7 @@
"alignment": "Ausrichtung",
"all": "Alle",
"all_borders": "Alle Rahmen",
+ "all_collections": "Alle Sammlungen",
"all_events": "Alle Ereignisse",
"all_features_in_group_standard_plus": "Alle Funktionen von Group Standard, plus:",
"all_logs": "Alle Logs",
@@ -333,6 +334,7 @@
"collabs_per_proj": "__count__ Mitarbeiter pro Projekt",
"collabs_per_proj_plural": "__count__ Mitarbeiter pro Projekt",
"collapse": "Einklappen",
+ "collection": "Sammlung",
"comment": "Kommentar",
"comment_only": "Nur Kommentare",
"common": "Häufige",
@@ -625,6 +627,7 @@
"for_publishers": "Für Verlage",
"for_students": "Für Studierende",
"for_universities": "Für Universitäten",
+ "format": "Format",
"forgot_your_password": "Passwort vergessen",
"found_matching_deleted_users": "__count__ übereinstimmende gelöschte Benutzer gefunden",
"fr": "Französisch",
@@ -1120,6 +1123,7 @@
"more_than_one_kind_of_snippet_was_requested": "Der Link zum Öffnen dieses Inhalts auf Overleaf enthielt einige ungültige Parameter. Wenn dies bei Links auf einer bestimmten Website weiterhin auftritt, melde dies bitte dort.",
"must_be_email_address": "Es muss eine E-Mail-Adresse sein!",
"my_library": "Meine Bibliothek",
+ "my_publications": "Meine Publikationen",
"n_items": "__count__ Artikel",
"n_items_plural": "__count__ Artikel",
"name": "Name",
@@ -1933,6 +1937,7 @@
"zip_contents_too_large": "ZIP-Inhalt zu groß",
"zotero": "Zotero",
"zotero_account_linked_successfully": "Zotero-Konto erfolgreich verknüpft.",
+ "zotero_could_not_load_collections": "Zotero-Sammlungen konnten nicht geladen werden",
"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_integration": "Zotero-Integration",
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 245ce6b9a6..851e484e44 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -194,6 +194,7 @@
"alignment": "Alignment",
"all": "All",
"all_borders": "All borders",
+ "all_collections": "All collections",
"all_events": "All events",
"all_features_in_group_standard_plus": "All features in Standard group, plus:",
"all_logs": "All logs",
@@ -460,6 +461,7 @@
"collabs_per_proj_plus": "__count__ collaborator per project, plus:",
"collabs_per_proj_plus_plural": "__count__ collaborators per project, plus:",
"collapse": "Collapse",
+ "collection": "Collection",
"column_width": "Column width",
"column_width_is_custom_click_to_resize": "Column width is custom. Click to resize",
"column_width_is_x_click_to_resize": "Column width is __width__. Click to resize",
@@ -1785,6 +1787,7 @@
"must_be_email_address": "Must be an email address.",
"must_be_purchased_online": "Must be purchased online",
"my_library": "My Library",
+ "my_publications": "My Publications",
"n_items": "__count__ item",
"n_items_plural": "__count__ items",
"n_more_collaborators": "__count__ more collaborator",
@@ -3538,6 +3541,7 @@
"zoom_to": "Zoom to",
"zotero": "Zotero",
"zotero_account_linked_successfully": "Zotero account linked successfully",
+ "zotero_could_not_load_collections": "Could not load Zotero collections",
"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.",
diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json
index 75b45aa7ef..29addc9eac 100644
--- a/services/web/locales/ru.json
+++ b/services/web/locales/ru.json
@@ -70,6 +70,7 @@
"ai_assistant": "ИИ-ассистент",
"ai_assistant_and_higher_ai_allowance": "ИИ-ассистент и более высокий лимит ИИ",
"ai_assistant_and_max_ai_allowance": "ИИ-ассистент и максимальный лимит ИИ",
+ "all_collections": "Все коллекции",
"all_premium_features": "Все премиум-функции",
"all_projects": "Все проекты",
"all_templates": "Все шаблоны",
@@ -142,6 +143,7 @@
"collaboration": "Совместная разработка",
"collaborator": "Соавтор",
"collabs_per_proj": "Максимальное число соавторов на проект: __count__",
+ "collection": "Коллекция",
"comment": "Комментарии",
"common": "Общие",
"commons": "Commons",
@@ -249,6 +251,7 @@
"folders": "Папки",
"footer_about_us": "О нас",
"forgot_your_password": "Забыли пароль?",
+ "format": "Формат",
"found_matching_deleted_users": "Найдено совпадающих удалённых пользователей: __count__",
"fr": "Французский",
"free": "Бесплатно",
@@ -470,6 +473,7 @@
"more_options": "Дополнительные параметры",
"must_be_email_address": "Введите правильный адрес электронной почты.",
"my_library": "Моя библиотека",
+ "my_publications": "Мои публикации",
"name": "Имя",
"native": "браузер",
"navigation": "Навигация",
@@ -902,6 +906,7 @@
"youve_paused_your_subscription": "Подписка <0>__planName__0> приостановлена до <0>__reactivationDate__0>, затем она возобновится автоматически. Вы можете возобновить её раньше в любой момент.",
"zh-CN": "Китайский",
"zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.",
+ "zotero_could_not_load_collections": "Не удалось загрузить коллекции Zotero",
"zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка",
"zotero_groups_relink": "При загрузке данных из Zotero произошла ошибка, по-видимости вызванная отсутствием разрешения на доступ. Пожалуйста, подключите аккаунт заново и попробуйте ещё раз.",
"zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero",
diff --git a/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs
index d4e563d833..f74301972c 100644
--- a/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs
+++ b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs
@@ -1,5 +1,4 @@
import logger from '@overleaf/logger'
-import Settings from '@overleaf/settings'
import fs from 'node:fs'
import crypto from 'node:crypto'
import Path from 'node:path'
@@ -13,7 +12,7 @@ let encryptorInstance = null
/**
* Get or create a stable encryptor data that persists across container
* recreations. Priority:
- * 1. ZOTERO_CIPHER_PASSWORD env var (explicit user config)
+ * 1. ZOTERO_TOKEN_CIPHER_PASSWORD env var (explicit user config)
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
* — auto-generated on first use, survives container rebuilds
*/
diff --git a/services/web/modules/zotero/app/src/ZoteroApiClient.mjs b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs
index 44541449b8..812a9b3783 100644
--- a/services/web/modules/zotero/app/src/ZoteroApiClient.mjs
+++ b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs
@@ -1,10 +1,8 @@
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
-import AbortError from 'node-fetch'
import {
fetchNothing,
fetchJson,
- fetchString,
fetchStringWithResponse,
RequestFailedError,
} from '@overleaf/fetch-utils'
@@ -61,38 +59,107 @@ async function getGroupsForUser(userId) {
if (!credentials) return null
const { apiKey, zoteroUserId } = credentials
+ const groups = []
+ const limit = 100
+
try {
- const groups = await fetchJson(`${ZOTERO_API_URL}/users/${zoteroUserId}/groups`, {
- headers: buildHeaders(apiKey),
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
- })
- return groups.map(g => ({
- id: String(g.id),
- name: g.data?.name || `Group ${g.id}`,
- }))
+ for (let start = 0; ; start += limit) {
+ const page = await fetchJson(`${ZOTERO_API_URL}/users/${zoteroUserId}/groups?limit=${limit}&start=${start}`, {
+ headers: buildHeaders(apiKey),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+ groups.push(...page.map(g => ({
+ id: String(g.id),
+ name: g.data?.name || `Group ${g.id}`,
+ })))
+ if (page.length < limit) break
+ }
+ return groups
+
} catch (err) {
normalizeApiError(err, 'getGroupsForUser')
}
}
/**
- * Export a library as BibTeX / BibLaTeX.
+ * Return the Zotero path for a personal or group library.
+ * Identifiers are validated before they become URL path components.
*/
-async function getLibraryBibtex(userId, groupId, format) {
+function getLibraryPath(zoteroUserId, groupId) {
+ const id = groupId || zoteroUserId
+ if (!/^\d+$/.test(String(id))) {
+ throw new OError('Invalid Zotero library ID', { status: 400 })
+ }
+ return groupId ? `/groups/${id}` : `/users/${id}`
+}
+
+/**
+ * List all collections, including nested collections, in a library.
+ */
+async function getCollectionsForLibrary(userId, groupId) {
+ const credentials = await TokenManager.getCredentials(userId)
+ if (!credentials) return null
+
+ const libraryPath = getLibraryPath(credentials.zoteroUserId, groupId)
+ const collections = []
+ const limit = 100
+
+ try {
+ for (let start = 0; ; start += limit) {
+ const page = await fetchJson(
+ `${ZOTERO_API_URL}${libraryPath}/collections?limit=${limit}&start=${start}`,
+ {
+ headers: buildHeaders(credentials.apiKey),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ }
+ )
+ if (!Array.isArray(page)) throw new OError('Invalid Zotero collections response', { status: 500 })
+
+ collections.push(...page.map(collection => ({
+ key: collection.key,
+ name: collection.data.name,
+ parentCollection: collection.data.parentCollection || null,
+ })))
+ if (page.length < limit) break
+ }
+ } catch (err) {
+ normalizeApiError(err, 'getCollectionsForLibrary')
+ }
+
+ return collections
+}
+
+/**
+ * Export a library, collection, or personal My Publications view as BibTeX / BibLaTeX.
+ */
+async function getLibraryBibtex(userId, groupId, format, collectionKey) {
const credentials = await TokenManager.getCredentials(userId)
if (!credentials) {
throw new ServiceNotConfiguredError({
- message: 'RefProvider credentials missed',
+ message: 'RefProvider credentials missing',
info: { userId, status: 400 }
})
}
- // Main library or group?
- let basePath
- if (groupId) basePath = `/groups/${groupId}/items`
- else basePath = `/users/${credentials.zoteroUserId}/items`
+ const libraryPath = getLibraryPath(credentials.zoteroUserId, groupId)
+ // "publications" is a local selector value, not a Zotero collection key.
+ const isPublications = collectionKey === 'publications'
+ if (isPublications && groupId) {
+ throw new OError('My Publications is only available in personal libraries', { status: 400 })
+ }
+ if (collectionKey != null && !isPublications &&
+ (typeof collectionKey !== 'string' || !/^[A-Za-z0-9]{8}$/.test(collectionKey))) {
+ throw new OError('Invalid Zotero collection key', { status: 400 })
+ }
- return _fetchBibtex(credentials.apiKey, basePath, format)
+ let basePath = `${libraryPath}/items`
+ if (isPublications) {
+ basePath = `${libraryPath}/publications/items`
+ } else if (collectionKey) {
+ basePath = `${libraryPath}/collections/${collectionKey}/items`
+ }
+
+ return _fetchBibtex(credentials.apiKey, basePath, format === 'biblatex' ? 'biblatex' : 'bibtex')
}
/**
@@ -107,7 +174,7 @@ async function _fetchBibtex(apiKey, basePath, format) {
let start = 0
const headers = buildHeaders(apiKey)
while (true) {
- const url = `${ZOTERO_API_URL}${basePath}?format=${format}&limit=${limit}&start=${start}`
+ const url = `${ZOTERO_API_URL}${basePath}?format=${format}&limit=${limit}&start=${start}&includeTrashed=0`
const { body: bibtexFetched, response } = await fetchStringWithResponse(url, {
headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
@@ -116,7 +183,12 @@ async function _fetchBibtex(apiKey, basePath, format) {
if (bibtex.trim()) allBibtex += bibtex
- const totalResults = parseInt(response.headers.get('Total-Results') || '0', 10)
+ const totalResultsHeader = response.headers.get('Total-Results')
+ const totalResults = Number(totalResultsHeader)
+ if (totalResultsHeader === null || !/^\d+$/.test(totalResultsHeader) ||
+ !Number.isSafeInteger(totalResults)) {
+ throw new OError('Missing or invalid Zotero Total-Results header', { status: 500 })
+ }
start += limit
if (start >= totalResults) {
break
@@ -133,13 +205,14 @@ async function _fetchBibtex(apiKey, basePath, format) {
*/
async function unlinkAccount(userId) {
const credentials = await TokenManager.getCredentials(userId)
- if (!credentials) return
await User.updateOne(
{ _id: userId },
{ $unset: { 'refProviders.zotero': 1 } }
).exec()
+ if (!credentials) return
+
try {
const { apiKey } = credentials
await fetchNothing(`${ZOTERO_API_URL}/keys/${apiKey}`, {
@@ -148,9 +221,8 @@ async function unlinkAccount(userId) {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
} catch (err) {
- logger.error({ err }, 'failed to detete key from Zotero account')
+ logger.error({ err }, 'failed to delete key from Zotero account')
}
-
}
function normalizeApiError(err, operation) {
@@ -182,7 +254,7 @@ function normalizeApiError(err, operation) {
if (status === 429) {
throw new TooManyRequestsError({
- message: 'Rate limit exeeded',
+ message: 'Rate limit exceeded',
info: { operation, status }
}).withCause(err)
}
@@ -193,6 +265,7 @@ function normalizeApiError(err, operation) {
export default {
getConnectionStatus,
getGroupsForUser,
+ getCollectionsForLibrary,
getLibraryBibtex,
unlinkAccount,
}
diff --git a/services/web/modules/zotero/app/src/ZoteroController.mjs b/services/web/modules/zotero/app/src/ZoteroController.mjs
index c65a16c52d..c1cb62fd95 100644
--- a/services/web/modules/zotero/app/src/ZoteroController.mjs
+++ b/services/web/modules/zotero/app/src/ZoteroController.mjs
@@ -13,6 +13,7 @@ async function oauth(req, res) {
const isPopup = req.query.popup === '1'
req.session.zoteroOAuth = {
+ userId: String(SessionManager.getLoggedInUserId(req.session)),
token: requestToken.oauth_token,
tokenSecret: requestToken.oauth_token_secret,
isPopup
@@ -42,7 +43,7 @@ async function oauthCallback(req, res) {
const saved = req.session.zoteroOAuth
delete req.session.zoteroOAuth
- if (!saved || saved.token !== oauthToken) {
+ if (!saved || saved.token !== oauthToken || saved.userId !== String(userId)) {
HttpErrorHandler.forbidden(req, res, 'Invalid OAuth token')
return
}
@@ -58,19 +59,28 @@ async function oauthCallback(req, res) {
} catch (err) {
logger.error(OError.getFullStack(err))
const info = OError.getFullInfo(err)
- logger.error({ info }, "Failed to obtain Zotero access token'")
+ logger.error({ info }, 'Failed to obtain Zotero access token')
HttpErrorHandler.badRequest(req, res, 'Failed to obtain Zotero access token')
return
}
const nonce = crypto.randomBytes(16).toString('base64')
- const csp = res.getHeader('Content-Security-Policy')
+ const scriptNonce = `'nonce-${nonce}'`
+ const directives = String(res.getHeader('Content-Security-Policy') || '')
+ .split(';')
+ .map(directive => directive.trim())
+ .filter(Boolean)
- res.setHeader(
- 'Content-Security-Policy',
- `${csp}; script-src 'nonce-${nonce}'`
- )
+ let hasScriptDirective = false
+ for (let i = 0; i < directives.length; i++) {
+ if (/^script-src(?:-elem)?(?:\s|$)/i.test(directives[i])) {
+ directives[i] += ` ${scriptNonce}`
+ hasScriptDirective = true
+ }
+ }
+ if (!hasScriptDirective) directives.push(`script-src ${scriptNonce}`)
+ res.setHeader('Content-Security-Policy', directives.join('; '))
res.send(`
@@ -128,7 +138,28 @@ async function getGroups(req, res) {
}
/**
- * DELETE /user/zotero/unlink
+ * GET /user/zotero/collections?group_id=
+ * Returns all collections in the personal library or selected group.
+ */
+async function getCollections(req, res) {
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ try {
+ const collections = await ZoteroApiClient.getCollectionsForLibrary(
+ userId,
+ req.query.group_id
+ )
+ res.json(collections)
+ } catch (err) {
+ const info = OError.getFullInfo(err)
+ const errStatus = info?.status || 500
+ logger.error(OError.getFullStack(err))
+ logger.error({ info }, 'failed to get Zotero collections')
+ return res.status(errStatus).json({ message: err.message })
+ }
+}
+
+/**
+ * DELETE /user/zotero
* Unlinks the user's Zotero account.
*/
async function unlink(req, res) {
@@ -150,5 +181,6 @@ export default {
oauthCallback,
getConnectionStatus,
getGroups,
+ getCollections,
unlink
}
diff --git a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs
index 6c595a669e..1247fa48f7 100644
--- a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs
+++ b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs
@@ -1,11 +1,8 @@
import logger from '@overleaf/logger'
import { callbackify } from '@overleaf/promise-utils'
-import { Project } from '../../../../app/src/models/Project.mjs'
-import ProjectLocator from '../../../../app/src/Features/Project/ProjectLocator.mjs'
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
import {
NotFoundError,
- TooManyRequestsError,
ServiceNotConfiguredError,
ForbiddenError
} from '../../../../app/src/Features/Errors/Errors.js'
@@ -20,6 +17,7 @@ import ZoteroApiClient from './ZoteroApiClient.mjs'
* {
* provider: 'zotero'
* group_id: string | null
+ * collection_key?: string | null
* importedAt: Date | string
* importer_id: string
* importer_name: string
@@ -28,6 +26,9 @@ import ZoteroApiClient from './ZoteroApiClient.mjs'
*
* - If group_id is present, export that group's library.
* - Otherwise, export the user's personal library ("My Library").
+ * - If collection_key is present, export only items in that collection.
+ * - The reserved collection_key "publications" exports My Publications.
+ * - Trash is excluded from all exports.
*/
async function createLinkedFile(
projectId,
@@ -45,17 +46,7 @@ async function createLinkedFile(
'creating Zotero linked file'
)
- const bibtex = await _getBibtex(linkedFileData)
-
- const file = await LinkedFilesHandler.promises.importContent(
- projectId,
- bibtex,
- _sanitizeData(linkedFileData),
- name,
- parentFolderId,
- userId
- )
- return file._id
+ return _importLinkedFile(projectId, linkedFileData, name, parentFolderId, userId)
}
/**
@@ -73,22 +64,17 @@ async function refreshLinkedFile(
'refreshing Zotero linked file'
)
-// refresh importer's displayed name
-// if the importer is the owner, name is not displayed, refresh is not needed
-// if the importer is not available, the old name is preserved
+ // Preserve the old name if the importer is unavailable. Persist the refreshed
+ // name with the bibliography through importContent(), not a separate write.
const userName = await _getUserName(linkedFileData.importer_id)
- if (userName && linkedFileData.importer_id != userId) {
+ if (userName) {
linkedFileData.importer_name = userName
- const { element, path } = await ProjectLocator.promises.findElement({
- project_id: projectId,
- element_id: parentFolderId,
- type: 'folders'
- })
- const fileIndex = element.fileRefs.findIndex(file => file.name === name)
- const updatePath = `${path.mongo}.fileRefs.${fileIndex}.linkedFileData.importer_name`
- await Project.updateOne({ _id: projectId }, { $set: { [updatePath]: userName } })
}
+ return _importLinkedFile(projectId, linkedFileData, name, parentFolderId, userId)
+}
+
+async function _importLinkedFile(projectId, linkedFileData, name, parentFolderId, userId) {
const bibtex = await _getBibtex(linkedFileData)
const file = await LinkedFilesHandler.promises.importContent(
@@ -108,7 +94,8 @@ async function _getBibtex(linkedFileData) {
return await ZoteroApiClient.getLibraryBibtex(
userId,
linkedFileData.group_id, // == null for main library
- linkedFileData.format || 'bibtex'
+ linkedFileData.format || 'bibtex',
+ linkedFileData.collection_key
)
} catch (err) {
@@ -121,8 +108,8 @@ async function _getBibtex(linkedFileData) {
throw new LinkedFilesErrors.AccessDeniedError('Zotero account not linked').withCause(err)
}
if (err instanceof NotFoundError) {
- logger.debug({ group: linkedFileData.group_id, err }, 'Zotero group is not found')
- throw new LinkedFilesErrors.SourceFileNotFoundError('Zotero group is not found').withCause(err)
+ logger.debug({ group: linkedFileData.group_id, collection: linkedFileData.collection_key, err }, 'Zotero library or collection is not found')
+ throw new LinkedFilesErrors.SourceFileNotFoundError('Zotero library or collection is not found').withCause(err)
}
logger.error({ linkedFileData, err }, 'failed to retrieve bib file from Zotero')
throw new LinkedFilesErrors.RemoteServiceError('Error retrieving bib file from Zotero').withCause(err)
@@ -133,6 +120,9 @@ function _sanitizeData(data) {
return {
provider: 'zotero',
group_id: data.group_id ?? null,
+ ...(data.collection_key && {
+ collection_key: data.collection_key,
+ }),
importedAt: data.importedAt,
...(data.importer_id && {
importer_id: data.importer_id,
diff --git a/services/web/modules/zotero/app/src/ZoteroOAuth.mjs b/services/web/modules/zotero/app/src/ZoteroOAuth.mjs
index 3be131e960..b738ab7040 100644
--- a/services/web/modules/zotero/app/src/ZoteroOAuth.mjs
+++ b/services/web/modules/zotero/app/src/ZoteroOAuth.mjs
@@ -31,8 +31,12 @@ function buildAuthorizationHeader(requestData, tokenData) {
return oauth.toHeader(authData).Authorization
}
-function parseOAuthResponse(body) {
- return Object.fromEntries(new URLSearchParams(body).entries())
+function parseOAuthResponse(body, requiredFields) {
+ const data = Object.fromEntries(new URLSearchParams(body).entries())
+ if (requiredFields.some(field => !data[field]?.trim())) {
+ throw new OError('Invalid Zotero OAuth response')
+ }
+ return data
}
function getAuthorizationUrl(oauthToken) {
@@ -44,11 +48,6 @@ function getAuthorizationUrl(oauthToken) {
return `${ZOTERO_OAUTH_URL}/authorize?${params}`
}
-/*
-function getAuthorizationUrl(oauthToken) {
- return `${ZOTERO_OAUTH_URL}/authorize?oauth_token=${oauthToken}`
-}
-*/
async function getRequestToken() {
const url = `${ZOTERO_OAUTH_URL}/request`
@@ -67,7 +66,7 @@ async function getRequestToken() {
},
})
- return parseOAuthResponse(body)
+ return parseOAuthResponse(body, ['oauth_token', 'oauth_token_secret'])
} catch (err) {
throw OError.tag(err, 'error obtaining Zotero request token')
}
@@ -100,16 +99,17 @@ async function exchangeRequestTokenForAccessToken(
},
})
- const data = parseOAuthResponse(body)
+ const data = parseOAuthResponse(body, ['oauth_token', 'userID'])
+ if (!/^\d+$/.test(data.userID)) {
+ throw new OError('Invalid Zotero user ID in OAuth response')
+ }
return {
accessToken: data.oauth_token,
- apiKeySecret: data.oauth_token_secret,
zoteroUserId: data.userID,
- username: data.username,
}
} catch (err) {
- logger.err(
+ logger.error(
{ err, oauthToken },
'error obtaining Zotero access token'
)
diff --git a/services/web/modules/zotero/app/src/ZoteroRouter.mjs b/services/web/modules/zotero/app/src/ZoteroRouter.mjs
index f09f54b2d8..7939e64554 100644
--- a/services/web/modules/zotero/app/src/ZoteroRouter.mjs
+++ b/services/web/modules/zotero/app/src/ZoteroRouter.mjs
@@ -10,6 +10,12 @@ export default {
ZoteroController.getGroups
)
+ webRouter.get(
+ '/user/zotero/collections',
+ AuthenticationController.requireLogin(),
+ ZoteroController.getCollections
+ )
+
// Unlink Zotero account
webRouter.delete(
'/user/zotero',
diff --git a/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx b/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx
index 4aca5a4cde..c079e8400e 100644
--- a/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx
+++ b/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx
@@ -1,5 +1,8 @@
import { useTranslation } from 'react-i18next'
-import { FormEventHandler, useEffect, useState } from 'react'
+import { FormEventHandler, useEffect, useMemo, useState } from 'react'
+import { getJSON } from '@/infrastructure/fetch-json'
+import useAsync from '@/shared/hooks/use-async'
+import { debugConsole } from '@/utils/debugging'
import * as eventTracking from '@/infrastructure/event-tracking'
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
import { useFileTreeCreateForm } from '@/features/file-tree/contexts/file-tree-create-form'
@@ -11,24 +14,71 @@ import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
import Notification from '@/shared/components/notification'
-type ZoteroGroup = {
+export type ZoteroGroup = {
id: string
name: string
}
+type ZoteroCollection = {
+ key: string
+ name: string
+ parentCollection: string | null
+}
+
+function collectionOptions(collections: ZoteroCollection[]) {
+ const byKey = new Map(collections.map(c => [c.key, c]))
+ return collections.map(collection => {
+ const path = [collection.name]
+ const visited = new Set([collection.key])
+ let parentKey = collection.parentCollection
+ while (parentKey && !visited.has(parentKey)) {
+ const parent = byKey.get(parentKey)
+ if (!parent) break
+ path.unshift(parent.name)
+ visited.add(parentKey)
+ parentKey = parent.parentCollection
+ }
+ return { key: collection.key, label: path.join(' / ') }
+ }).sort((a, b) => a.label.localeCompare(b.label))
+}
+
type FileTreeImportFromZoteroProps = { groups: ZoteroGroup[] }
export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZoteroProps) {
const { t } = useTranslation()
- const { name, setName } = useFileTreeCreateName()
+ const { name } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable()
- const [selectedGroupId, setSelectedGroupId] = useState("")
- const [selectedFormat, setSelectedFormat] = useState('bibtex')
+ const [selectedGroupId, setSelectedGroupId] = useState('')
+ const [selectedCollectionKey, setSelectedCollectionKey] = useState('')
+ const {
+ runAsync,
+ reset,
+ data: collections,
+ isLoading: collectionsLoading,
+ isError: collectionsError,
+ } = useAsync({ status: 'pending' })
+ const [selectedFormat, setSelectedFormat] = useState('bibtex')
+ const sortedCollections = useMemo(() => collectionOptions(collections || []), [collections])
+
+ useEffect(() => {
+ const controller = new AbortController()
+ reset()
+
+ const query = selectedGroupId ? `?group_id=${encodeURIComponent(selectedGroupId)}` : ''
+ runAsync(getJSON(`/user/zotero/collections${query}`, {
+ signal: controller.signal,
+ }))
+ .catch(err => {
+ debugConsole.error(err?.data?.message || err?.message || err)
+ })
+
+ return () => controller.abort()
+ }, [selectedGroupId, runAsync, reset])
useEffect(() => {
setValid(!!name)
- }, [name])
+ }, [name, setValid])
const handleSubmit: FormEventHandler = event => {
event.preventDefault()
@@ -42,6 +92,7 @@ export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZ
provider: 'zotero',
data: {
group_id: selectedGroupId || undefined,
+ collection_key: selectedCollectionKey || undefined,
format: selectedFormat,
}
})
@@ -62,9 +113,10 @@ export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZ
id="zotero-library-select"
value={selectedGroupId}
disabled={inFlight}
- onChange={(e: React.ChangeEvent) =>
+ onChange={(e: React.ChangeEvent) => {
setSelectedGroupId(e.target.value)
- }
+ setSelectedCollectionKey('')
+ }}
>
{groups?.map(g => (
@@ -75,6 +127,32 @@ export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZ
+
+ {t('collection')}
+ ) =>
+ setSelectedCollectionKey(e.target.value)
+ }
+ >
+
+ {!selectedGroupId && (
+
+ )}
+ {sortedCollections.map(collection => (
+
+ ))}
+
+
+
+ {collectionsError && (
+
+ )}
+
- {(importer_id === getMeta('ol-user_id')) ? (
+ {(importerId === getMeta('ol-user_id')) ? (
t('imported_from_zotero_at_date', {
formattedDate,
relativeDate: relative,
@@ -41,7 +45,7 @@ export function TPRFileViewInfo({ file }: TPRFileViewInfoProps) {
t('imported_from_zotero_at_date_by', {
formattedDate,
relativeDate: relative,
- importer_name,
+ importedByName: importerName,
})
)}
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 ee71c9d4d0..58f106a639 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,12 +1,11 @@
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
-type TPRFileViewInfoProps = {
+type TPRFileViewNotOriginalImporterProps = {
file: LinkedFile
}
-export function TPRFileViewNotOriginalImporter({
- file,
-}: TPRFileViewNotOriginalImporterProps) {
+// Keep the exported hook until its host registration can be removed together.
+export function TPRFileViewNotOriginalImporter(_props: TPRFileViewNotOriginalImporterProps) {
return null
}
diff --git a/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx b/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx
index 8936cd6641..a3f6762d07 100644
--- a/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx
+++ b/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx
@@ -8,7 +8,7 @@ import Notification from '@/shared/components/notification'
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
import FileTreeModalCreateFileMode from '@/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode'
import FileTreeCreateNameProvider from '@/features/file-tree/contexts/file-tree-create-name'
-import FileTreeImportFromZotero from './file-tree-import-from-zotero'
+import FileTreeImportFromZotero, { type ZoteroGroup } from './file-tree-import-from-zotero'
export function CreateFileMode() {
const { t } = useTranslation()
@@ -93,11 +93,12 @@ export function CreateFilePane() {
{
- window.open(
+ const popup = window.open(
'/user/zotero/oauth?popup=1',
'_blank',
'width=600,height=700'
)
+ if (!popup) window.location.assign('/user/zotero/oauth?popup=0')
}}
>
{t('link_to_zotero')}
diff --git a/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx b/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx
index 961d2462d6..e2cf47f6b1 100644
--- a/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx
+++ b/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx
@@ -15,20 +15,22 @@ const ZoteroLinkCard = () => {
const {
isError,
runAsync,
- data: isConnected
+ isLoading
} = useAsync()
const handleClick = () => {
+ if (isLoading) return
runAsync(getJSON('/user/zotero/status'))
.then(isConnected => {
if (isConnected) {
setShowModal(true)
} else {
- window.open(
+ const popup = window.open(
'/user/zotero/oauth?popup=1',
'_blank',
'width=600,height=700'
)
+ if (!popup) window.location.assign('/user/zotero/oauth?popup=0')
}
})
.catch(err => {
diff --git a/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx b/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx
index 0e8c6c7c0a..9708640276 100644
--- a/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx
+++ b/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx
@@ -2,7 +2,7 @@ import { useTranslation, Trans } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { deleteJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect } from 'react'
import {
OLModal,
OLModalHeader,
@@ -13,11 +13,9 @@ import {
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
-import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
-
-type ZoteroLinkInfoModalModalProps = {
+type ZoteroLinkInfoModalProps = {
show: boolean
- isError: any
+ isError: boolean
handleHide: () => void
}
@@ -29,18 +27,20 @@ const ZoteroLinkInfoModal = ({ show, isError, handleHide }: ZoteroLinkInfoModalP
isLoading: isUnlinking,
isError: isErrorUnlink,
runAsync: runAsyncUnlink,
+ reset: resetUnlink,
} = useAsync()
const handleUnlink = () => {
runAsyncUnlink(deleteJSON('/user/zotero'))
+ .then(handleHide)
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
- handleHide()
}
useEffect(() => {
if (!show) return
setShowUnlinkInfo(false)
- }, [show])
+ resetUnlink()
+ }, [show, resetUnlink])
return (
@@ -49,6 +49,9 @@ const ZoteroLinkInfoModal = ({ show, isError, handleHide }: ZoteroLinkInfoModalP
+ {isErrorUnlink && (
+
+ )}
{showUnlinkInfo ? (
void
- /** Host writes these BibTeX entries (raw text, combined). */
- onInsert: (bibtexText: string) => void
-}
-
-type Step = 'library' | 'items'
-
-function errorMessage(err: unknown, fallback: string) {
- if (err && typeof err === 'object') {
- const data = (err as { data?: { message?: string } }).data
- if (data?.message) return data.message === 'zotero_not_linked' ? 'zotero_not_linked' : data.message
- if ((err as { message?: string }).message) {
- return (err as { message: string }).message
- }
- }
- return fallback
-}
-
-const ITEMS_PER_PAGE = 100
-
-export default function ZoteroPickerModal({
- show,
- handleHide,
- onInsert,
-}: ZoteroPickerModalProps) {
- const { t } = useTranslation()
-
- const [step, setStep] = useState('library')
- const [notLinked, setNotLinked] = useState(false)
-
- // -- library step --
- const [loadingLibraries, setLoadingLibraries] = useState(false)
- const [libraries, setLibraries] = useState([])
- const [libraryIdx, setLibraryIdx] = useState(0)
- const [collections, setCollections] = useState([])
- const [collectionsLoading, setCollectionsLoading] = useState(false)
- const [collectionKey, setCollectionKey] = useState('')
- const [libraryError, setLibraryError] = useState(null)
-
- // -- items step --
- const [items, setItems] = useState([])
- const [total, setTotal] = useState(0)
- const [itemsLoading, setItemsLoading] = useState(false)
- const [itemsError, setItemsError] = useState(null)
- const [selectedKeys, setSelectedKeys] = useState>(new Set())
-
- // -- import state --
- const [importing, setImporting] = useState(false)
- const [importError, setImportError] = useState(null)
-
- const currentLibrary = libraries[libraryIdx]
-
- // -----------------------------------------------------------------------
- // Reset when the modal closes
- // -----------------------------------------------------------------------
- const handleClose = useCallback(() => {
- setStep('library')
- setNotLinked(false)
- setLibraries([])
- setLibraryIdx(0)
- setCollections([])
- setCollectionKey('')
- setLibraryError(null)
- setItems([])
- setTotal(0)
- setItemsError(null)
- setSelectedKeys(new Set())
- setImporting(false)
- setImportError(null)
- handleHide()
- }, [handleHide])
-
- // -----------------------------------------------------------------------
- // Load libraries when the modal opens
- // -----------------------------------------------------------------------
- useEffect(() => {
- if (!show) return
- let alive = true
- setLoadingLibraries(true)
- setLibraryError(null)
- setNotLinked(false)
- setLibraries([])
- setCollections([])
- setCollectionKey('')
- setLibraryIdx(0)
- void getJSON('/user/zotero/picker/libraries')
- .then(data => {
- if (!alive) return
- const libs = Array.isArray(data) ? data : []
- setLibraries(libs)
- if (libs.length === 0) {
- setLibraryError(t('Failed to load your Zotero libraries'))
- }
- })
- .catch(err => {
- if (!alive) return
- const msg = errorMessage(err, t('Failed to load your Zotero libraries'))
- if (msg === 'zotero_not_linked') setNotLinked(true)
- else setLibraryError(msg)
- })
- .finally(() => {
- if (alive) setLoadingLibraries(false)
- })
- return () => {
- alive = false
- }
- }, [show, t])
-
- // -----------------------------------------------------------------------
- // Load the collections of the selected library
- // -----------------------------------------------------------------------
- const loadCollections = useCallback(
- (lib: Library) => {
- setCollections([])
- setCollectionKey('')
- setCollectionsLoading(true)
- const qs = new URLSearchParams({ libraryKind: lib.kind })
- if (lib.kind === 'group') qs.set('library', lib.id)
- void getJSON(`/user/zotero/picker/collections?${qs.toString()}`)
- .then(data => {
- setCollections(Array.isArray(data) ? data : [])
- })
- .catch(err => {
- const msg = errorMessage(err, null)
- if (msg === 'zotero_not_linked') setNotLinked(true)
- })
- .finally(() => setCollectionsLoading(false))
- },
- []
- )
-
- useEffect(() => {
- if (show && currentLibrary) {
- loadCollections(currentLibrary)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [show, libraryIdx, libraries.length])
-
- // -----------------------------------------------------------------------
- // Browse items
- // -----------------------------------------------------------------------
- const browse = useCallback(() => {
- if (!currentLibrary) return
- setStep('items')
- setItemsError(null)
- setImportError(null)
- setItems([])
- setSelectedKeys(new Set())
- setItemsLoading(true)
- const qs = new URLSearchParams({
- libraryKind: currentLibrary.kind,
- })
- if (currentLibrary.kind === 'group') qs.set('library', currentLibrary.id)
- if (collectionKey) qs.set('collection', collectionKey)
- qs.set('limit', String(ITEMS_PER_PAGE))
- void getJSON<{ items: ZItem[]; total: number }>(
- `/user/zotero/picker/items?${qs.toString()}`
- )
- .then(data => {
- setItems(data.items || [])
- setTotal(data.total || (data.items || []).length)
- })
- .catch(err => {
- const msg = errorMessage(err, t('Failed to load items'))
- if (msg === 'zotero_not_linked') {
- setNotLinked(true)
- setStep('library')
- } else {
- setItemsError(msg)
- }
- })
- .finally(() => setItemsLoading(false))
- }, [currentLibrary, collectionKey, t])
-
- // -----------------------------------------------------------------------
- // Selection
- // -----------------------------------------------------------------------
- const toggleItem = useCallback((key: string) => {
- setSelectedKeys(prev => {
- const next = new Set(prev)
- if (next.has(key)) next.delete(key)
- else next.add(key)
- return next
- })
- }, [])
-
- const allSelected = items.length > 0 && items.every(i => selectedKeys.has(i.key))
- const toggleAll = useCallback(() => {
- if (allSelected) setSelectedKeys(new Set())
- else setSelectedKeys(new Set(items.map(i => i.key)))
- }, [allSelected, items])
-
- // -----------------------------------------------------------------------
- // Import
- // -----------------------------------------------------------------------
- const handleImport = useCallback(async () => {
- if (!currentLibrary || selectedKeys.size === 0) return
- setImporting(true)
- setImportError(null)
- try {
- const qs = new URLSearchParams({
- libraryKind: currentLibrary.kind,
- keys: Array.from(selectedKeys).join(','),
- })
- if (currentLibrary.kind === 'group') qs.set('library', currentLibrary.id)
- const data = await getJSON<{ bibtex: string }>(
- `/user/zotero/picker/bibtex?${qs.toString()}`
- )
- onInsert(data.bibtex || '')
- handleClose()
- } catch (err) {
- const msg = errorMessage(err, null)
- if (msg === 'zotero_not_linked') {
- setNotLinked(true)
- setImportError(t('Zotero is no longer linked to your account'))
- } else {
- setImportError(errorMessage(err, t('Could not import from Zotero')))
- }
- setImporting(false)
- }
- }, [currentLibrary, selectedKeys, onInsert, handleClose, t])
-
- // -----------------------------------------------------------------------
- // Render
- // -----------------------------------------------------------------------
- return (
-
-
-
- {step === 'library'
- ? t('Import from Zotero')
- : t('Items', {
- author: currentLibrary?.name || t('Zotero'),
- })}
-
-
-
-
- {notLinked ? (
- <>
- {t('Zotero is not linked to your account yet.')}
-
- {t('Link your Zotero account to import references. Create a key at ') +
- (
-
- zotero.org/settings/keys
-
- ) +
- t(' (library access + read access to all groups).')}
-
-
-
- {t('Link Zotero')}
-
-
- {t('Cancel')}
-
-
- >
- ) : (
- <>
- {step === 'library' && (
- <>
- {libraryError && (
- setLibraryError(null)}
- />
- )}
- {loadingLibraries && {t('Loading your Zotero libraries…')}
}
- {!loadingLibraries && libraries.length > 0 && (
- <>
-
- {t('library')}
- ) =>
- setLibraryIdx(Number(e.target.value) || 0)
- }
- >
- {libraries.map((lib, i) => (
-
- ))}
-
-
-
-
- {t('collection')} ({t('optional')})
-
- ) =>
- setCollectionKey(e.target.value)
- }
- >
-
- {collections.map(c => (
-
- ))}
-
-
-
- {t('Browse items')}
-
- >
- )}
- >
- )}
-
- {step === 'items' && (
- <>
- {
- setStep('library')
- setItemsError(null)
- setImportError(null)
- }}
- style={{ padding: 0, marginBottom: '8px' }}
- data-testid="zot-back"
- >
- {t('Back to libraries')}
-
-
- {itemsLoading && {t('Loading items…')}
}
-
- {itemsError && (
- setItemsError(null)}
- />
- )}
- {importError && (
- setImportError(null)}
- />
- )}
-
- {!itemsLoading && items.length > 0 && (
- <>
-
- {t('Showing __shown__ of __total__ (newest first)', {
- shown: items.length,
- total,
- })}
-
-
-
-
-
-
- |
-
- {t('Title')}
- |
-
- {t('Type')}
- |
-
- {t('Year')}
- |
-
-
-
- {items.map((w, i) => (
-
- |
- toggleItem(w.key)}
- aria-label={w.title || t('Untitled')}
- />
- |
-
- {w.title || t('Untitled')}
- {w.firstCreator ? (
-
- {` — ${w.firstCreator}`}
-
- ) : null}
- |
-
- {w.itemType || '—'}
- |
-
- {(w.date || '').slice(0, 4) || '—'}
- |
-
- ))}
-
-
-
- >
- )}
- >
- )}
- >
- )}
-
-
- {step === 'items' && !notLinked && !itemsLoading && items.length > 0 && (
-
-
- {t('Cancel')}
-
- void handleImport()}
- disabled={selectedKeys.size === 0 || importing}
- isLoading={importing}
- >
- {t('Import selected (__count__)', { count: selectedKeys.size })}
-
-
- )}
-
- )
-}
diff --git a/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx
index 537e7d77dc..e382b83157 100644
--- a/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx
+++ b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
-import { getJSON, deleteJSON } from '@/infrastructure/fetch-json'
+import { getJSON, deleteJSON, isForbidden, type FetchError } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/shared/components/ol/ol-button'
@@ -17,10 +17,7 @@ import ZoteroLogo from '@/shared/svgs/zotero-logo'
/**
* Zotero account linking widget for the Account Settings page.
- * Instead of OAuth, users paste their Zotero API key directly.
- * Create one at https://www.zotero.org/settings/keys with:
- * - "Allow library access"
- * - "Allow read access to all groups" (for group library imports)
+ * Links the account through the Zotero OAuth flow.
*
* Registered via overleafModuleImports.referenceLinkingWidgets.
*/
@@ -31,10 +28,11 @@ export const ZoteroWidget = function ZoteroWidget() {
const {
isLoading: isCheckingConn,
isError: isErrorConnCheck,
+ error: connCheckError,
runAsync: runAsyncConnCheck,
data: isConnected,
setData: setConnState,
- } = useAsync()
+ } = useAsync()
const {
isLoading: isUnlinking,
@@ -44,6 +42,8 @@ export const ZoteroWidget = function ZoteroWidget() {
const [showUnlinkModal, setShowUnlinkModal] = useState(false)
+ const needsRelink = isForbidden(connCheckError)
+
const handleConnCheck = useCallback(() => {
runAsyncConnCheck(getJSON('/user/zotero/status'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
@@ -58,7 +58,7 @@ export const ZoteroWidget = function ZoteroWidget() {
.then(() => setConnState(false))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
.finally(() => setShowUnlinkModal(false))
- }, [runAsyncUnlink])
+ }, [runAsyncUnlink, setConnState])
if (isCheckingConn) {
return (
@@ -69,7 +69,7 @@ export const ZoteroWidget = function ZoteroWidget() {
-
GitHub
+ {t('zotero')}
@@ -96,7 +96,7 @@ export const ZoteroWidget = function ZoteroWidget() {
{t('zotero_sync_description', { appName })}
- {isErrorConnCheck && (
+ {(isErrorConnCheck && !needsRelink) && (
{isUnlinking ? t('unlinking') : t('unlink')}
- ) : isErrorConnCheck ? (
+ ) : isErrorConnCheck && !needsRelink ? (