mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Zotero: clean up the code; add collections support
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(`
|
||||
<!doctype html>
|
||||
@@ -128,7 +138,28 @@ async function getGroups(req, res) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /user/zotero/unlink
|
||||
* GET /user/zotero/collections?group_id=<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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -10,6 +10,12 @@ export default {
|
||||
ZoteroController.getGroups
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/user/zotero/collections',
|
||||
AuthenticationController.requireLogin(),
|
||||
ZoteroController.getCollections
|
||||
)
|
||||
|
||||
// Unlink Zotero account
|
||||
webRouter.delete(
|
||||
'/user/zotero',
|
||||
|
||||
+86
-8
@@ -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<string>("")
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('bibtex')
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('')
|
||||
const [selectedCollectionKey, setSelectedCollectionKey] = useState('')
|
||||
const {
|
||||
runAsync,
|
||||
reset,
|
||||
data: collections,
|
||||
isLoading: collectionsLoading,
|
||||
isError: collectionsError,
|
||||
} = useAsync<ZoteroCollection[]>({ 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<ZoteroCollection[]>(`/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<HTMLSelectElement>) =>
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedGroupId(e.target.value)
|
||||
}
|
||||
setSelectedCollectionKey('')
|
||||
}}
|
||||
>
|
||||
<option value="">{t('my_library')}</option>
|
||||
{groups?.map(g => (
|
||||
@@ -75,6 +127,32 @@ export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZ
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="zotero-collection-select">
|
||||
<OLFormLabel>{t('collection')}</OLFormLabel>
|
||||
<OLFormSelect
|
||||
id="zotero-collection-select"
|
||||
value={selectedCollectionKey}
|
||||
disabled={inFlight || collectionsLoading}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setSelectedCollectionKey(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">{t('all_collections')}</option>
|
||||
{!selectedGroupId && (
|
||||
<option value="publications">{t('my_publications')}</option>
|
||||
)}
|
||||
{sortedCollections.map(collection => (
|
||||
<option key={collection.key} value={collection.key}>
|
||||
{collection.label}
|
||||
</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
|
||||
{collectionsError && (
|
||||
<Notification type="error" content={t('zotero_could_not_load_collections')} />
|
||||
)}
|
||||
|
||||
<FileTreeCreateNameInput
|
||||
label={t('file_name_in_this_project')}
|
||||
placeholder="zotero.bib"
|
||||
|
||||
@@ -21,18 +21,22 @@ export function TPRFileViewInfo({ file }: TPRFileViewInfoProps) {
|
||||
|
||||
if (!hasProvider(file, 'zotero')) return null
|
||||
|
||||
const importedAt = (file.linkedFileData as any)?.importedAt || file.created
|
||||
const metadata = file.linkedFileData as LinkedFileData['zotero'] & {
|
||||
importer_id?: string
|
||||
importer_name?: string
|
||||
}
|
||||
const importedAt = metadata.importedAt || file.created
|
||||
const formattedDate = formatTime(importedAt)
|
||||
const relative = relativeDate(importedAt)
|
||||
|
||||
const importer_id = (file.linkedFileData as any)?.importer_id
|
||||
const importer_name = (file.linkedFileData as any)?.importer_name || 'Unknown'
|
||||
const importerId = metadata.importer_id
|
||||
const importerName = metadata.importer_name || 'Unknown'
|
||||
|
||||
return (
|
||||
<p>
|
||||
<LinkedFileIcon />
|
||||
|
||||
{(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,
|
||||
})
|
||||
)}
|
||||
</p>
|
||||
|
||||
+3
-4
@@ -1,12 +1,11 @@
|
||||
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
|
||||
|
||||
type TPRFileViewInfoProps = {
|
||||
type TPRFileViewNotOriginalImporterProps = {
|
||||
file: LinkedFile<keyof LinkedFileData>
|
||||
}
|
||||
|
||||
export function TPRFileViewNotOriginalImporter({
|
||||
file,
|
||||
}: TPRFileViewNotOriginalImporterProps) {
|
||||
// Keep the exported hook until its host registration can be removed together.
|
||||
export function TPRFileViewNotOriginalImporter(_props: TPRFileViewNotOriginalImporterProps) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
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')}
|
||||
|
||||
@@ -15,20 +15,22 @@ const ZoteroLinkCard = () => {
|
||||
const {
|
||||
isError,
|
||||
runAsync,
|
||||
data: isConnected
|
||||
isLoading
|
||||
} = useAsync<boolean>()
|
||||
|
||||
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 => {
|
||||
|
||||
@@ -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<void>()
|
||||
|
||||
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 (
|
||||
<OLModal show={show} onHide={handleHide} backdrop="static" themed="true">
|
||||
@@ -49,6 +49,9 @@ const ZoteroLinkInfoModal = ({ show, isError, handleHide }: ZoteroLinkInfoModalP
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{isErrorUnlink && (
|
||||
<Notification type="error" content={t('generic_something_went_wrong')} />
|
||||
)}
|
||||
{showUnlinkInfo ? (
|
||||
<Notification
|
||||
type="warning"
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
/**
|
||||
* Zotero picker modal (P4 — BIB_ORCID_TEMPLATES_PLAN.md).
|
||||
*
|
||||
* Same UX as the ORCID picker, source = the user's OWN linked Zotero:
|
||||
* step 'library': pick a library (main library or a group) + an
|
||||
* optional collection + Browse
|
||||
* step 'items': list items (newest first), select some, Import
|
||||
*
|
||||
* The modal ends by calling `onInsert(bibtexText)` with the combined
|
||||
* BibTeX of the selected items (one combined Zotero request server-side;
|
||||
* the host surface parses and writes it through its own guarded import
|
||||
* path). "Zotero not linked" (409) renders a link action to the OAuth
|
||||
* flow.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormSelect from '@/shared/components/ol/ol-form-select'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
|
||||
type Library = { id: string; kind: 'user' | 'group'; name: string }
|
||||
type Collection = { key: string; name: string }
|
||||
type ZItem = {
|
||||
key: string
|
||||
title: string
|
||||
itemType: string
|
||||
date: string
|
||||
firstCreator: string
|
||||
}
|
||||
|
||||
type ZoteroPickerModalProps = {
|
||||
show: boolean
|
||||
handleHide: () => 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<Step>('library')
|
||||
const [notLinked, setNotLinked] = useState(false)
|
||||
|
||||
// -- library step --
|
||||
const [loadingLibraries, setLoadingLibraries] = useState(false)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryIdx, setLibraryIdx] = useState(0)
|
||||
const [collections, setCollections] = useState<Collection[]>([])
|
||||
const [collectionsLoading, setCollectionsLoading] = useState(false)
|
||||
const [collectionKey, setCollectionKey] = useState('')
|
||||
const [libraryError, setLibraryError] = useState<string | null>(null)
|
||||
|
||||
// -- items step --
|
||||
const [items, setItems] = useState<ZItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [itemsLoading, setItemsLoading] = useState(false)
|
||||
const [itemsError, setItemsError] = useState<string | null>(null)
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
|
||||
|
||||
// -- import state --
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importError, setImportError] = useState<string | null>(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<Library[]>('/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<Collection[]>(`/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 (
|
||||
<OLModal show={show} onHide={handleClose} data-testid="zotero-picker-modal">
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>
|
||||
{step === 'library'
|
||||
? t('Import from Zotero')
|
||||
: t('Items', {
|
||||
author: currentLibrary?.name || t('Zotero'),
|
||||
})}
|
||||
</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{notLinked ? (
|
||||
<>
|
||||
<p>{t('Zotero is not linked to your account yet.')}</p>
|
||||
<p>
|
||||
{t('Link your Zotero account to import references. Create a key at ') +
|
||||
(
|
||||
<a
|
||||
href="https://www.zotero.org/settings/keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
zotero.org/settings/keys
|
||||
</a>
|
||||
) +
|
||||
t(' (library access + read access to all groups).')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
href="/user/zotero/oauth?popup=0"
|
||||
target="_blank"
|
||||
>
|
||||
{t('Link Zotero')}
|
||||
</OLButton>
|
||||
<OLButton variant="secondary" onClick={handleClose}>
|
||||
{t('Cancel')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step === 'library' && (
|
||||
<>
|
||||
{libraryError && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={libraryError}
|
||||
isDismissible
|
||||
onDismiss={() => setLibraryError(null)}
|
||||
/>
|
||||
)}
|
||||
{loadingLibraries && <p>{t('Loading your Zotero libraries…')}</p>}
|
||||
{!loadingLibraries && libraries.length > 0 && (
|
||||
<>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor="zot-lib-select">{t('library')}</OLFormLabel>
|
||||
<OLFormSelect
|
||||
id="zot-lib-select"
|
||||
value={String(libraryIdx)}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setLibraryIdx(Number(e.target.value) || 0)
|
||||
}
|
||||
>
|
||||
{libraries.map((lib, i) => (
|
||||
<option key={`${lib.kind}-${lib.id}`} value={String(i)}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor="zot-coll-select">
|
||||
{t('collection')} ({t('optional')})
|
||||
</OLFormLabel>
|
||||
<OLFormSelect
|
||||
id="zot-coll-select"
|
||||
value={collectionKey}
|
||||
disabled={collectionsLoading}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setCollectionKey(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">{t('All items')}</option>
|
||||
{collections.map(c => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={browse}
|
||||
>
|
||||
{t('Browse items')}
|
||||
</OLButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'items' && (
|
||||
<>
|
||||
<OLButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStep('library')
|
||||
setItemsError(null)
|
||||
setImportError(null)
|
||||
}}
|
||||
style={{ padding: 0, marginBottom: '8px' }}
|
||||
data-testid="zot-back"
|
||||
>
|
||||
{t('Back to libraries')}
|
||||
</OLButton>
|
||||
|
||||
{itemsLoading && <p>{t('Loading items…')}</p>}
|
||||
|
||||
{itemsError && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={itemsError}
|
||||
isDismissible
|
||||
onDismiss={() => setItemsError(null)}
|
||||
/>
|
||||
)}
|
||||
{importError && (
|
||||
<Notification
|
||||
type="warning"
|
||||
content={importError}
|
||||
isDismissible
|
||||
onDismiss={() => setImportError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!itemsLoading && items.length > 0 && (
|
||||
<>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-secondary, #666)' }}>
|
||||
{t('Showing __shown__ of __total__ (newest first)', {
|
||||
shown: items.length,
|
||||
total,
|
||||
})}
|
||||
</p>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<OLFormCheckbox
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
label={t('Select all (__count__)', { count: items.length })}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '350px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
data-testid="zot-items"
|
||||
>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
backgroundColor: 'var(--bg-light-secondary)',
|
||||
}}
|
||||
>
|
||||
<th style={{ padding: '6px 10px', width: '30px' }} />
|
||||
<th style={{ padding: '6px 10px', textAlign: 'left' }}>
|
||||
{t('Title')}
|
||||
</th>
|
||||
<th style={{ padding: '6px 10px', textAlign: 'left' }}>
|
||||
{t('Type')}
|
||||
</th>
|
||||
<th style={{ padding: '6px 10px', textAlign: 'left', width: '70px' }}>
|
||||
{t('Year')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((w, i) => (
|
||||
<tr
|
||||
key={w.key || i}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '6px 10px' }}>
|
||||
<OLFormCheckbox
|
||||
checked={selectedKeys.has(w.key)}
|
||||
onChange={() => toggleItem(w.key)}
|
||||
aria-label={w.title || t('Untitled')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px' }}>
|
||||
{w.title || t('Untitled')}
|
||||
{w.firstCreator ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary, #666)',
|
||||
}}
|
||||
>
|
||||
{` — ${w.firstCreator}`}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontSize: '12px' }}>
|
||||
{w.itemType || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontSize: '12px' }}>
|
||||
{(w.date || '').slice(0, 4) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OLModalBody>
|
||||
|
||||
{step === 'items' && !notLinked && !itemsLoading && items.length > 0 && (
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleClose}>
|
||||
{t('Cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={selectedKeys.size === 0 || importing}
|
||||
isLoading={importing}
|
||||
>
|
||||
{t('Import selected (__count__)', { count: selectedKeys.size })}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
)}
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>()
|
||||
} = useAsync<boolean, FetchError>()
|
||||
|
||||
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() {
|
||||
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>GitHub</h4>
|
||||
<h4>{t('zotero')}</h4>
|
||||
</div>
|
||||
|
||||
<p className="small">
|
||||
@@ -96,7 +96,7 @@ export const ZoteroWidget = function ZoteroWidget() {
|
||||
{t('zotero_sync_description', { appName })}
|
||||
</p>
|
||||
|
||||
{isErrorConnCheck && (
|
||||
{(isErrorConnCheck && !needsRelink) && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={t('problem_checking_connection_with_provider', { provider: t('zotero') })}
|
||||
@@ -120,7 +120,7 @@ export const ZoteroWidget = function ZoteroWidget() {
|
||||
>
|
||||
{isUnlinking ? t('unlinking') : t('unlink')}
|
||||
</OLButton>
|
||||
) : isErrorConnCheck ? (
|
||||
) : isErrorConnCheck && !needsRelink ? (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleConnCheck}
|
||||
|
||||
@@ -13,7 +13,7 @@ if (Settings.enabledLinkedFileTypes?.includes('zotero')) {
|
||||
clientKey: process.env.ZOTERO_CLIENT_KEY,
|
||||
clientSecret: process.env.ZOTERO_CLIENT_SECRET,
|
||||
callbackURL: `${siteUrl}/user/zotero/oauth/callback`,
|
||||
},
|
||||
}
|
||||
|
||||
ZoteroModule = {
|
||||
router: ZoteroRouter,
|
||||
|
||||
Reference in New Issue
Block a user