mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
Zotero Integration (#164)
This commit is contained in:
@@ -162,7 +162,7 @@ services:
|
|||||||
- dev.env
|
- dev.env
|
||||||
environment:
|
environment:
|
||||||
- APP_NAME=Overleaf Community Edition
|
- APP_NAME=Overleaf Community Edition
|
||||||
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
|
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file,zotero
|
||||||
- EMAIL_CONFIRMATION_DISABLED=true
|
- EMAIL_CONFIRMATION_DISABLED=true
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- OVERLEAF_ALLOW_PUBLIC_ACCESS=true
|
- OVERLEAF_ALLOW_PUBLIC_ACCESS=true
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
OVERLEAF_REDIS_HOST: redis
|
OVERLEAF_REDIS_HOST: redis
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
|
|
||||||
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file"
|
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero"
|
||||||
|
|
||||||
# Enables Thumbnail generation using ImageMagick
|
# Enables Thumbnail generation using ImageMagick
|
||||||
ENABLE_CONVERSIONS: "true"
|
ENABLE_CONVERSIONS: "true"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
OVERLEAF_EMAIL_SMTP_HOST: "mailtrap"
|
OVERLEAF_EMAIL_SMTP_HOST: "mailtrap"
|
||||||
OVERLEAF_EMAIL_SMTP_PORT: "25"
|
OVERLEAF_EMAIL_SMTP_PORT: "25"
|
||||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS: "true"
|
OVERLEAF_EMAIL_SMTP_IGNORE_TLS: "true"
|
||||||
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file"
|
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero"
|
||||||
ENABLE_CONVERSIONS: "true"
|
ENABLE_CONVERSIONS: "true"
|
||||||
EMAIL_CONFIRMATION_DISABLED: "true"
|
EMAIL_CONFIRMATION_DISABLED: "true"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -189,7 +189,13 @@ export default LinkedFilesController = {
|
|||||||
refreshLinkedFile: expressify(refreshLinkedFile),
|
refreshLinkedFile: expressify(refreshLinkedFile),
|
||||||
|
|
||||||
handleError(error, req, res, next) {
|
handleError(error, req, res, next) {
|
||||||
if (error instanceof AccessDeniedError) {
|
if (
|
||||||
|
error instanceof AccessDeniedError &&
|
||||||
|
error.message === 'Zotero account not linked'
|
||||||
|
) {
|
||||||
|
res.status(400)
|
||||||
|
plainTextResponse(res, 'Your account is not linked with Zotero')
|
||||||
|
} else if (error instanceof AccessDeniedError) {
|
||||||
res.status(403)
|
res.status(403)
|
||||||
plainTextResponse(
|
plainTextResponse(
|
||||||
res,
|
res,
|
||||||
@@ -231,8 +237,7 @@ export default LinkedFilesController = {
|
|||||||
} else {
|
} else {
|
||||||
plainTextResponse(
|
plainTextResponse(
|
||||||
res,
|
res,
|
||||||
`Your URL could not be reached (${
|
`Your URL could not be reached (${error.info?.status || error.cause?.info?.status
|
||||||
error.info?.status || error.cause?.info?.status
|
|
||||||
} status code). Please check it and try again.`
|
} status code). Please check it and try again.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1006,14 +1006,39 @@ module.exports = {
|
|||||||
//
|
//
|
||||||
// Restart webpack after making changes.
|
// Restart webpack after making changes.
|
||||||
//
|
//
|
||||||
createFileModes: [],
|
createFileModes: [
|
||||||
|
Path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../modules/zotero/frontend/js/components/zotero-create-file'
|
||||||
|
),
|
||||||
|
],
|
||||||
devToolbar: [],
|
devToolbar: [],
|
||||||
gitBridge: [],
|
gitBridge: [],
|
||||||
publishModal: [],
|
publishModal: [],
|
||||||
tprFileViewInfo: [],
|
tprFileViewInfo: [
|
||||||
tprFileViewRefreshError: [],
|
Path.resolve(
|
||||||
tprFileViewRefreshButton: [],
|
__dirname,
|
||||||
tprFileViewNotOriginalImporter: [],
|
'../modules/zotero/frontend/js/components/tpr-file-view-info'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tprFileViewRefreshError: [
|
||||||
|
Path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../modules/zotero/frontend/js/components/tpr-file-view-refresh-error'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tprFileViewRefreshButton: [
|
||||||
|
Path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../modules/zotero/frontend/js/components/tpr-file-view-refresh-button'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tprFileViewNotOriginalImporter: [
|
||||||
|
Path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../modules/zotero/frontend/js/components/tpr-file-view-not-original-importer'
|
||||||
|
),
|
||||||
|
],
|
||||||
contactUsModal: [],
|
contactUsModal: [],
|
||||||
sourceEditorExtensions: [],
|
sourceEditorExtensions: [],
|
||||||
sourceEditorComponents: [],
|
sourceEditorComponents: [],
|
||||||
@@ -1037,7 +1062,12 @@ module.exports = {
|
|||||||
langFeedbackLinkingWidgets: [],
|
langFeedbackLinkingWidgets: [],
|
||||||
labsExperiments: [],
|
labsExperiments: [],
|
||||||
integrationLinkingWidgets: [],
|
integrationLinkingWidgets: [],
|
||||||
referenceLinkingWidgets: [],
|
referenceLinkingWidgets: [
|
||||||
|
Path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../modules/zotero/frontend/js/components/zotero-widget'
|
||||||
|
),
|
||||||
|
],
|
||||||
importProjectFromGithubModalWrapper: [],
|
importProjectFromGithubModalWrapper: [],
|
||||||
importProjectFromGithubMenu: [],
|
importProjectFromGithubMenu: [],
|
||||||
editorLeftMenuSync: [
|
editorLeftMenuSync: [
|
||||||
@@ -1127,6 +1157,7 @@ module.exports = {
|
|||||||
'admin-tools', // import after authentication
|
'admin-tools', // import after authentication
|
||||||
'template-gallery',
|
'template-gallery',
|
||||||
'git-bridge',
|
'git-bridge',
|
||||||
|
'zotero',
|
||||||
],
|
],
|
||||||
viewIncludes: {},
|
viewIncludes: {},
|
||||||
|
|
||||||
|
|||||||
@@ -2581,9 +2581,12 @@
|
|||||||
"zoom_out": "",
|
"zoom_out": "",
|
||||||
"zoom_to": "",
|
"zoom_to": "",
|
||||||
"zotero": "",
|
"zotero": "",
|
||||||
|
"zotero_account_linked_successfully": "",
|
||||||
"zotero_dynamic_sync_description": "",
|
"zotero_dynamic_sync_description": "",
|
||||||
"zotero_groups_loading_error": "",
|
"zotero_groups_loading_error": "",
|
||||||
"zotero_groups_relink": "",
|
"zotero_groups_relink": "",
|
||||||
|
"zotero_imported_by_collaborator": "",
|
||||||
|
"zotero_imported_by_unknown": "",
|
||||||
"zotero_integration": "",
|
"zotero_integration": "",
|
||||||
"zotero_is_premium": "",
|
"zotero_is_premium": "",
|
||||||
"zotero_reference_loading_error": "",
|
"zotero_reference_loading_error": "",
|
||||||
|
|||||||
@@ -1741,8 +1741,11 @@
|
|||||||
"zh-CN": "Chinesisch",
|
"zh-CN": "Chinesisch",
|
||||||
"zip_contents_too_large": "ZIP-Inhalt zu groß",
|
"zip_contents_too_large": "ZIP-Inhalt zu groß",
|
||||||
"zotero": "Zotero",
|
"zotero": "Zotero",
|
||||||
|
"zotero_account_linked_successfully": "Zotero-Konto erfolgreich verknüpft.",
|
||||||
"zotero_groups_loading_error": "Beim Laden von Gruppen von Zotero ist ein Fehler aufgetreten",
|
"zotero_groups_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_groups_relink": "Beim Zugriff auf die Zotero-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.",
|
||||||
|
"zotero_imported_by_collaborator": "Diese Datei wurde aus Zotero von deinem Mitautor importiert. Du kannst sie nicht aktualisieren.",
|
||||||
|
"zotero_imported_by_unknown": "Diese Datei wurde aus Zotero von einem unbekannten Benutzer importiert. Du kannst sie nicht aktualisieren.",
|
||||||
"zotero_integration": "Zotero-Integration",
|
"zotero_integration": "Zotero-Integration",
|
||||||
"zotero_is_premium": "Zotero-Integration ist eine Premiumfunktion",
|
"zotero_is_premium": "Zotero-Integration ist eine Premiumfunktion",
|
||||||
"zotero_reference_loading_error": "Fehler, Referenzen konnten nicht von Mendeley geladen werden",
|
"zotero_reference_loading_error": "Fehler, Referenzen konnten nicht von Mendeley geladen werden",
|
||||||
|
|||||||
@@ -3252,9 +3252,12 @@
|
|||||||
"zoom_to": "Zoom to",
|
"zoom_to": "Zoom to",
|
||||||
"zotero": "Zotero",
|
"zotero": "Zotero",
|
||||||
"zotero_and_mendeley_integrations": "<0>Zotero</0> and <0>Mendeley</0> integrations",
|
"zotero_and_mendeley_integrations": "<0>Zotero</0> and <0>Mendeley</0> integrations",
|
||||||
|
"zotero_account_linked_successfully": "Zotero account linked successfully",
|
||||||
"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_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_loading_error": "There was an error loading groups from Zotero",
|
||||||
"zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.",
|
"zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.",
|
||||||
|
"zotero_imported_by_collaborator": "This file was imported from Zotero by your collaborator. You cannot refresh it.",
|
||||||
|
"zotero_imported_by_unknown": "This file was imported from Zotero by an unknown user. You cannot refresh it.",
|
||||||
"zotero_integration": "Zotero integration",
|
"zotero_integration": "Zotero integration",
|
||||||
"zotero_is_premium": "Zotero integration is a premium feature",
|
"zotero_is_premium": "Zotero integration is a premium feature",
|
||||||
"zotero_reference_loading_error": "Error, could not load references from Zotero",
|
"zotero_reference_loading_error": "Error, could not load references from Zotero",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@
|
|||||||
"ill_take_it": "Беру!",
|
"ill_take_it": "Беру!",
|
||||||
"import_from_github": "Импорт с GitHub",
|
"import_from_github": "Импорт с GitHub",
|
||||||
"import_to_sharelatex": "Импортировать в __appName__",
|
"import_to_sharelatex": "Импортировать в __appName__",
|
||||||
|
"imported_from_zotero_at_date": "Импортировано из Zotero в __formattedDate__ __relativeDate__",
|
||||||
"importing": "Импорт",
|
"importing": "Импорт",
|
||||||
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub",
|
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub",
|
||||||
"inactive_projects": "Неактивные проекты",
|
"inactive_projects": "Неактивные проекты",
|
||||||
@@ -571,5 +572,13 @@
|
|||||||
"your_sessions": "Ваши сессии",
|
"your_sessions": "Ваши сессии",
|
||||||
"your_subscription_has_expired": "Срок Вашей подписки истёк.",
|
"your_subscription_has_expired": "Срок Вашей подписки истёк.",
|
||||||
"your_subscriptions": "Ваши подписки",
|
"your_subscriptions": "Ваши подписки",
|
||||||
"zh-CN": "Китайский"
|
"zh-CN": "Китайский",
|
||||||
|
"zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.",
|
||||||
|
"zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка",
|
||||||
|
"zotero_imported_by_collaborator": "Этот файл был импортирован из Zotero вашим соавтором. Вы не можете обновить файл.",
|
||||||
|
"zotero_imported_by_unknown": "Этот файл был импортирован из Zotero неизвестным пользователем. Вы не можете обновить файл.",
|
||||||
|
"zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero",
|
||||||
|
"zotero_reference_loading_error_expired": "Срок действия токена Zotero истёк, пожалуйста, повторно подключите аккаунт",
|
||||||
|
"zotero_reference_loading_error_forbidden": "Не удалось загрузить ссылки из Zotero, пожалуйста, повторно подключите аккаунт и попробуйте снова",
|
||||||
|
"zotero_sync_description": "Используя интеграцию с Zotero вы можете импортировать свои ссылки из Zotero в проекты __appName__."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import Path from 'node:path'
|
||||||
|
|
||||||
|
const CIPHER_KEY_FILE = '/var/lib/overleaf/data/.zotero-cipher-key'
|
||||||
|
const CIPHER_LABEL = '2024.1-v3'
|
||||||
|
|
||||||
|
let encryptorInstance = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a stable cipher password that persists across container
|
||||||
|
* recreations. Priority:
|
||||||
|
* 1. ZOTERO_CIPHER_PASSWORD env var (explicit user config)
|
||||||
|
* 2. Key file in the persistent volume (/var/lib/overleaf/data/)
|
||||||
|
* — auto-generated on first use, survives container rebuilds
|
||||||
|
*/
|
||||||
|
function _getStableCipherPassword() {
|
||||||
|
if (process.env.ZOTERO_CIPHER_PASSWORD) {
|
||||||
|
return process.env.ZOTERO_CIPHER_PASSWORD
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing = fs.readFileSync(CIPHER_KEY_FILE, 'utf8').trim()
|
||||||
|
if (existing.length >= 16) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist yet — generate one
|
||||||
|
}
|
||||||
|
const newKey = crypto.randomBytes(32).toString('base64')
|
||||||
|
const dir = Path.dirname(CIPHER_KEY_FILE)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
fs.writeFileSync(CIPHER_KEY_FILE, newKey, { mode: 0o600 })
|
||||||
|
return newKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getEncryptor() {
|
||||||
|
if (!encryptorInstance) {
|
||||||
|
const encryptorSettings = Settings.zotero?.encryptor || Settings.oauthProviders?.encryptor
|
||||||
|
if (!encryptorSettings) {
|
||||||
|
const cipherLabel = process.env.ZOTERO_CIPHER_LABEL || CIPHER_LABEL
|
||||||
|
const cipherPassword = _getStableCipherPassword()
|
||||||
|
encryptorInstance = new AccessTokenEncryptorClass({
|
||||||
|
cipherLabel,
|
||||||
|
cipherPasswords: {
|
||||||
|
[cipherLabel]: cipherPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
encryptorInstance = new AccessTokenEncryptorClass(encryptorSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return encryptorInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessTokenEncryptor = {
|
||||||
|
promises: {
|
||||||
|
async encryptJson(json) {
|
||||||
|
return await _getEncryptor().promises.encryptJson(json)
|
||||||
|
},
|
||||||
|
async decryptToJson(encryptedJson) {
|
||||||
|
return await _getEncryptor().promises.decryptToJson(encryptedJson)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
208
services/web/modules/zotero/app/src/ZoteroApiClient.mjs
Normal file
208
services/web/modules/zotero/app/src/ZoteroApiClient.mjs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import OError from '@overleaf/o-error'
|
||||||
|
import { fetchJson, fetchString } from '@overleaf/fetch-utils'
|
||||||
|
import { User } from '../../../../app/src/models/User.mjs'
|
||||||
|
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
|
||||||
|
|
||||||
|
const ZOTERO_API_URL = 'https://api.zotero.org'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt stored Zotero credentials from user record.
|
||||||
|
* Returns { apiKey, zoteroUserId } or null if not linked.
|
||||||
|
*/
|
||||||
|
async function _getCredentials(userId) {
|
||||||
|
const user = await User.findById(userId, 'refProviders.zotero').exec()
|
||||||
|
if (!user?.refProviders?.zotero?.apiKeyEncrypted) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decrypted = await AccessTokenEncryptor.promises.decryptToJson(
|
||||||
|
user.refProviders.zotero.apiKeyEncrypted
|
||||||
|
)
|
||||||
|
return decrypted
|
||||||
|
} catch (err) {
|
||||||
|
throw OError.tag(err, 'failed to decrypt Zotero credentials', { userId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a request to the Zotero API with the user's API key.
|
||||||
|
*/
|
||||||
|
async function _zoteroApiRequest(apiKey, path, opts = {}) {
|
||||||
|
const url = `${ZOTERO_API_URL}${path}`
|
||||||
|
const headers = {
|
||||||
|
'Zotero-API-Version': '3',
|
||||||
|
'Zotero-API-Key': apiKey,
|
||||||
|
...(opts.headers || {}),
|
||||||
|
}
|
||||||
|
return { url, headers }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of groups for a user.
|
||||||
|
*/
|
||||||
|
async function getGroupsForUser(userId) {
|
||||||
|
const credentials = await _getCredentials(userId)
|
||||||
|
if (!credentials) {
|
||||||
|
throw new ZoteroAccountNotLinkedError()
|
||||||
|
}
|
||||||
|
const { apiKey, zoteroUserId } = credentials
|
||||||
|
const { url, headers } = await _zoteroApiRequest(
|
||||||
|
apiKey,
|
||||||
|
`/users/${zoteroUserId}/groups`
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const groups = await fetchJson(url, { headers })
|
||||||
|
return groups.map(g => ({
|
||||||
|
id: String(g.id),
|
||||||
|
name: g.data?.name || `Group ${g.id}`,
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
logger.err({ err, userId }, 'error fetching Zotero groups')
|
||||||
|
if (err.response?.status === 403) {
|
||||||
|
throw new ZoteroForbiddenError('forbidden')
|
||||||
|
}
|
||||||
|
throw OError.tag(err, 'error fetching Zotero groups')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export the user's entire library as BibTeX.
|
||||||
|
*/
|
||||||
|
async function getUserLibraryBibtex(userId) {
|
||||||
|
const credentials = await _getCredentials(userId)
|
||||||
|
if (!credentials) {
|
||||||
|
throw new ZoteroAccountNotLinkedError()
|
||||||
|
}
|
||||||
|
return _fetchBibtex(
|
||||||
|
credentials.apiKey,
|
||||||
|
`/users/${credentials.zoteroUserId}/items`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a group library as BibTeX.
|
||||||
|
*/
|
||||||
|
async function getGroupLibraryBibtex(userId, groupId) {
|
||||||
|
const credentials = await _getCredentials(userId)
|
||||||
|
if (!credentials) {
|
||||||
|
throw new ZoteroAccountNotLinkedError()
|
||||||
|
}
|
||||||
|
return _fetchBibtex(credentials.apiKey, `/groups/${groupId}/items`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all items from a Zotero library endpoint as BibTeX.
|
||||||
|
* Handles pagination (Zotero API limits to 100 items per request).
|
||||||
|
*/
|
||||||
|
async function _fetchBibtex(apiKey, basePath) {
|
||||||
|
let allBibtex = ''
|
||||||
|
let start = 0
|
||||||
|
const limit = 100
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { url, headers } = await _zoteroApiRequest(apiKey, basePath, {
|
||||||
|
headers: {},
|
||||||
|
})
|
||||||
|
const fullUrl = `${url}?format=bibtex&limit=${limit}&start=${start}`
|
||||||
|
try {
|
||||||
|
const response = await fetch(fullUrl, { headers })
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new ZoteroForbiddenError('Zotero API returned 403')
|
||||||
|
}
|
||||||
|
throw new Error(`Zotero API returned ${response.status}`)
|
||||||
|
}
|
||||||
|
const bibtex = await response.text()
|
||||||
|
if (bibtex.trim()) {
|
||||||
|
allBibtex += bibtex + '\n'
|
||||||
|
}
|
||||||
|
const totalResults = parseInt(
|
||||||
|
response.headers.get('Total-Results') || '0',
|
||||||
|
10
|
||||||
|
)
|
||||||
|
start += limit
|
||||||
|
if (start >= totalResults) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZoteroForbiddenError) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
throw OError.tag(err, 'error fetching BibTeX from Zotero', { basePath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allBibtex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a Zotero API key by calling /keys/{key}.
|
||||||
|
* Returns { zoteroUserId } on success, throws on failure.
|
||||||
|
*/
|
||||||
|
async function validateApiKey(apiKey) {
|
||||||
|
const url = `${ZOTERO_API_URL}/keys/${encodeURIComponent(apiKey)}`
|
||||||
|
try {
|
||||||
|
const data = await fetchJson(url, {
|
||||||
|
headers: { 'Zotero-API-Version': '3' },
|
||||||
|
})
|
||||||
|
if (!data.userID) {
|
||||||
|
throw new Error('Zotero API key response missing userID')
|
||||||
|
}
|
||||||
|
return { zoteroUserId: String(data.userID) }
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 404 || err.response?.status === 403) {
|
||||||
|
throw new ZoteroForbiddenError('Invalid Zotero API key')
|
||||||
|
}
|
||||||
|
throw OError.tag(err, 'error validating Zotero API key')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a Zotero account (store encrypted credentials).
|
||||||
|
*/
|
||||||
|
async function storeCredentials(userId, apiKey, zoteroUserId) {
|
||||||
|
const apiKeyEncrypted = await AccessTokenEncryptor.promises.encryptJson({
|
||||||
|
apiKey,
|
||||||
|
zoteroUserId: String(zoteroUserId),
|
||||||
|
})
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: userId },
|
||||||
|
{ $set: { 'refProviders.zotero': { apiKeyEncrypted } } }
|
||||||
|
).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a Zotero account.
|
||||||
|
*/
|
||||||
|
async function unlinkAccount(userId) {
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: userId },
|
||||||
|
{ $unset: { 'refProviders.zotero': 1 } }
|
||||||
|
).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZoteroForbiddenError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ZoteroForbiddenError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZoteroAccountNotLinkedError extends Error {
|
||||||
|
constructor(message = 'Zotero account not linked') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ZoteroAccountNotLinkedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getGroupsForUser,
|
||||||
|
getUserLibraryBibtex,
|
||||||
|
getGroupLibraryBibtex,
|
||||||
|
validateApiKey,
|
||||||
|
storeCredentials,
|
||||||
|
unlinkAccount,
|
||||||
|
ZoteroForbiddenError,
|
||||||
|
ZoteroAccountNotLinkedError,
|
||||||
|
}
|
||||||
86
services/web/modules/zotero/app/src/ZoteroController.mjs
Normal file
86
services/web/modules/zotero/app/src/ZoteroController.mjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||||
|
import ZoteroApiClient from './ZoteroApiClient.mjs'
|
||||||
|
import { ZoteroForbiddenError } from './ZoteroApiClient.mjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /zotero/groups
|
||||||
|
* Returns the user's Zotero groups (for the create-file modal).
|
||||||
|
*/
|
||||||
|
async function getGroups(req, res) {
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
try {
|
||||||
|
const groups = await ZoteroApiClient.getGroupsForUser(userId)
|
||||||
|
res.json({ groups })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZoteroForbiddenError) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'forbidden',
|
||||||
|
message: 'zotero_groups_relink',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.err({ err, userId }, 'error fetching Zotero groups')
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'internal',
|
||||||
|
message: 'zotero_groups_loading_error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /zotero/link
|
||||||
|
* Links a Zotero account by validating the user-provided API key
|
||||||
|
* and storing the encrypted credentials.
|
||||||
|
*
|
||||||
|
* Users create their API key at https://www.zotero.org/settings/keys
|
||||||
|
* with "Allow library access" and "Allow read access to all groups".
|
||||||
|
*/
|
||||||
|
async function link(req, res) {
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
const { apiKey } = req.body
|
||||||
|
|
||||||
|
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'missing_api_key' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { zoteroUserId } = await ZoteroApiClient.validateApiKey(
|
||||||
|
apiKey.trim()
|
||||||
|
)
|
||||||
|
await ZoteroApiClient.storeCredentials(userId, apiKey.trim(), zoteroUserId)
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZoteroForbiddenError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'invalid_api_key',
|
||||||
|
message: 'zotero_api_key_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.err({ err, userId }, 'error linking Zotero account')
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'internal',
|
||||||
|
message: 'generic_something_went_wrong',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /zotero/unlink
|
||||||
|
* Unlinks the user's Zotero account.
|
||||||
|
*/
|
||||||
|
async function unlink(req, res) {
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
try {
|
||||||
|
await ZoteroApiClient.unlinkAccount(userId)
|
||||||
|
res.sendStatus(200)
|
||||||
|
} catch (err) {
|
||||||
|
logger.err({ err, userId }, 'error unlinking Zotero')
|
||||||
|
res.sendStatus(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getGroups,
|
||||||
|
link,
|
||||||
|
unlink,
|
||||||
|
}
|
||||||
131
services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs
Normal file
131
services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import { callbackify } from '@overleaf/promise-utils'
|
||||||
|
import LinkedFilesHandler from '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler.mjs'
|
||||||
|
import ZoteroApiClient from './ZoteroApiClient.mjs'
|
||||||
|
import { ZoteroForbiddenError, ZoteroAccountNotLinkedError } from './ZoteroApiClient.mjs'
|
||||||
|
import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs'
|
||||||
|
import { Project } from '../../../../app/src/models/Project.mjs'
|
||||||
|
|
||||||
|
const {
|
||||||
|
FeatureNotAvailableError,
|
||||||
|
AccessDeniedError,
|
||||||
|
RemoteServiceError,
|
||||||
|
NotOriginalImporterError,
|
||||||
|
} = LinkedFilesErrors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a linked .bib file from Zotero (either My Library or a Group Library).
|
||||||
|
*
|
||||||
|
* linkedFileData shape:
|
||||||
|
* { provider: 'zotero', zoteroGroupId?: string, importedAt: string }
|
||||||
|
*
|
||||||
|
* - If zoteroGroupId is present, export that group's library.
|
||||||
|
* - Otherwise, export the user's personal library ("My Library").
|
||||||
|
*/
|
||||||
|
async function createLinkedFile(
|
||||||
|
projectId,
|
||||||
|
linkedFileData,
|
||||||
|
name,
|
||||||
|
parentFolderId,
|
||||||
|
userId
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
{ projectId, userId, groupId: linkedFileData.zoteroGroupId },
|
||||||
|
'creating Zotero linked file'
|
||||||
|
)
|
||||||
|
|
||||||
|
linkedFileData.importedByUserId = userId
|
||||||
|
|
||||||
|
const bibtex = await _getBibtex(userId, linkedFileData)
|
||||||
|
|
||||||
|
const file = await LinkedFilesHandler.promises.importContent(
|
||||||
|
projectId,
|
||||||
|
bibtex,
|
||||||
|
_sanitizeData(linkedFileData),
|
||||||
|
name,
|
||||||
|
parentFolderId,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
return file._id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh an existing Zotero linked .bib file.
|
||||||
|
*/
|
||||||
|
async function refreshLinkedFile(
|
||||||
|
projectId,
|
||||||
|
linkedFileData,
|
||||||
|
name,
|
||||||
|
parentFolderId,
|
||||||
|
userId
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
{ projectId, userId, groupId: linkedFileData.zoteroGroupId },
|
||||||
|
'refreshing Zotero linked file'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (linkedFileData.importedByUserId) {
|
||||||
|
if (linkedFileData.importedByUserId !== userId) {
|
||||||
|
throw new NotOriginalImporterError(
|
||||||
|
'Only the user who created the Zotero-linked file can refresh this file'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No owner metadata (legacy files). Only project owner may refresh.
|
||||||
|
const project = await Project.findById(projectId, 'owner_ref').lean()
|
||||||
|
if (!project || String(project.owner_ref) !== String(userId)) {
|
||||||
|
throw new NotOriginalImporterError(
|
||||||
|
'Only the user who created the Zotero-linked file can refresh this file'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
linkedFileData.importedByUserId = userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const bibtex = await _getBibtex(userId, linkedFileData)
|
||||||
|
|
||||||
|
const file = await LinkedFilesHandler.promises.importContent(
|
||||||
|
projectId,
|
||||||
|
bibtex,
|
||||||
|
_sanitizeData(linkedFileData),
|
||||||
|
name,
|
||||||
|
parentFolderId,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
return file._id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getBibtex(userId, linkedFileData) {
|
||||||
|
try {
|
||||||
|
if (linkedFileData.zoteroGroupId) {
|
||||||
|
return await ZoteroApiClient.getGroupLibraryBibtex(
|
||||||
|
userId,
|
||||||
|
linkedFileData.zoteroGroupId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return await ZoteroApiClient.getUserLibraryBibtex(userId)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZoteroForbiddenError) {
|
||||||
|
throw new AccessDeniedError('Zotero access denied').withCause(err)
|
||||||
|
}
|
||||||
|
if (err instanceof ZoteroAccountNotLinkedError) {
|
||||||
|
throw new AccessDeniedError('Zotero account not linked').withCause(err)
|
||||||
|
}
|
||||||
|
throw new RemoteServiceError('Zotero API error').withCause(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sanitizeData(data) {
|
||||||
|
return {
|
||||||
|
provider: 'zotero',
|
||||||
|
zoteroGroupId: data.zoteroGroupId || undefined,
|
||||||
|
importedAt: data.importedAt,
|
||||||
|
importedByUserId: data.importedByUserId || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createLinkedFile: callbackify(createLinkedFile),
|
||||||
|
refreshLinkedFile: callbackify(refreshLinkedFile),
|
||||||
|
promises: { createLinkedFile, refreshLinkedFile },
|
||||||
|
}
|
||||||
27
services/web/modules/zotero/app/src/ZoteroRouter.mjs
Normal file
27
services/web/modules/zotero/app/src/ZoteroRouter.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||||
|
import ZoteroController from './ZoteroController.mjs'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
apply(webRouter) {
|
||||||
|
// Get Zotero groups for the create-file modal
|
||||||
|
webRouter.get(
|
||||||
|
'/zotero/groups',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
ZoteroController.getGroups
|
||||||
|
)
|
||||||
|
|
||||||
|
// Link Zotero account (a user submits an API key)
|
||||||
|
webRouter.post(
|
||||||
|
'/zotero/link',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
ZoteroController.link
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unlink Zotero account
|
||||||
|
webRouter.post(
|
||||||
|
'/zotero/unlink',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
ZoteroController.unlink
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { formatTime, relativeDate } from '@/features/utils/format-date'
|
||||||
|
import { LinkedFileIcon } from '@/features/file-view/components/file-view-icons'
|
||||||
|
import type { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||||
|
|
||||||
|
type TPRFileViewInfoProps = {
|
||||||
|
file: BinaryFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows "Imported from Zotero at <date>" in the file view header
|
||||||
|
* when viewing a Zotero-linked .bib file.
|
||||||
|
* Registered via overleafModuleImports.tprFileViewInfo.
|
||||||
|
*/
|
||||||
|
export function TPRFileViewInfo({ file }: TPRFileViewInfoProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (file.linkedFileData?.provider !== 'zotero') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedAt = (file.linkedFileData as any)?.importedAt || file.created
|
||||||
|
const formattedDate = formatTime(importedAt)
|
||||||
|
const relative = relativeDate(importedAt)
|
||||||
|
|
||||||
|
const groupId = (file.linkedFileData as any)?.zoteroGroupId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<LinkedFileIcon />
|
||||||
|
|
||||||
|
{t('imported_from_zotero_at_date', {
|
||||||
|
formattedDate,
|
||||||
|
relativeDate: relative,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{groupId && (
|
||||||
|
<span className="text-muted small"> (Group: {groupId})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||||
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
|
||||||
|
type TPRFileViewNotOriginalImporterProps = {
|
||||||
|
file: BinaryFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a warning if the current user is not the original importer of this Zotero file.
|
||||||
|
* Registered via overleafModuleImports.tprFileViewNotOriginalImporter.
|
||||||
|
*/
|
||||||
|
export function TPRFileViewNotOriginalImporter({
|
||||||
|
file,
|
||||||
|
}: TPRFileViewNotOriginalImporterProps) {
|
||||||
|
const provider = file.linkedFileData?.provider
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (provider !== 'zotero') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId =
|
||||||
|
(getMeta('ol-user') as any)?._id || (getMeta('ol-user_id') as string)
|
||||||
|
const importedByUserId = (file.linkedFileData as any)?.importedByUserId
|
||||||
|
|
||||||
|
if (provider !== 'zotero') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!importedByUserId) {
|
||||||
|
return (
|
||||||
|
<div className="file-view-error">
|
||||||
|
<OLNotification
|
||||||
|
type="warning"
|
||||||
|
content={t('zotero_imported_by_unknown')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// TODO: fetch the collaborator's name and show it
|
||||||
|
if (currentUserId && importedByUserId !== currentUserId) {
|
||||||
|
return (
|
||||||
|
<div className="file-view-error">
|
||||||
|
<OLNotification
|
||||||
|
type="warning"
|
||||||
|
content={t('zotero_imported_by_collaborator')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
|
import type { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
|
||||||
|
type TPRFileViewRefreshButtonProps = {
|
||||||
|
file: BinaryFile
|
||||||
|
refreshFile: (isTPR: boolean | null) => void
|
||||||
|
refreshing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zotero-specific refresh button for the file view.
|
||||||
|
* Tells the system this is a TPR file so references are re-indexed.
|
||||||
|
* Registered via overleafModuleImports.tprFileViewRefreshButton.
|
||||||
|
*/
|
||||||
|
export function TPRFileViewRefreshButton({
|
||||||
|
file,
|
||||||
|
refreshFile,
|
||||||
|
refreshing,
|
||||||
|
}: TPRFileViewRefreshButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const provider = (file.linkedFileData as Record<string, unknown>)?.provider
|
||||||
|
const currentUserId =
|
||||||
|
(getMeta('ol-user') as any)?._id || (getMeta('ol-user_id') as string)
|
||||||
|
const importedByUserId = (file.linkedFileData as any)?.importedByUserId
|
||||||
|
const isOriginalImporter =
|
||||||
|
provider === 'zotero' &&
|
||||||
|
currentUserId &&
|
||||||
|
importedByUserId === currentUserId
|
||||||
|
|
||||||
|
if (provider !== 'zotero' || isOriginalImporter) {
|
||||||
|
// Zotero or default refresh for originator only
|
||||||
|
return (
|
||||||
|
<OLButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => refreshFile(provider === 'zotero' ? true : null)}
|
||||||
|
disabled={refreshing}
|
||||||
|
isLoading={refreshing}
|
||||||
|
loadingLabel={t('refreshing')}
|
||||||
|
>
|
||||||
|
{t('refresh')}
|
||||||
|
</OLButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collaborator of Zotero file should not refresh
|
||||||
|
return (
|
||||||
|
<OLButton
|
||||||
|
variant="primary"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{t('refresh')}
|
||||||
|
</OLButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
import type { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||||
|
|
||||||
|
type TPRFileViewRefreshErrorProps = {
|
||||||
|
file: BinaryFile
|
||||||
|
refreshError: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zotero-specific error messages when refreshing a linked file fails.
|
||||||
|
* Registered via overleafModuleImports.tprFileViewRefreshError.
|
||||||
|
*/
|
||||||
|
export function TPRFileViewRefreshError({
|
||||||
|
file,
|
||||||
|
refreshError,
|
||||||
|
}: TPRFileViewRefreshErrorProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (file.linkedFileData?.provider !== 'zotero') {
|
||||||
|
// Not a Zotero file — fall back to default error display
|
||||||
|
return (
|
||||||
|
<div className="file-view-error">
|
||||||
|
<OLNotification type="error" content={refreshError} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = refreshError
|
||||||
|
if (refreshError === 'forbidden' || refreshError?.includes('403')) {
|
||||||
|
message = t('zotero_reference_loading_error_forbidden')
|
||||||
|
} else if (
|
||||||
|
refreshError === 'expired' ||
|
||||||
|
refreshError?.includes('token expired')
|
||||||
|
) {
|
||||||
|
message = t('zotero_reference_loading_error_expired')
|
||||||
|
} else if (!message) {
|
||||||
|
message = t('zotero_reference_loading_error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-view-error">
|
||||||
|
<OLNotification type="error" content={message} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { FormEventHandler, useEffect, useState, useCallback } from 'react'
|
||||||
|
import { getJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
|
||||||
|
import { useFileTreeCreateForm } from '@/features/file-tree/contexts/file-tree-create-form'
|
||||||
|
import { useFileTreeMainContext } from '@/features/file-tree/contexts/file-tree-main'
|
||||||
|
import FileTreeModalCreateFileMode from '@/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode'
|
||||||
|
import ErrorMessage from '@/features/file-tree/components/file-tree-create/error-message'
|
||||||
|
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||||
|
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||||
|
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||||
|
import OLFormSelect from '@/shared/components/ol/ol-form-select'
|
||||||
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
|
||||||
|
type ZoteroGroup = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateFileMode() {
|
||||||
|
const { refProviders } = useFileTreeMainContext()
|
||||||
|
const isLinked = (refProviders as Record<string, boolean>)?.zotero
|
||||||
|
|
||||||
|
if (!isLinked) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileTreeModalCreateFileMode
|
||||||
|
mode="zotero"
|
||||||
|
icon="library_books"
|
||||||
|
label="From Zotero"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateFilePane() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { setValid } = useFileTreeCreateForm()
|
||||||
|
const { newFileCreateMode, finishCreatingLinkedFile, error, inFlight } =
|
||||||
|
useFileTreeActionable()
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState<ZoteroGroup[]>([])
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string>('')
|
||||||
|
const [name, setName] = useState('zotero.bib')
|
||||||
|
const [loadingGroups, setLoadingGroups] = useState(true)
|
||||||
|
const [groupsError, setGroupsError] = useState('')
|
||||||
|
|
||||||
|
// form validation
|
||||||
|
useEffect(() => {
|
||||||
|
setValid(!!name && !loadingGroups)
|
||||||
|
}, [setValid, name, loadingGroups])
|
||||||
|
|
||||||
|
// load groups when the mode is active
|
||||||
|
useEffect(() => {
|
||||||
|
if (newFileCreateMode !== 'zotero') return
|
||||||
|
|
||||||
|
setLoadingGroups(true)
|
||||||
|
setGroupsError('')
|
||||||
|
getJSON('/zotero/groups')
|
||||||
|
.then((data: { groups: ZoteroGroup[] }) => {
|
||||||
|
setGroups(data.groups || [])
|
||||||
|
setLoadingGroups(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setGroupsError(t('zotero_groups_loading_error'))
|
||||||
|
setLoadingGroups(false)
|
||||||
|
})
|
||||||
|
}, [newFileCreateMode, t])
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler = useCallback(
|
||||||
|
event => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const data: Record<string, string> = {}
|
||||||
|
if (selectedGroupId) {
|
||||||
|
data.zoteroGroupId = selectedGroupId
|
||||||
|
}
|
||||||
|
|
||||||
|
finishCreatingLinkedFile({
|
||||||
|
name,
|
||||||
|
provider: 'zotero',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[name, selectedGroupId, finishCreatingLinkedFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (newFileCreateMode !== 'zotero') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="form-controls"
|
||||||
|
id="create-file"
|
||||||
|
noValidate
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{groupsError && (
|
||||||
|
<OLNotification type="error" content={groupsError} className="mb-3" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OLFormGroup controlId="zotero-file-name">
|
||||||
|
<OLFormLabel>{t('file_name')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
type="text"
|
||||||
|
placeholder="zotero.bib"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
disabled={inFlight}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setName(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="zotero-library-select">
|
||||||
|
<OLFormLabel>Library</OLFormLabel>
|
||||||
|
{loadingGroups ? (
|
||||||
|
<p className="text-muted">{t('loading')}...</p>
|
||||||
|
) : (
|
||||||
|
<OLFormSelect
|
||||||
|
id="zotero-library-select"
|
||||||
|
value={selectedGroupId}
|
||||||
|
disabled={inFlight}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
setSelectedGroupId(e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">My Library</option>
|
||||||
|
{groups.map(g => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</OLFormSelect>
|
||||||
|
)}
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
{error && <ErrorMessage error={error} />}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { postJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||||
|
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)
|
||||||
|
*
|
||||||
|
* Registered via overleafModuleImports.referenceLinkingWidgets.
|
||||||
|
*/
|
||||||
|
export default function ZoteroWidget() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const user = getMeta('ol-user')
|
||||||
|
const refProviders = user?.refProviders || {}
|
||||||
|
const [isLinked, setIsLinked] = useState(Boolean(refProviders.zotero))
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
|
const handleLink = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!apiKey) return
|
||||||
|
setProcessing(true)
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
try {
|
||||||
|
await postJSON('/zotero/link', { body: { apiKey } })
|
||||||
|
setSuccess(t('zotero_account_linked_successfully'))
|
||||||
|
setApiKey('')
|
||||||
|
setProcessing(false)
|
||||||
|
setIsLinked(true)
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg =
|
||||||
|
err?.data?.error === 'invalid_api_key'
|
||||||
|
? 'Invalid API key. Please check your key and try again.'
|
||||||
|
: t('generic_something_went_wrong')
|
||||||
|
setError(msg)
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiKey, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleUnlink = useCallback(async () => {
|
||||||
|
setProcessing(true)
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
try {
|
||||||
|
await postJSON('/zotero/unlink')
|
||||||
|
setProcessing(false)
|
||||||
|
setIsLinked(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(t('generic_something_went_wrong'))
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-widget-container">
|
||||||
|
<div>
|
||||||
|
<ZoteroLogo />
|
||||||
|
</div>
|
||||||
|
<div className="description-container">
|
||||||
|
<div className="title-row">
|
||||||
|
<h4>{t('zotero')}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="small">
|
||||||
|
{t('zotero_sync_description', {
|
||||||
|
appName:
|
||||||
|
getMeta('ol-ExposedSettings')?.appName || 'Overleaf',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{error && <OLNotification type="error" content={error} />}
|
||||||
|
{success && <OLNotification type="success" content={success} />}
|
||||||
|
{!isLinked && (
|
||||||
|
<form onSubmit={handleLink}>
|
||||||
|
<p className="small text-muted">
|
||||||
|
Create an API key at{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.zotero.org/settings/keys/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
zotero.org/settings/keys
|
||||||
|
</a>{' '}
|
||||||
|
with <strong>Allow library access</strong> and{' '}
|
||||||
|
<strong>Allow read access to all groups</strong> enabled.
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<OLFormControl
|
||||||
|
type="text"
|
||||||
|
placeholder="Paste your Zotero API key"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setApiKey(e.target.value.trim())
|
||||||
|
}
|
||||||
|
disabled={processing}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<OLButton
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!apiKey}
|
||||||
|
isLoading={processing}
|
||||||
|
>
|
||||||
|
{t('link_to_zotero')}
|
||||||
|
</OLButton>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isLinked && (
|
||||||
|
<OLButton
|
||||||
|
variant="danger-ghost"
|
||||||
|
onClick={handleUnlink}
|
||||||
|
isLoading={processing}
|
||||||
|
>
|
||||||
|
{t('unlink')}
|
||||||
|
</OLButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
services/web/modules/zotero/index.mjs
Normal file
19
services/web/modules/zotero/index.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import ZoteroRouter from './app/src/ZoteroRouter.mjs'
|
||||||
|
|
||||||
|
let ZoteroModule = {}
|
||||||
|
|
||||||
|
if (Settings.enabledLinkedFileTypes?.includes('zotero')) {
|
||||||
|
const { default: ZoteroLinkedFileAgent } = await import(
|
||||||
|
'./app/src/ZoteroLinkedFileAgent.mjs'
|
||||||
|
)
|
||||||
|
|
||||||
|
ZoteroModule = {
|
||||||
|
router: ZoteroRouter,
|
||||||
|
linkedFileAgents: {
|
||||||
|
zotero: () => ZoteroLinkedFileAgent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZoteroModule
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, vi } from 'vitest'
|
import { expect, vi } from 'vitest'
|
||||||
import sinon from 'sinon'
|
import sinon from 'sinon'
|
||||||
|
import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs'
|
||||||
const modulePath =
|
const modulePath =
|
||||||
'../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs'
|
'../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs'
|
||||||
|
|
||||||
@@ -224,6 +225,37 @@ describe('LinkedFilesController', function () {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns error 400 if user is not original importer', async function (ctx) {
|
||||||
|
ctx.Agent.promises.refreshLinkedFile = sinon
|
||||||
|
.stub()
|
||||||
|
.rejects(
|
||||||
|
new LinkedFilesErrors.NotOriginalImporterError(
|
||||||
|
'Only the user who created the Zotero-linked file can refresh this file'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await new Promise(resolve => {
|
||||||
|
ctx.next = sinon.stub().callsFake(() => resolve('unexpected error'))
|
||||||
|
ctx.res = {
|
||||||
|
status: sinon.stub().callsFake(code => {
|
||||||
|
expect(code).to.equal(400)
|
||||||
|
return ctx.res
|
||||||
|
}),
|
||||||
|
contentType: sinon.stub(),
|
||||||
|
setHeader: sinon.stub(),
|
||||||
|
send: sinon.stub(msg => {
|
||||||
|
expect(msg).to.equal('You are not the user who originally imported this file')
|
||||||
|
resolve()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.LinkedFilesController.refreshLinkedFile(ctx.req, ctx.res, ctx.next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ctx.Agent.promises.refreshLinkedFile).to.not.have.been.called
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user