mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Zotero: improve integration and reference import
- use OAuth instead of tokens - add an integration card - improve error handling - add BibLaTeX format support - fix indexing of linked .bib files on creation
This commit is contained in:
@@ -368,7 +368,23 @@ export default LinkedFilesController = {
|
|||||||
error.message === 'Zotero account not linked'
|
error.message === 'Zotero account not linked'
|
||||||
) {
|
) {
|
||||||
res.status(400)
|
res.status(400)
|
||||||
plainTextResponse(res, 'Your account is not linked with Zotero')
|
plainTextResponse(
|
||||||
|
res,
|
||||||
|
res.locals.translate(
|
||||||
|
'zotero_reference_loading_error_not_linked'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (
|
||||||
|
error instanceof AccessDeniedError &&
|
||||||
|
error.message === 'Zotero access denied'
|
||||||
|
) {
|
||||||
|
res.status(403)
|
||||||
|
plainTextResponse(
|
||||||
|
res,
|
||||||
|
res.locals.translate(
|
||||||
|
'zotero_reference_loading_error_forbidden'
|
||||||
|
)
|
||||||
|
)
|
||||||
} else if (error instanceof AccessDeniedError) {
|
} else if (error instanceof AccessDeniedError) {
|
||||||
res.status(403)
|
res.status(403)
|
||||||
plainTextResponse(
|
plainTextResponse(
|
||||||
|
|||||||
@@ -1163,6 +1163,10 @@ module.exports = {
|
|||||||
__dirname,
|
__dirname,
|
||||||
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
|
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
|
||||||
),
|
),
|
||||||
|
Path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../modules/zotero/frontend/js/components/zotero-integration-card.tsx'
|
||||||
|
),
|
||||||
],
|
],
|
||||||
referenceSearchSetting: [],
|
referenceSearchSetting: [],
|
||||||
settingsModalEditorTabSections: [],
|
settingsModalEditorTabSections: [],
|
||||||
|
|||||||
@@ -1646,6 +1646,7 @@
|
|||||||
"project_actions": "",
|
"project_actions": "",
|
||||||
"project_approaching_file_limit": "",
|
"project_approaching_file_limit": "",
|
||||||
"project_categories_tags": "",
|
"project_categories_tags": "",
|
||||||
|
"problem_checking_connection_with_provider": "",
|
||||||
"project_figure_modal": "",
|
"project_figure_modal": "",
|
||||||
"project_files_history": "",
|
"project_files_history": "",
|
||||||
"project_flagged_too_many_compiles": "",
|
"project_flagged_too_many_compiles": "",
|
||||||
|
|||||||
@@ -487,7 +487,13 @@ export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
|
|||||||
|
|
||||||
const finishCreatingLinkedFile = useCallback(
|
const finishCreatingLinkedFile = useCallback(
|
||||||
(entity: Omit<NewLinkedFileEntity, 'endpoint'>) => {
|
(entity: Omit<NewLinkedFileEntity, 'endpoint'>) => {
|
||||||
return finishCreatingDocOrFile({ ...entity, endpoint: 'linked_file' })
|
const shouldReindexReferences = /\.bib$/.test(entity.name)
|
||||||
|
return finishCreatingDocOrFile({ ...entity, endpoint: 'linked_file' }).
|
||||||
|
then(() => {
|
||||||
|
if (shouldReindexReferences) {
|
||||||
|
indexAllReferences(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[finishCreatingDocOrFile]
|
[finishCreatingDocOrFile]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export type LinkedFileData = {
|
|||||||
provider: 'zotero'
|
provider: 'zotero'
|
||||||
zoteroGroupId?: string
|
zoteroGroupId?: string
|
||||||
importedAt: Date | string
|
importedAt: Date | string
|
||||||
importedByUserId?: string
|
importedByUserId: string
|
||||||
importedByName?: string
|
importedByName: string
|
||||||
|
bibFormat: 'bibtex' | 'biblatex'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,7 @@
|
|||||||
"choose_a_custom_color": "Wähle eine eigene Farbe",
|
"choose_a_custom_color": "Wähle eine eigene Farbe",
|
||||||
"choose_your_plan": "Wähle deinen Kontotyp",
|
"choose_your_plan": "Wähle deinen Kontotyp",
|
||||||
"citation": "Zitat",
|
"citation": "Zitat",
|
||||||
|
"cite_directly_or_import_references": "Referenzen importieren",
|
||||||
"city": "Stadt",
|
"city": "Stadt",
|
||||||
"clear_cached_files": "Zwischengespeicherte Dateien löschen",
|
"clear_cached_files": "Zwischengespeicherte Dateien löschen",
|
||||||
"clear_search": "Suche löschen",
|
"clear_search": "Suche löschen",
|
||||||
@@ -1312,6 +1313,7 @@
|
|||||||
"private": "Privat",
|
"private": "Privat",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"problem_changing_email_address": "Es gab ein Problem beim Ändern deiner E-Mail-Adresse. Bitte versuche es in ein paar Minuten erneut. Wenn das Problem bestehen bleibt, kontaktiere uns bitte.",
|
"problem_changing_email_address": "Es gab ein Problem beim Ändern deiner E-Mail-Adresse. Bitte versuche es in ein paar Minuten erneut. Wenn das Problem bestehen bleibt, kontaktiere uns bitte.",
|
||||||
|
"problem_checking_connection_with_provider": "Beim Überprüfen der Verbindung mit dem __provider__-Dienst ist ein Problem aufgetreten. Bitte versuchen Sie es in einigen Augenblicken erneut.",
|
||||||
"problem_talking_to_publishing_service": "Es gibt ein Problem mit unserem Veröffentlichungsservice. Bitte versuche es in einigen Minuten noch einmal",
|
"problem_talking_to_publishing_service": "Es gibt ein Problem mit unserem Veröffentlichungsservice. Bitte versuche es in einigen Minuten noch einmal",
|
||||||
"problem_with_subscription_contact_us": "Es gibt ein Problem mit deinem Abonnement. Bitte kontaktiere uns für mehr Informationen.",
|
"problem_with_subscription_contact_us": "Es gibt ein Problem mit deinem Abonnement. Bitte kontaktiere uns für mehr Informationen.",
|
||||||
"processing": "in Bearbeitung",
|
"processing": "in Bearbeitung",
|
||||||
@@ -1743,9 +1745,9 @@
|
|||||||
"unlink_github_warning": "Bei allen Projekten, die mit GitHub synchronisiert sind, wird die Verlinkung entfernt und nicht länger mit GitHub synchronisiert. Bist du sicher, dass du die Verbindung zu deinem GitHub-Nutzerkonto lösen möchtest?",
|
"unlink_github_warning": "Bei allen Projekten, die mit GitHub synchronisiert sind, wird die Verlinkung entfernt und nicht länger mit GitHub synchronisiert. Bist du sicher, dass du die Verbindung zu deinem GitHub-Nutzerkonto lösen möchtest?",
|
||||||
"unlink_provider_account_title": "__provider__-Konto verknüpfen",
|
"unlink_provider_account_title": "__provider__-Konto verknüpfen",
|
||||||
"unlink_provider_account_warning": "Warnung: Wenn du die Verknüpfung deines Kontos mit __provider__ aufhebst, kannst du dich nicht mehr mit __provider__ anmelden.",
|
"unlink_provider_account_warning": "Warnung: Wenn du die Verknüpfung deines Kontos mit __provider__ aufhebst, kannst du dich nicht mehr mit __provider__ anmelden.",
|
||||||
"unlink_reference": "Link zum Referenzengeber entfernen",
|
"unlink_reference": "Link zum Referenzengeber __provider__ entfernen",
|
||||||
"unlink_sso": "SSO-Verknüpfung aufheben",
|
"unlink_sso": "SSO-Verknüpfung aufheben",
|
||||||
"unlink_warning_reference": "Achtung: Wenn du dein Konto von diesem Anbieter entkoppelst, wirst du nicht in der Lage sein, Referenzen in deine Projekte zu importieren.",
|
"unlink_warning_reference": "Achtung: Wenn du dein Konto von __provider__ entkoppelst, wirst du nicht in der Lage sein, Referenzen in deine Projekte zu importieren.",
|
||||||
"unlinking": "Verknüpfung wird aufgehoben",
|
"unlinking": "Verknüpfung wird aufgehoben",
|
||||||
"unpause_subscription": "Abonnement fortsetzen",
|
"unpause_subscription": "Abonnement fortsetzen",
|
||||||
"unpausing": "Fortsetzen",
|
"unpausing": "Fortsetzen",
|
||||||
@@ -1865,6 +1867,7 @@
|
|||||||
"you_can_still_use_your_premium_features": "Du kannst deine Premiumfunktionen weiterhin nutzen, bis die Pause aktiv wird.",
|
"you_can_still_use_your_premium_features": "Du kannst deine Premiumfunktionen weiterhin nutzen, bis die Pause aktiv wird.",
|
||||||
"you_cant_add_or_change_password_due_to_sso": "Du kannst dein Passwort nicht hinzufügen oder ändern, da deine Gruppe oder Organisation <0>Single Sign-On (SSO)</0> verwendet.",
|
"you_cant_add_or_change_password_due_to_sso": "Du kannst dein Passwort nicht hinzufügen oder ändern, da deine Gruppe oder Organisation <0>Single Sign-On (SSO)</0> verwendet.",
|
||||||
"you_cant_overwrite_it": "Überschreiben ist nicht möglich.",
|
"you_cant_overwrite_it": "Überschreiben ist nicht möglich.",
|
||||||
|
"you_currently_have_x_linked_with_your_overleaf_account": "Sie haben derzeit <0>__managers__</0> mit Ihrem __appName__ Konto verknüpft.",
|
||||||
"you_dont_have_any_repositories": "Du hast keine Repositories",
|
"you_dont_have_any_repositories": "Du hast keine Repositories",
|
||||||
"you_have_added_x_of_group_size_y": "Du hast <0>__addedUsersSize__</0> von <1>__groupSize__</1> verfügbaren Mitgliedern hinzugefügt",
|
"you_have_added_x_of_group_size_y": "Du hast <0>__addedUsersSize__</0> von <1>__groupSize__</1> verfügbaren Mitgliedern hinzugefügt",
|
||||||
"you_unpaused_your_subscription": "Du hast dein Abonnement fortgesetzt.",
|
"you_unpaused_your_subscription": "Du hast dein Abonnement fortgesetzt.",
|
||||||
|
|||||||
@@ -418,7 +418,7 @@
|
|||||||
"citation": "Citation",
|
"citation": "Citation",
|
||||||
"citation_key_autogenerate_helper": "Auto-generated from the author and year, if left blank",
|
"citation_key_autogenerate_helper": "Auto-generated from the author and year, if left blank",
|
||||||
"cite_a_reference_from_your_reference_manager_to_automatically_add_it_to_your_bib_file": "Cite a reference from your reference manager to automatically add it to your .bib file.",
|
"cite_a_reference_from_your_reference_manager_to_automatically_add_it_to_your_bib_file": "Cite a reference from your reference manager to automatically add it to your .bib file.",
|
||||||
"cite_directly_or_import_references": "Cite directly or import references",
|
"cite_directly_or_import_references": "Import references",
|
||||||
"cite_faster": "Cite faster",
|
"cite_faster": "Cite faster",
|
||||||
"city": "City",
|
"city": "City",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
@@ -2163,6 +2163,7 @@
|
|||||||
"private": "Private",
|
"private": "Private",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"problem_changing_email_address": "There was a problem changing your email address. Please try again in a few moments. If the problem continues please contact us.",
|
"problem_changing_email_address": "There was a problem changing your email address. Please try again in a few moments. If the problem continues please contact us.",
|
||||||
|
"problem_checking_connection_with_provider": "Sorry, there was a problem checking the connection with __provider__ service. Please try again in a few moments.",
|
||||||
"problem_talking_to_publishing_service": "There is a problem with our publishing service, please try again in a few minutes",
|
"problem_talking_to_publishing_service": "There is a problem with our publishing service, please try again in a few minutes",
|
||||||
"problem_with_subscription_contact_us": "There is a problem with your subscription. Please contact us for more information.",
|
"problem_with_subscription_contact_us": "There is a problem with your subscription. Please contact us for more information.",
|
||||||
"proceed_to_paypal": "Proceed to PayPal",
|
"proceed_to_paypal": "Proceed to PayPal",
|
||||||
@@ -3158,12 +3159,12 @@
|
|||||||
"unlink_linked_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).</0>",
|
"unlink_linked_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).</0>",
|
||||||
"unlink_provider_account_title": "Unlink __provider__ Account",
|
"unlink_provider_account_title": "Unlink __provider__ Account",
|
||||||
"unlink_provider_account_warning": "Warning: When you unlink your account from __provider__ you will not be able to sign in using __provider__ anymore.",
|
"unlink_provider_account_warning": "Warning: When you unlink your account from __provider__ you will not be able to sign in using __provider__ anymore.",
|
||||||
"unlink_reference": "Unlink References Provider",
|
"unlink_reference": "Unlink __provider__ References Provider",
|
||||||
"unlink_sso": "Unlink SSO",
|
"unlink_sso": "Unlink SSO",
|
||||||
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own.",
|
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own.",
|
||||||
"unlink_user_explanation": "You’re about to remove the SSO login option for <0>__email__</0>. This will force them to reauthenticate their Overleaf account with your IdP. They’ll receive an email asking them to do this.",
|
"unlink_user_explanation": "You’re about to remove the SSO login option for <0>__email__</0>. This will force them to reauthenticate their Overleaf account with your IdP. They’ll receive an email asking them to do this.",
|
||||||
"unlink_users": "Unlink users",
|
"unlink_users": "Unlink users",
|
||||||
"unlink_warning_reference": "Warning: When you unlink your account from this provider you will not be able to import references into your projects.",
|
"unlink_warning_reference": "Warning: When you unlink your account from __provider__ you will not be able to import references into your projects.",
|
||||||
"unlinking": "Unlinking",
|
"unlinking": "Unlinking",
|
||||||
"unmerge_cells": "Unmerge cells",
|
"unmerge_cells": "Unmerge cells",
|
||||||
"unpause_subscription": "Unpause subscription",
|
"unpause_subscription": "Unpause subscription",
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
"checking_dropbox_status": "Проверка состояния Dropbox",
|
"checking_dropbox_status": "Проверка состояния Dropbox",
|
||||||
"checking_project_github_status": "Проверка статуса проекта на GitHub",
|
"checking_project_github_status": "Проверка статуса проекта на GitHub",
|
||||||
"choose_your_plan": "Выберите тариф",
|
"choose_your_plan": "Выберите тариф",
|
||||||
|
"cite_directly_or_import_references": "Импортировать ссылки",
|
||||||
"city": "Город",
|
"city": "Город",
|
||||||
"clear_cached_files": "Очистить кэшированные файлы",
|
"clear_cached_files": "Очистить кэшированные файлы",
|
||||||
"clear_sessions": "Завершить сессии",
|
"clear_sessions": "Завершить сессии",
|
||||||
@@ -229,6 +230,8 @@
|
|||||||
"features": "Возможности",
|
"features": "Возможности",
|
||||||
"features_like_track_changes": "Такие функции, как отслеживание изменений в реальном времени",
|
"features_like_track_changes": "Такие функции, как отслеживание изменений в реальном времени",
|
||||||
"fi": "Финский",
|
"fi": "Финский",
|
||||||
|
"file_name": "Имя файла",
|
||||||
|
"file_name_in_this_project": "Имя файла в этом проекте",
|
||||||
"files_cannot_include_invalid_characters": "Файлы не могут содержать символы «*» и «/»",
|
"files_cannot_include_invalid_characters": "Файлы не могут содержать символы «*» и «/»",
|
||||||
"filter_projects": "Фильтровать проекты",
|
"filter_projects": "Фильтровать проекты",
|
||||||
"filter_users": "Фильтровать пользователей",
|
"filter_users": "Фильтровать пользователей",
|
||||||
@@ -242,6 +245,7 @@
|
|||||||
"free_7_day_trial_billed_annually": "Бесплатный пробный период 7 дней, затем ежегодная оплата",
|
"free_7_day_trial_billed_annually": "Бесплатный пробный период 7 дней, затем ежегодная оплата",
|
||||||
"free_7_day_trial_billed_monthly": "Бесплатный пробный период 7 дней, затем ежемесячная оплата",
|
"free_7_day_trial_billed_monthly": "Бесплатный пробный период 7 дней, затем ежемесячная оплата",
|
||||||
"free_dropbox_and_history": "Бесплатные Dropbox и История",
|
"free_dropbox_and_history": "Бесплатные Dropbox и История",
|
||||||
|
"from_provider": "Из __provider__",
|
||||||
"full_doc_history": "Полная история изменений",
|
"full_doc_history": "Полная история изменений",
|
||||||
"full_project_history": "Полная история проекта",
|
"full_project_history": "Полная история проекта",
|
||||||
"general": "Общие",
|
"general": "Общие",
|
||||||
@@ -364,6 +368,7 @@
|
|||||||
"i_want_to_stay": "Я хочу остаться",
|
"i_want_to_stay": "Я хочу остаться",
|
||||||
"ignore_validation_errors": "Не проверять синтаксис",
|
"ignore_validation_errors": "Не проверять синтаксис",
|
||||||
"ill_take_it": "Беру!",
|
"ill_take_it": "Беру!",
|
||||||
|
"import_a_bibtex_file_from_your_provider_account": "Импортируйте BibTeX файл из Вашего аккаунта __provider__",
|
||||||
"import_from_github": "Импорт с GitHub",
|
"import_from_github": "Импорт с GitHub",
|
||||||
"import_to_sharelatex": "Импортировать в __appName__",
|
"import_to_sharelatex": "Импортировать в __appName__",
|
||||||
"imported_from_zotero_at_date": "Импортировано из Zotero в __formattedDate__ __relativeDate__",
|
"imported_from_zotero_at_date": "Импортировано из Zotero в __formattedDate__ __relativeDate__",
|
||||||
@@ -404,6 +409,7 @@
|
|||||||
"leave_now": "Покинуть",
|
"leave_now": "Покинуть",
|
||||||
"leave_projects": "Оставить проекты",
|
"leave_projects": "Оставить проекты",
|
||||||
"lets_get_those_premium_features": "Давайте сразу подключим Ваши премиум-функции. Мы спишем <0>__paymentAmount__</0> с указанных Вами платёжных данных.",
|
"lets_get_those_premium_features": "Давайте сразу подключим Ваши премиум-функции. Мы спишем <0>__paymentAmount__</0> с указанных Вами платёжных данных.",
|
||||||
|
"library": "Библиотека",
|
||||||
"link_to_github": "Привязать аккаунт на GitHub",
|
"link_to_github": "Привязать аккаунт на GitHub",
|
||||||
"link_to_github_description": "Вам необходимо авторизовать __appName__ для доступа к Вашему GitHub аккаунту, чтобы разрешить нам синхронизацию Ваших проектов.",
|
"link_to_github_description": "Вам необходимо авторизовать __appName__ для доступа к Вашему GitHub аккаунту, чтобы разрешить нам синхронизацию Ваших проектов.",
|
||||||
"links": "Ссылки",
|
"links": "Ссылки",
|
||||||
@@ -451,6 +457,7 @@
|
|||||||
"more_compile_time": "Больше времени компиляции",
|
"more_compile_time": "Больше времени компиляции",
|
||||||
"more_options": "Дополнительные параметры",
|
"more_options": "Дополнительные параметры",
|
||||||
"must_be_email_address": "Введите правильный адрес электронной почты.",
|
"must_be_email_address": "Введите правильный адрес электронной почты.",
|
||||||
|
"my_library": "Моя библиотека",
|
||||||
"name": "Имя",
|
"name": "Имя",
|
||||||
"native": "браузер",
|
"native": "браузер",
|
||||||
"navigation": "Навигация",
|
"navigation": "Навигация",
|
||||||
@@ -548,6 +555,7 @@
|
|||||||
"private": "Закрытый",
|
"private": "Закрытый",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"problem_changing_email_address": "Возникла проблема при обновлении Вашего адреса электронной почты. Пожалуйста, попробуйте через некоторое время снова. Если проблема повторится, пожалуйста свяжитесь с нами.",
|
"problem_changing_email_address": "Возникла проблема при обновлении Вашего адреса электронной почты. Пожалуйста, попробуйте через некоторое время снова. Если проблема повторится, пожалуйста свяжитесь с нами.",
|
||||||
|
"problem_checking_connection_with_provider": "При проверке соединения со службой __provider__ возникла проблема. Пожалуйста, повторите попытку через некоторое время.",
|
||||||
"problem_talking_to_publishing_service": "Проблема с сервером публикации. Пожалуйста, попробуйте через некоторое время ещё раз",
|
"problem_talking_to_publishing_service": "Проблема с сервером публикации. Пожалуйста, попробуйте через некоторое время ещё раз",
|
||||||
"problem_with_subscription_contact_us": "Возникли проблемы с Вашей подпиской. Пожалуйста, свяжитесь с нами, чтобы узнать подробности.",
|
"problem_with_subscription_contact_us": "Возникли проблемы с Вашей подпиской. Пожалуйста, свяжитесь с нами, чтобы узнать подробности.",
|
||||||
"processing": "обработка",
|
"processing": "обработка",
|
||||||
@@ -751,6 +759,8 @@
|
|||||||
"unlink": "Отсоединить",
|
"unlink": "Отсоединить",
|
||||||
"unlink_github_repository": "Отсоединить Git-репозиторий",
|
"unlink_github_repository": "Отсоединить Git-репозиторий",
|
||||||
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?",
|
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?",
|
||||||
|
"unlink_reference": "Отсоединить провайдер ссылок __provider__",
|
||||||
|
"unlink_warning_reference": "Если вы отсоедините аккаунт, у Вас не будет возможности импортировать ссылки из __provider__ в Ваши проекты.",
|
||||||
"unpause_subscription": "Возобновить подписку",
|
"unpause_subscription": "Возобновить подписку",
|
||||||
"unpausing": "Возобновление",
|
"unpausing": "Возобновление",
|
||||||
"unpublish": "Отменить публикацию",
|
"unpublish": "Отменить публикацию",
|
||||||
@@ -830,6 +840,7 @@
|
|||||||
"you_are_using_our_free_plan": "Вы пользуетесь бесплатным тарифом. Смените тариф, чтобы получить максимум от Overleaf",
|
"you_are_using_our_free_plan": "Вы пользуетесь бесплатным тарифом. Смените тариф, чтобы получить максимум от Overleaf",
|
||||||
"you_can_still_use_your_premium_features": "Вы сможете пользоваться премиум-функциями до того, как приостановка вступит в силу.",
|
"you_can_still_use_your_premium_features": "Вы сможете пользоваться премиум-функциями до того, как приостановка вступит в силу.",
|
||||||
"you_cant_overwrite_it": "Перезаписать нельзя.",
|
"you_cant_overwrite_it": "Перезаписать нельзя.",
|
||||||
|
"you_currently_have_x_linked_with_your_overleaf_account": "Для вашей учётной записи __appName__ в настоящее время настроена интеграция с <0>__managers__</0>.",
|
||||||
"you_dont_have_any_repositories": "У вас нет ни одного репозитория",
|
"you_dont_have_any_repositories": "У вас нет ни одного репозитория",
|
||||||
"you_have_added_x_of_group_size_y": "Вы добавили <0>__addedUsersSize__</0> из <1>__groupSize__</1> доступных участников",
|
"you_have_added_x_of_group_size_y": "Вы добавили <0>__addedUsersSize__</0> из <1>__groupSize__</1> доступных участников",
|
||||||
"you_unpaused_your_subscription": "Вы возобновили подписку.",
|
"you_unpaused_your_subscription": "Вы возобновили подписку.",
|
||||||
@@ -857,6 +868,7 @@
|
|||||||
"zh-CN": "Китайский",
|
"zh-CN": "Китайский",
|
||||||
"zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.",
|
"zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.",
|
||||||
"zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка",
|
"zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка",
|
||||||
|
"zotero_groups_relink": "При загрузке данных из Zotero произошла ошибка, по-видимости вызванная отсутствием разрешения на доступ. Пожалуйста, подключите аккаунт заново и попробуйте ещё раз.",
|
||||||
"zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero",
|
"zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero",
|
||||||
"zotero_reference_loading_error_expired": "Срок действия токена Zotero истёк, пожалуйста, повторно подключите аккаунт",
|
"zotero_reference_loading_error_expired": "Срок действия токена Zotero истёк, пожалуйста, повторно подключите аккаунт",
|
||||||
"zotero_reference_loading_error_forbidden": "Не удалось загрузить ссылки из Zotero, пожалуйста, повторно подключите аккаунт и попробуйте снова",
|
"zotero_reference_loading_error_forbidden": "Не удалось загрузить ссылки из Zotero, пожалуйста, повторно подключите аккаунт и попробуйте снова",
|
||||||
|
|||||||
@@ -1,66 +1,82 @@
|
|||||||
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
|
import logger from '@overleaf/logger'
|
||||||
import Settings from '@overleaf/settings'
|
import Settings from '@overleaf/settings'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import Path from 'node:path'
|
import Path from 'node:path'
|
||||||
|
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
|
||||||
|
|
||||||
const CIPHER_KEY_FILE = '/var/lib/overleaf/data/.zotero-cipher-key'
|
const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.token-cipher.json'
|
||||||
const CIPHER_LABEL = '2024.1-v3'
|
const TOKEN_CIPHER_LABEL = 'OL_CEP-v3'
|
||||||
|
|
||||||
let encryptorInstance = null
|
let encryptorInstance = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a stable cipher password that persists across container
|
* Get or create a stable encryptor data that persists across container
|
||||||
* recreations. Priority:
|
* recreations. Priority:
|
||||||
* 1. ZOTERO_CIPHER_PASSWORD env var (explicit user config)
|
* 1. ZOTERO_CIPHER_PASSWORD env var (explicit user config)
|
||||||
* 2. Key file in the persistent volume (/var/lib/overleaf/data/)
|
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
|
||||||
* — auto-generated on first use, survives container rebuilds
|
* — auto-generated on first use, survives container rebuilds
|
||||||
*/
|
*/
|
||||||
function _getStableCipherPassword() {
|
function _getEncryptorData() {
|
||||||
if (process.env.ZOTERO_CIPHER_PASSWORD) {
|
const cipherPassword = process.env.ZOTERO_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD
|
||||||
return process.env.ZOTERO_CIPHER_PASSWORD
|
const cipherLabel = process.env.ZOTERO_TOKEN_CIPHER_LABEL ||
|
||||||
}
|
process.env.TOKEN_CIPHER_LABEL ||
|
||||||
try {
|
TOKEN_CIPHER_LABEL
|
||||||
const existing = fs.readFileSync(CIPHER_KEY_FILE, 'utf8').trim()
|
if (cipherPassword) {
|
||||||
if (existing.length >= 16) {
|
return {
|
||||||
return existing
|
cipherLabel,
|
||||||
|
cipherPasswords: {
|
||||||
|
[cipherLabel]: cipherPassword
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// File doesn't exist yet — generate one
|
|
||||||
}
|
}
|
||||||
const newKey = crypto.randomBytes(32).toString('base64')
|
// cipherPassword is not set, use file
|
||||||
const dir = Path.dirname(CIPHER_KEY_FILE)
|
const cipherFile = process.env.ZOTERO_TOKEN_CIPHER_FILE ||
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
process.env.TOKEN_CIPHER_FILE ||
|
||||||
fs.writeFileSync(CIPHER_KEY_FILE, newKey, { mode: 0o600 })
|
TOKEN_CIPHER_FILE
|
||||||
return newKey
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(cipherFile, 'utf8'))
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
logger.info({ cipherFile }, 'Creating new cipherFile')
|
||||||
|
} else {
|
||||||
|
logger.error({ err, cipherFile }, 'Bad cipherFile')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// File doesn't exist yet — generate one
|
||||||
|
try {
|
||||||
|
const encryptorData = {
|
||||||
|
cipherLabel,
|
||||||
|
cipherPasswords: {
|
||||||
|
[cipherLabel]: crypto.randomBytes(32).toString('base64')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = Path.dirname(cipherFile)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 })
|
||||||
|
return encryptorData
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, cipherFile }, 'Cannot create cipherFile')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getEncryptor() {
|
function _getEncryptor() {
|
||||||
if (!encryptorInstance) {
|
if (!encryptorInstance) {
|
||||||
const encryptorSettings = Settings.zotero?.encryptor || Settings.oauthProviders?.encryptor
|
const encryptorData = _getEncryptorData()
|
||||||
if (!encryptorSettings) {
|
encryptorInstance = new AccessTokenEncryptorClass(encryptorData)
|
||||||
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
|
return encryptorInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AccessTokenEncryptor = {
|
export const AccessTokenEncryptor = {
|
||||||
promises: {
|
async encryptJson(json) {
|
||||||
async encryptJson(json) {
|
return await _getEncryptor().promises.encryptJson(json)
|
||||||
return await _getEncryptor().promises.encryptJson(json)
|
},
|
||||||
},
|
async decryptToJson(encryptedJson) {
|
||||||
async decryptToJson(encryptedJson) {
|
return await _getEncryptor().promises.decryptToJson(encryptedJson)
|
||||||
return await _getEncryptor().promises.decryptToJson(encryptedJson)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import { User } from '../../../../app/src/models/User.mjs'
|
||||||
|
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.decryptToJson(
|
||||||
|
user.refProviders.zotero.apiKeyEncrypted
|
||||||
|
)
|
||||||
|
return decrypted
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ userId, err }, 'failed to decrypt Zotero credentials, treating as not connected')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a Zotero account (store encrypted credentials).
|
||||||
|
*/
|
||||||
|
async function storeCredentials(userId, apiKey, zoteroUserId) {
|
||||||
|
const apiKeyEncrypted = await AccessTokenEncryptor.encryptJson({
|
||||||
|
apiKey,
|
||||||
|
zoteroUserId: String(zoteroUserId),
|
||||||
|
})
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: userId },
|
||||||
|
{ $set: { 'refProviders.zotero': { apiKeyEncrypted } } }
|
||||||
|
).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getCredentials,
|
||||||
|
storeCredentials,
|
||||||
|
}
|
||||||
@@ -1,208 +1,197 @@
|
|||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
import Settings from '@overleaf/settings'
|
|
||||||
import OError from '@overleaf/o-error'
|
import OError from '@overleaf/o-error'
|
||||||
import { fetchJson, fetchString } from '@overleaf/fetch-utils'
|
import AbortError from 'node-fetch'
|
||||||
|
import {
|
||||||
|
fetchNothing,
|
||||||
|
fetchJson,
|
||||||
|
fetchString,
|
||||||
|
fetchStringWithResponse,
|
||||||
|
RequestFailedError,
|
||||||
|
} from '@overleaf/fetch-utils'
|
||||||
import { User } from '../../../../app/src/models/User.mjs'
|
import { User } from '../../../../app/src/models/User.mjs'
|
||||||
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
TooManyRequestsError,
|
||||||
|
ServiceNotConfiguredError,
|
||||||
|
ForbiddenError,
|
||||||
|
} from '../../../../app/src/Features/Errors/Errors.js'
|
||||||
|
import TokenManager from './TokenManager.mjs'
|
||||||
|
|
||||||
const ZOTERO_API_URL = 'https://api.zotero.org'
|
const ZOTERO_API_URL = 'https://api.zotero.org'
|
||||||
|
const REQUEST_TIMEOUT_MS = 60 * 1000
|
||||||
|
// TODO: implement conditional requests
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt stored Zotero credentials from user record.
|
* Build a header for Zotero API request.
|
||||||
* Returns { apiKey, zoteroUserId } or null if not linked.
|
|
||||||
*/
|
*/
|
||||||
async function _getCredentials(userId) {
|
function buildHeaders(apiKey, opts = {}) {
|
||||||
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 = {
|
const headers = {
|
||||||
'Zotero-API-Version': '3',
|
'Zotero-API-Version': '3',
|
||||||
'Zotero-API-Key': apiKey,
|
'Zotero-API-Key': apiKey,
|
||||||
...(opts.headers || {}),
|
'User-Agent': 'Overleaf-CEP-Zotero',
|
||||||
|
...opts,
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks connection to Zotero by calling /keys/{key}.
|
||||||
|
*/
|
||||||
|
async function getConnectionStatus(userId) {
|
||||||
|
const credentials = await TokenManager.getCredentials(userId)
|
||||||
|
if (!credentials) return false
|
||||||
|
|
||||||
|
const { apiKey } = credentials
|
||||||
|
try {
|
||||||
|
await fetchJson(`${ZOTERO_API_URL}/keys/${apiKey}`, {
|
||||||
|
headers: buildHeaders(apiKey),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
normalizeApiError(err, 'getConnectionStatus')
|
||||||
}
|
}
|
||||||
return { url, headers }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of groups for a user.
|
* Get the list of groups for a user.
|
||||||
*/
|
*/
|
||||||
async function getGroupsForUser(userId) {
|
async function getGroupsForUser(userId) {
|
||||||
const credentials = await _getCredentials(userId)
|
const credentials = await TokenManager.getCredentials(userId)
|
||||||
if (!credentials) {
|
if (!credentials) return null
|
||||||
throw new ZoteroAccountNotLinkedError()
|
|
||||||
}
|
|
||||||
const { apiKey, zoteroUserId } = credentials
|
const { apiKey, zoteroUserId } = credentials
|
||||||
const { url, headers } = await _zoteroApiRequest(
|
|
||||||
apiKey,
|
|
||||||
`/users/${zoteroUserId}/groups`
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
const groups = await fetchJson(url, { headers })
|
const groups = await fetchJson(`${ZOTERO_API_URL}/users/${zoteroUserId}/groups`, {
|
||||||
|
headers: buildHeaders(apiKey),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||||
|
})
|
||||||
return groups.map(g => ({
|
return groups.map(g => ({
|
||||||
id: String(g.id),
|
id: String(g.id),
|
||||||
name: g.data?.name || `Group ${g.id}`,
|
name: g.data?.name || `Group ${g.id}`,
|
||||||
}))
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.err({ err, userId }, 'error fetching Zotero groups')
|
normalizeApiError(err, 'getGroupsForUser')
|
||||||
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.
|
* Export a library as BibTeX / BibLaTeX.
|
||||||
*/
|
*/
|
||||||
async function getUserLibraryBibtex(userId) {
|
async function getLibraryBibtex(userId, groupId, format) {
|
||||||
const credentials = await _getCredentials(userId)
|
const credentials = await TokenManager.getCredentials(userId)
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new ZoteroAccountNotLinkedError()
|
throw new ServiceNotConfiguredError({
|
||||||
|
message: 'RefProvider credentials missed',
|
||||||
|
info: { userId, status: 400 }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return _fetchBibtex(
|
|
||||||
credentials.apiKey,
|
|
||||||
`/users/${credentials.zoteroUserId}/items`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Main library or group?
|
||||||
* Export a group library as BibTeX.
|
let basePath
|
||||||
*/
|
if (groupId) basePath = `/groups/${groupId}/items`
|
||||||
async function getGroupLibraryBibtex(userId, groupId) {
|
else basePath = `/users/${credentials.zoteroUserId}/items`
|
||||||
const credentials = await _getCredentials(userId)
|
|
||||||
if (!credentials) {
|
return _fetchBibtex(credentials.apiKey, basePath, format)
|
||||||
throw new ZoteroAccountNotLinkedError()
|
|
||||||
}
|
|
||||||
return _fetchBibtex(credentials.apiKey, `/groups/${groupId}/items`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all items from a Zotero library endpoint as BibTeX.
|
* Fetch all items from a Zotero library endpoint as BibTeX.
|
||||||
* Handles pagination (Zotero API limits to 100 items per request).
|
* Handles pagination (Zotero API limits to 100 items per request).
|
||||||
*/
|
*/
|
||||||
async function _fetchBibtex(apiKey, basePath) {
|
async function _fetchBibtex(apiKey, basePath, format) {
|
||||||
let allBibtex = ''
|
|
||||||
let start = 0
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
|
let allBibtex = ''
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
const { url, headers } = await _zoteroApiRequest(apiKey, basePath, {
|
let start = 0
|
||||||
headers: {},
|
const headers = buildHeaders(apiKey)
|
||||||
})
|
while (true) {
|
||||||
const fullUrl = `${url}?format=bibtex&limit=${limit}&start=${start}`
|
const url = `${ZOTERO_API_URL}${basePath}?format=${format}&limit=${limit}&start=${start}`
|
||||||
try {
|
const { body: bibtex, response } = await fetchStringWithResponse(url, {
|
||||||
const response = await fetch(fullUrl, { headers })
|
headers,
|
||||||
if (!response.ok) {
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||||
if (response.status === 403) {
|
})
|
||||||
throw new ZoteroForbiddenError('Zotero API returned 403')
|
|
||||||
}
|
if (bibtex.trim()) allBibtex += bibtex
|
||||||
throw new Error(`Zotero API returned ${response.status}`)
|
|
||||||
}
|
const totalResults = parseInt(response.headers.get('Total-Results') || '0', 10)
|
||||||
const bibtex = await response.text()
|
|
||||||
if (bibtex.trim()) {
|
|
||||||
allBibtex += bibtex + '\n'
|
|
||||||
}
|
|
||||||
const totalResults = parseInt(
|
|
||||||
response.headers.get('Total-Results') || '0',
|
|
||||||
10
|
|
||||||
)
|
|
||||||
start += limit
|
start += limit
|
||||||
if (start >= totalResults) {
|
if (start >= totalResults) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ZoteroForbiddenError) {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
throw OError.tag(err, 'error fetching BibTeX from Zotero', { basePath })
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
normalizeApiError(err, '_fetchBibtex')
|
||||||
}
|
}
|
||||||
return allBibtex
|
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.
|
* Unlink a Zotero account.
|
||||||
*/
|
*/
|
||||||
async function unlinkAccount(userId) {
|
async function unlinkAccount(userId) {
|
||||||
|
const credentials = await TokenManager.getCredentials(userId)
|
||||||
|
if (!credentials) return
|
||||||
|
|
||||||
await User.updateOne(
|
await User.updateOne(
|
||||||
{ _id: userId },
|
{ _id: userId },
|
||||||
{ $unset: { 'refProviders.zotero': 1 } }
|
{ $unset: { 'refProviders.zotero': 1 } }
|
||||||
).exec()
|
).exec()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiKey } = credentials
|
||||||
|
await fetchNothing(`${ZOTERO_API_URL}/keys/${apiKey}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: buildHeaders(apiKey),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'failed to detete key from Zotero account')
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ZoteroForbiddenError extends Error {
|
function normalizeApiError(err, operation) {
|
||||||
constructor(message) {
|
logger.error({ operation }, 'Zotero API request failed')
|
||||||
super(message)
|
|
||||||
this.name = 'ZoteroForbiddenError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ZoteroAccountNotLinkedError extends Error {
|
if (err.name === 'AbortError') {
|
||||||
constructor(message = 'Zotero account not linked') {
|
throw new OError('RefProvider request timed out', { operation, status: 504 }).withCause(err)
|
||||||
super(message)
|
|
||||||
this.name = 'ZoteroAccountNotLinkedError'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(err instanceof RequestFailedError)) {
|
||||||
|
throw new OError('Something wrong with RefProvider request', { operation, status: 500 }).withCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = err.response?.status || 500
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
|
throw new ForbiddenError({
|
||||||
|
message: 'Access denied',
|
||||||
|
info: { operation, status }
|
||||||
|
}).withCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: 'Not found',
|
||||||
|
info: { operation, status }
|
||||||
|
}).withCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 429) {
|
||||||
|
throw new TooManyRequestsError({
|
||||||
|
message: 'Rate limit exeeded',
|
||||||
|
info: { operation, status }
|
||||||
|
}).withCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new OError('RefProvider request error', { operation, status }).withCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
getConnectionStatus,
|
||||||
getGroupsForUser,
|
getGroupsForUser,
|
||||||
getUserLibraryBibtex,
|
getLibraryBibtex,
|
||||||
getGroupLibraryBibtex,
|
|
||||||
validateApiKey,
|
|
||||||
storeCredentials,
|
|
||||||
unlinkAccount,
|
unlinkAccount,
|
||||||
ZoteroForbiddenError,
|
|
||||||
ZoteroAccountNotLinkedError,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,134 @@
|
|||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
|
import OError from '@overleaf/o-error'
|
||||||
|
import crypto from 'crypto'
|
||||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||||
|
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs'
|
||||||
import ZoteroApiClient from './ZoteroApiClient.mjs'
|
import ZoteroApiClient from './ZoteroApiClient.mjs'
|
||||||
import { ZoteroForbiddenError } from './ZoteroApiClient.mjs'
|
import ZoteroOAuth from './ZoteroOAuth.mjs'
|
||||||
|
import TokenManager from './TokenManager.mjs'
|
||||||
|
|
||||||
|
async function oauth(req, res) {
|
||||||
|
try {
|
||||||
|
const requestToken = await ZoteroOAuth.getRequestToken()
|
||||||
|
const isPopup = req.query.popup === '1'
|
||||||
|
|
||||||
|
req.session.zoteroOAuth = {
|
||||||
|
token: requestToken.oauth_token,
|
||||||
|
tokenSecret: requestToken.oauth_token_secret,
|
||||||
|
isPopup
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUrl = ZoteroOAuth.getAuthorizationUrl(requestToken.oauth_token)
|
||||||
|
res.redirect(authUrl)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(OError.getFullStack(err))
|
||||||
|
const info = OError.getFullInfo(err)
|
||||||
|
logger.error({ info }, "Failed to start Zotero authorization")
|
||||||
|
|
||||||
|
HttpErrorHandler.badRequest(req, res, 'Failed to start Zotero authorization')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oauthCallback(req, res) {
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
|
||||||
|
const {
|
||||||
|
oauth_token: oauthToken,
|
||||||
|
oauth_verifier: oauthVerifier,
|
||||||
|
} = req.query
|
||||||
|
|
||||||
|
const saved = req.session.zoteroOAuth
|
||||||
|
delete req.session.zoteroOAuth
|
||||||
|
|
||||||
|
if (!saved || saved.token !== oauthToken) {
|
||||||
|
HttpErrorHandler.forbidden(req, res, 'Invalid OAuth token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accessToken, zoteroUserId } = await ZoteroOAuth.exchangeRequestTokenForAccessToken(
|
||||||
|
oauthToken,
|
||||||
|
saved.tokenSecret,
|
||||||
|
oauthVerifier,
|
||||||
|
)
|
||||||
|
await TokenManager.storeCredentials(userId, accessToken, zoteroUserId)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(OError.getFullStack(err))
|
||||||
|
const info = OError.getFullInfo(err)
|
||||||
|
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')
|
||||||
|
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
`${csp}; script-src 'nonce-${nonce}'`
|
||||||
|
)
|
||||||
|
|
||||||
|
res.send(`
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<script nonce="${nonce}">
|
||||||
|
const channel = new BroadcastChannel('zotero')
|
||||||
|
channel.postMessage({ type: 'zotero-linked' })
|
||||||
|
${saved.isPopup
|
||||||
|
? 'window.close()'
|
||||||
|
: "location.href = '/user/settings#references'"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /zotero/groups
|
* GET /user/zotero/status
|
||||||
* Returns the user's Zotero groups (for the create-file modal).
|
* Returns the user's Zotero connection status, true or false
|
||||||
|
*/
|
||||||
|
async function getConnectionStatus(req, res) {
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isConnected = await ZoteroApiClient.getConnectionStatus(userId)
|
||||||
|
res.json(isConnected)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const info = OError.getFullInfo(err)
|
||||||
|
const errStatus = info?.status || 500
|
||||||
|
logger.error(OError.getFullStack(err))
|
||||||
|
logger.error({ info }, "failed to check user connection")
|
||||||
|
return res.status(errStatus).json({ message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /user/zotero/groups
|
||||||
|
* Returns the user's Zotero groups or null, if account is not linked
|
||||||
*/
|
*/
|
||||||
async function getGroups(req, res) {
|
async function getGroups(req, res) {
|
||||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
try {
|
try {
|
||||||
const groups = await ZoteroApiClient.getGroupsForUser(userId)
|
const groups = await ZoteroApiClient.getGroupsForUser(userId)
|
||||||
res.json({ groups })
|
res.json(groups)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ZoteroForbiddenError) {
|
const info = OError.getFullInfo(err)
|
||||||
return res.status(403).json({
|
const errStatus = info?.status || 500
|
||||||
error: 'forbidden',
|
logger.error(OError.getFullStack(err))
|
||||||
message: 'zotero_groups_relink',
|
logger.error({ info }, "failed to get user groups")
|
||||||
})
|
return res.status(errStatus).json({ message: err.message })
|
||||||
}
|
|
||||||
logger.err({ err, userId }, 'error fetching Zotero groups')
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'internal',
|
|
||||||
message: 'zotero_groups_loading_error',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /zotero/link
|
* DELETE /user/zotero/unlink
|
||||||
* 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.
|
* Unlinks the user's Zotero account.
|
||||||
*/
|
*/
|
||||||
async function unlink(req, res) {
|
async function unlink(req, res) {
|
||||||
@@ -74,13 +137,18 @@ async function unlink(req, res) {
|
|||||||
await ZoteroApiClient.unlinkAccount(userId)
|
await ZoteroApiClient.unlinkAccount(userId)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.err({ err, userId }, 'error unlinking Zotero')
|
const info = OError.getFullInfo(err)
|
||||||
res.sendStatus(500)
|
const errStatus = info?.status || 500
|
||||||
|
logger.error(OError.getFullStack(err))
|
||||||
|
logger.error({ info }, "error unlinking Zotero account")
|
||||||
|
return res.status(errStatus).json({ message: err.message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
oauth,
|
||||||
|
oauthCallback,
|
||||||
|
getConnectionStatus,
|
||||||
getGroups,
|
getGroups,
|
||||||
link,
|
unlink
|
||||||
unlink,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
import { callbackify } from '@overleaf/promise-utils'
|
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 UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
|
||||||
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
TooManyRequestsError,
|
||||||
|
ServiceNotConfiguredError,
|
||||||
|
ForbiddenError
|
||||||
|
} from '../../../../app/src/Features/Errors/Errors.js'
|
||||||
import LinkedFilesHandler from '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler.mjs'
|
import LinkedFilesHandler from '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler.mjs'
|
||||||
import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs'
|
import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs'
|
||||||
import ZoteroApiClient from './ZoteroApiClient.mjs'
|
import ZoteroApiClient from './ZoteroApiClient.mjs'
|
||||||
import { ZoteroForbiddenError, ZoteroAccountNotLinkedError } from './ZoteroApiClient.mjs'
|
|
||||||
|
|
||||||
const {
|
|
||||||
AccessDeniedError,
|
|
||||||
RemoteServiceError,
|
|
||||||
} = LinkedFilesErrors
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a linked .bib file from Zotero (either My Library or a Group Library).
|
* Create a linked .bib file from Zotero (either My Library or a Group Library).
|
||||||
@@ -19,8 +21,9 @@ const {
|
|||||||
* provider: 'zotero'
|
* provider: 'zotero'
|
||||||
* zoteroGroupId?: string
|
* zoteroGroupId?: string
|
||||||
* importedAt: Date | string
|
* importedAt: Date | string
|
||||||
* importedByUserId?: string
|
* importedByUserId: string
|
||||||
* importedByName?: string
|
* importedByName: string
|
||||||
|
* bibFormat: 'bibtex' || 'biblatex'
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* - If zoteroGroupId is present, export that group's library.
|
* - If zoteroGroupId is present, export that group's library.
|
||||||
@@ -33,13 +36,14 @@ async function createLinkedFile(
|
|||||||
parentFolderId,
|
parentFolderId,
|
||||||
userId
|
userId
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
|
||||||
{ projectId, userId, groupId: linkedFileData.zoteroGroupId },
|
|
||||||
'creating Zotero linked file'
|
|
||||||
)
|
|
||||||
|
|
||||||
linkedFileData.importedByUserId = userId
|
linkedFileData.importedByUserId = userId
|
||||||
linkedFileData.importedByName = await _getUserName(userId)
|
linkedFileData.importedByName = await _getUserName(userId) || 'Unknown'
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{ projectId, userId, linkedFileData },
|
||||||
|
'creating Zotero linked file'
|
||||||
|
)
|
||||||
|
|
||||||
const bibtex = await _getBibtex(linkedFileData)
|
const bibtex = await _getBibtex(linkedFileData)
|
||||||
|
|
||||||
@@ -65,10 +69,26 @@ async function refreshLinkedFile(
|
|||||||
userId
|
userId
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ projectId, userId, groupId: linkedFileData.zoteroGroupId },
|
{ projectId, userId, linkedFileData },
|
||||||
'refreshing Zotero linked file'
|
'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
|
||||||
|
const userName = await _getUserName(linkedFileData.importedByUserId)
|
||||||
|
if (userName && linkedFileData.importedByUserId != userId) {
|
||||||
|
linkedFileData.importedByName = 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.importedByName`
|
||||||
|
await Project.updateOne({ _id: projectId }, { $set: { [updatePath]: userName } })
|
||||||
|
}
|
||||||
|
|
||||||
const bibtex = await _getBibtex(linkedFileData)
|
const bibtex = await _getBibtex(linkedFileData)
|
||||||
|
|
||||||
const file = await LinkedFilesHandler.promises.importContent(
|
const file = await LinkedFilesHandler.promises.importContent(
|
||||||
@@ -85,22 +105,27 @@ async function refreshLinkedFile(
|
|||||||
async function _getBibtex(linkedFileData) {
|
async function _getBibtex(linkedFileData) {
|
||||||
const userId = linkedFileData.importedByUserId
|
const userId = linkedFileData.importedByUserId
|
||||||
try {
|
try {
|
||||||
if (linkedFileData.zoteroGroupId) {
|
return await ZoteroApiClient.getLibraryBibtex(
|
||||||
return await ZoteroApiClient.getGroupLibraryBibtex(
|
userId,
|
||||||
userId,
|
linkedFileData.zoteroGroupId, // == null for main library
|
||||||
linkedFileData.zoteroGroupId
|
linkedFileData.bibFormat || 'bibtex'
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
return await ZoteroApiClient.getUserLibraryBibtex(userId)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ZoteroForbiddenError) {
|
|
||||||
throw new AccessDeniedError('Zotero access denied').withCause(err)
|
if (err instanceof ForbiddenError) {
|
||||||
|
logger.debug({ linkedFileData, err }, 'Zotero access denied')
|
||||||
|
throw new LinkedFilesErrors.AccessDeniedError('Zotero access denied').withCause(err)
|
||||||
}
|
}
|
||||||
if (err instanceof ZoteroAccountNotLinkedError) {
|
if (err instanceof ServiceNotConfiguredError) {
|
||||||
throw new AccessDeniedError('Zotero account not linked').withCause(err)
|
logger.debug({ userId: linkedFileData.importedByUserId, err }, 'Zotero account not linked')
|
||||||
|
throw new LinkedFilesErrors.AccessDeniedError('Zotero account not linked').withCause(err)
|
||||||
}
|
}
|
||||||
throw new RemoteServiceError('Zotero API error').withCause(err)
|
if (err instanceof NotFoundError) {
|
||||||
|
logger.debug({ group: linkedFileData.zoteroGroupId, err }, 'Zotero group is not found')
|
||||||
|
throw new LinkedFilesErrors.SourceFileNotFoundError('Zotero group 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +140,7 @@ function _sanitizeData(data) {
|
|||||||
importedByUserId: data.importedByUserId,
|
importedByUserId: data.importedByUserId,
|
||||||
}),
|
}),
|
||||||
importedByName: data.importedByName || 'Unknown',
|
importedByName: data.importedByName || 'Unknown',
|
||||||
|
bibFormat: (data.bibFormat === 'biblatex') ? 'biblatex' : 'bibtex'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +152,12 @@ async function _getUserName(userId) {
|
|||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error({ userId, err }, 'failed to get user info')
|
logger.error({ userId, err }, 'failed to get user info')
|
||||||
}
|
}
|
||||||
if (!user) return 'Unknown'
|
if (!user) return null
|
||||||
|
|
||||||
const { email, first_name, last_name } = user
|
const { email, first_name, last_name } = user
|
||||||
const name = (first_name || last_name) ?
|
const name = (first_name || last_name) ?
|
||||||
[first_name, last_name].filter(n => n != null).join(' ') : email
|
[first_name, last_name].filter(n => n != null).join(' ') : email
|
||||||
return name || 'Unknown'
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import OError from '@overleaf/o-error'
|
||||||
|
import { fetchString } from '@overleaf/fetch-utils'
|
||||||
|
import OAuth from 'oauth-1.0a'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const ZOTERO_OAUTH_URL = 'https://www.zotero.org/oauth'
|
||||||
|
|
||||||
|
function hashFunctionSha1(baseString, key) {
|
||||||
|
return crypto.createHmac('sha1', key)
|
||||||
|
.update(baseString)
|
||||||
|
.digest('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOAuthClient() {
|
||||||
|
return OAuth({
|
||||||
|
consumer: {
|
||||||
|
key: Settings.zotero.clientKey,
|
||||||
|
secret: Settings.zotero.clientSecret,
|
||||||
|
},
|
||||||
|
signature_method: 'HMAC-SHA1',
|
||||||
|
hash_function: hashFunctionSha1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthorizationHeader(requestData, tokenData) {
|
||||||
|
const oauth = createOAuthClient()
|
||||||
|
const authData = oauth.authorize(requestData, tokenData)
|
||||||
|
|
||||||
|
return oauth.toHeader(authData).Authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOAuthResponse(body) {
|
||||||
|
return Object.fromEntries(new URLSearchParams(body).entries())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthorizationUrl(oauthToken) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
oauth_token: oauthToken,
|
||||||
|
library_access: '1',
|
||||||
|
all_groups: 'read',
|
||||||
|
})
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await fetchString(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: buildAuthorizationHeader({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
oauth_callback: Settings.zotero.callbackURL,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return parseOAuthResponse(body)
|
||||||
|
} catch (err) {
|
||||||
|
throw OError.tag(err, 'error obtaining Zotero request token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeRequestTokenForAccessToken(
|
||||||
|
oauthToken,
|
||||||
|
oauthTokenSecret,
|
||||||
|
oauthVerifier,
|
||||||
|
) {
|
||||||
|
const url = `${ZOTERO_OAUTH_URL}/access`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await fetchString(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: buildAuthorizationHeader(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
oauth_verifier: oauthVerifier,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: oauthToken,
|
||||||
|
secret: oauthTokenSecret,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = parseOAuthResponse(body)
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: data.oauth_token,
|
||||||
|
apiKeySecret: data.oauth_token_secret,
|
||||||
|
zoteroUserId: data.userID,
|
||||||
|
username: data.username,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.err(
|
||||||
|
{ err, oauthToken },
|
||||||
|
'error obtaining Zotero access token'
|
||||||
|
)
|
||||||
|
|
||||||
|
throw OError.tag(err, 'error obtaining Zotero access token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getAuthorizationUrl,
|
||||||
|
getRequestToken,
|
||||||
|
exchangeRequestTokenForAccessToken,
|
||||||
|
}
|
||||||
@@ -5,23 +5,36 @@ export default {
|
|||||||
apply(webRouter) {
|
apply(webRouter) {
|
||||||
// Get Zotero groups for the create-file modal
|
// Get Zotero groups for the create-file modal
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
'/zotero/groups',
|
'/user/zotero/groups',
|
||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
ZoteroController.getGroups
|
ZoteroController.getGroups
|
||||||
)
|
)
|
||||||
|
|
||||||
// Link Zotero account (a user submits an API key)
|
|
||||||
webRouter.post(
|
|
||||||
'/zotero/link',
|
|
||||||
AuthenticationController.requireLogin(),
|
|
||||||
ZoteroController.link
|
|
||||||
)
|
|
||||||
|
|
||||||
// Unlink Zotero account
|
// Unlink Zotero account
|
||||||
webRouter.post(
|
webRouter.delete(
|
||||||
'/zotero/unlink',
|
'/user/zotero',
|
||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
ZoteroController.unlink
|
ZoteroController.unlink
|
||||||
|
)
|
||||||
|
webRouter.get(
|
||||||
|
'/user/zotero/status',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
ZoteroController.getConnectionStatus
|
||||||
)
|
)
|
||||||
|
|
||||||
|
webRouter.get(
|
||||||
|
'/user/zotero/oauth',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
ZoteroController.oauth
|
||||||
|
)
|
||||||
|
// callback for Zotero OAuth flow
|
||||||
|
webRouter.get(
|
||||||
|
'/user/zotero/oauth/callback',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
ZoteroController.oauthCallback
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { FormEventHandler, useEffect, useState } from 'react'
|
||||||
|
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'
|
||||||
|
import { useFileTreeCreateName } from '@/features/file-tree/contexts/file-tree-create-name'
|
||||||
|
import FileTreeCreateNameInput from '@/features/file-tree/components/file-tree-create/file-tree-create-name-input'
|
||||||
|
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 OLFormSelect from '@/shared/components/ol/ol-form-select'
|
||||||
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
|
||||||
|
type ZoteroGroup = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileTreeImportFromZoteroProps = { groups: ZoteroGroup[] }
|
||||||
|
|
||||||
|
export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZoteroProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { name, setName } = useFileTreeCreateName()
|
||||||
|
const { setValid } = useFileTreeCreateForm()
|
||||||
|
const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable()
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string>('')
|
||||||
|
const [selectedFormat, setSelectedFormat] = useState<string>('bibtex')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValid(!!name)
|
||||||
|
}, [name])
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler = event => {
|
||||||
|
event.preventDefault()
|
||||||
|
eventTracking.sendMB('new-file-created', {
|
||||||
|
method: 'zotero',
|
||||||
|
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
|
||||||
|
})
|
||||||
|
|
||||||
|
finishCreatingLinkedFile({
|
||||||
|
name,
|
||||||
|
provider: 'zotero',
|
||||||
|
data: { zoteroGroupId: selectedGroupId, bibFormat: selectedFormat }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>{t('import_a_bibtex_file_from_your_provider_account', { provider: 'Zotero' })}</p>
|
||||||
|
<form
|
||||||
|
className="form-controls"
|
||||||
|
id="create-file"
|
||||||
|
noValidate
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<OLFormGroup controlId="zotero-library-select">
|
||||||
|
<OLFormLabel>{t('library')}</OLFormLabel>
|
||||||
|
<OLFormSelect
|
||||||
|
id="zotero-library-select"
|
||||||
|
value={selectedGroupId}
|
||||||
|
disabled={inFlight}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
setSelectedGroupId(e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t('my_library')}</option>
|
||||||
|
{groups?.map(g => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</OLFormSelect>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<FileTreeCreateNameInput
|
||||||
|
label={t('file_name_in_this_project')}
|
||||||
|
placeholder="zotero.bib"
|
||||||
|
error={error}
|
||||||
|
inFlight={inFlight}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="zotero-format-select">
|
||||||
|
<OLFormLabel>{t('format')}</OLFormLabel>
|
||||||
|
<OLFormSelect
|
||||||
|
id="zotero-format-select"
|
||||||
|
value={selectedFormat}
|
||||||
|
disabled={inFlight}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
setSelectedFormat(e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="bibtex">{'BibTeX'}</option>
|
||||||
|
<option value="biblatex">{'BibLaTeX'}</option>
|
||||||
|
</OLFormSelect>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
{inFlight && (
|
||||||
|
<div role="status" className="loading d-flex justify-content-center align-items-center fs-5">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="spinner-border spinner-border-sm"
|
||||||
|
></div>
|
||||||
|
{t('importing') + '…'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <ErrorMessage error={error} />}
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,15 +5,17 @@ import { LinkedFileIcon } from '@/features/file-view/components/file-view-icons'
|
|||||||
import { hasProvider } from '@/features/file-view/types/binary-file'
|
import { hasProvider } from '@/features/file-view/types/binary-file'
|
||||||
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
|
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows "Imported from Zotero at <date>" in the owner's file view header or
|
||||||
|
* "Imported from Zotero at <date> by <owner>" in the collaborator's file view header
|
||||||
|
* when viewing a Zotero-linked .bib file.
|
||||||
|
* Registered via overleafModuleImports.tprFileViewInfo.
|
||||||
|
*/
|
||||||
|
|
||||||
type TPRFileViewInfoProps = {
|
type TPRFileViewInfoProps = {
|
||||||
file: LinkedFile<keyof LinkedFileData>
|
file: LinkedFile<keyof LinkedFileData>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
export function TPRFileViewInfo({ file }: TPRFileViewInfoProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
|||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
import type { BinaryFile } from '@/features/file-view/types/binary-file'
|
|
||||||
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
|
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
|
||||||
|
|
||||||
type TPRFileViewInfoProps = {
|
type TPRFileViewInfoProps = {
|
||||||
|
|||||||
@@ -20,17 +20,13 @@ export function TPRFileViewRefreshButton({
|
|||||||
refreshing,
|
refreshing,
|
||||||
}: TPRFileViewRefreshButtonProps) {
|
}: TPRFileViewRefreshButtonProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const importedByUserId = (file.linkedFileData as any)?.importedByUserId
|
|
||||||
const zoteroIsProvider = hasProvider(file, 'zotero')
|
const zoteroIsProvider = hasProvider(file, 'zotero')
|
||||||
const disabled = (zoteroIsProvider && !importedByUserId) ? true : false
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OLButton
|
<OLButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => refreshFile(zoteroIsProvider ? true : null)}
|
onClick={() => refreshFile(zoteroIsProvider)}
|
||||||
disabled={refreshing || disabled}
|
|
||||||
isLoading={refreshing}
|
isLoading={refreshing}
|
||||||
loadingLabel={t('refreshing')}
|
|
||||||
>
|
>
|
||||||
{t('refresh')}
|
{t('refresh')}
|
||||||
</OLButton>
|
</OLButton>
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import OLNotification from '@/shared/components/ol/ol-notification'
|
|||||||
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
|
import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file'
|
||||||
import { hasProvider } from '@/features/file-view/types/binary-file'
|
import { hasProvider } from '@/features/file-view/types/binary-file'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zotero-specific error messages when refreshing a linked file fails.
|
||||||
|
* Registered via overleafModuleImports.tprFileViewRefreshError.
|
||||||
|
*/
|
||||||
|
|
||||||
type TPRFileViewRefreshErrorProps = {
|
type TPRFileViewRefreshErrorProps = {
|
||||||
file: LinkedFile<keyof LinkedFileData>
|
file: LinkedFile<keyof LinkedFileData>
|
||||||
refreshError: string
|
refreshError: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Zotero-specific error messages when refreshing a linked file fails.
|
|
||||||
* Registered via overleafModuleImports.tprFileViewRefreshError.
|
|
||||||
*/
|
|
||||||
export function TPRFileViewRefreshError({
|
export function TPRFileViewRefreshError({
|
||||||
file,
|
file,
|
||||||
refreshError,
|
refreshError,
|
||||||
|
|||||||
@@ -1,167 +1,119 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FormEventHandler, useEffect, useState, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import useAsync from '@/shared/hooks/use-async'
|
||||||
import { getJSON } from '@/infrastructure/fetch-json'
|
import { getJSON } from '@/infrastructure/fetch-json'
|
||||||
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
|
import { debugConsole } from '@/utils/debugging'
|
||||||
import { useFileTreeCreateForm } from '@/features/file-tree/contexts/file-tree-create-form'
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
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'
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
|
||||||
type ZoteroGroup = {
|
import FileTreeModalCreateFileMode from '@/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode'
|
||||||
id: string
|
import FileTreeCreateNameProvider from '@/features/file-tree/contexts/file-tree-create-name'
|
||||||
name: string
|
import FileTreeImportFromZotero from './file-tree-import-from-zotero'
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateFileMode() {
|
export function CreateFileMode() {
|
||||||
const { refProviders } = useFileTreeMainContext()
|
const { t } = useTranslation()
|
||||||
const isLinked = (refProviders as Record<string, boolean>)?.zotero
|
|
||||||
|
|
||||||
if (!isLinked) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileTreeModalCreateFileMode
|
<FileTreeModalCreateFileMode
|
||||||
mode="zotero"
|
mode="zotero"
|
||||||
icon="library_books"
|
icon="library_books"
|
||||||
label="From Zotero"
|
label={t('from_provider', { provider: 'Zotero' })}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateFilePane() {
|
export function CreateFilePane() {
|
||||||
const { newFileCreateMode } = useFileTreeActionable()
|
const { newFileCreateMode } = useFileTreeActionable()
|
||||||
|
|
||||||
if (newFileCreateMode !== 'zotero') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ZoteroCreateFilePane />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ZoteroCreateFilePane() {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { setValid } = useFileTreeCreateForm()
|
const isZoteroMode = newFileCreateMode === 'zotero'
|
||||||
const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable()
|
|
||||||
|
|
||||||
const [groups, setGroups] = useState<ZoteroGroup[]>([])
|
const {
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string>('')
|
runAsync,
|
||||||
const [name, setName] = useState('zotero.bib')
|
data: groups,
|
||||||
const [loadingGroups, setLoadingGroups] = useState(true)
|
isSuccess: isGroupsLoaded,
|
||||||
const [groupsError, setGroupsError] = useState('')
|
isError: isGroupsError,
|
||||||
|
isLoading: isGroupsLoading,
|
||||||
|
} = useAsync<ZoteroGroup[]>()
|
||||||
|
|
||||||
|
const loadGroups = useCallback(() => {
|
||||||
|
return runAsync(getJSON('/user/zotero/groups'))
|
||||||
|
.catch(err => {
|
||||||
|
debugConsole.error(err?.data?.message || err?.message || err)
|
||||||
|
})
|
||||||
|
}, [runAsync])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValid(!!name && !loadingGroups)
|
if (!isZoteroMode) return
|
||||||
}, [setValid, name, loadingGroups])
|
|
||||||
|
loadGroups()
|
||||||
|
}, [loadGroups, isZoteroMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
if (!isZoteroMode || !isGroupsLoaded || groups) return
|
||||||
|
|
||||||
setLoadingGroups(true)
|
const channel = new BroadcastChannel('zotero')
|
||||||
setGroupsError('')
|
channel.onmessage = event => {
|
||||||
|
if (event.data?.type === 'zotero-linked') {
|
||||||
getJSON('/zotero/groups')
|
loadGroups()
|
||||||
.then((data: { groups: ZoteroGroup[] }) => {
|
}
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
setGroups(data.groups || [])
|
|
||||||
setLoadingGroups(false)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
setGroupsError(t('zotero_groups_loading_error'))
|
|
||||||
setLoadingGroups(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
}
|
||||||
}, [t])
|
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler = useCallback(
|
return () => channel.close()
|
||||||
event => {
|
}, [isZoteroMode, isGroupsLoaded, groups, loadGroups])
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
if (!name || loadingGroups || inFlight) {
|
if (!isZoteroMode) return null
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: Record<string, string> = {}
|
if (isGroupsLoading) {
|
||||||
|
return (
|
||||||
if (selectedGroupId) {
|
<>
|
||||||
data.zoteroGroupId = selectedGroupId
|
<br/>
|
||||||
}
|
<div role="status" className="loading d-flex justify-content-center align-items-center fs-5">
|
||||||
|
<div
|
||||||
finishCreatingLinkedFile({
|
aria-hidden="true"
|
||||||
name,
|
className="spinner-border spinner-border-sm"
|
||||||
provider: 'zotero',
|
></div>
|
||||||
data,
|
{t('loading') + '…'}
|
||||||
})
|
</div>
|
||||||
},
|
</>
|
||||||
[
|
)
|
||||||
name,
|
} else if (isGroupsLoaded) {
|
||||||
loadingGroups,
|
if (groups) {
|
||||||
inFlight,
|
return (
|
||||||
selectedGroupId,
|
<FileTreeCreateNameProvider initialName="zotero.bib">
|
||||||
finishCreatingLinkedFile,
|
<FileTreeImportFromZotero
|
||||||
]
|
groups={groups}
|
||||||
)
|
/>
|
||||||
|
</FileTreeCreateNameProvider>
|
||||||
return (
|
)
|
||||||
<form
|
} else {
|
||||||
className="form-controls"
|
return (
|
||||||
id="create-file"
|
<div className = "referencesImportModal">
|
||||||
noValidate
|
<p>{t('zotero_sync_description')}</p>
|
||||||
onSubmit={handleSubmit}
|
<p>
|
||||||
>
|
<OLButton
|
||||||
{groupsError && (
|
variant="primary"
|
||||||
<OLNotification type="error" content={groupsError} className="mb-3" />
|
onClick={() => {
|
||||||
)}
|
window.open(
|
||||||
|
'/user/zotero/oauth?popup=1',
|
||||||
<OLFormGroup controlId="zotero-file-name">
|
'_blank',
|
||||||
<OLFormLabel>{t('file_name')}</OLFormLabel>
|
'width=600,height=700'
|
||||||
<OLFormControl
|
)
|
||||||
type="text"
|
}}
|
||||||
placeholder="zotero.bib"
|
>
|
||||||
required
|
{t('link_to_zotero')}
|
||||||
value={name}
|
</OLButton>
|
||||||
disabled={inFlight}
|
</p>
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
</div>
|
||||||
setName(e.target.value)
|
)
|
||||||
}
|
}
|
||||||
/>
|
} else if (isGroupsError) {
|
||||||
</OLFormGroup>
|
return (
|
||||||
|
<OLNotification
|
||||||
<OLFormGroup controlId="zotero-library-select">
|
type="error"
|
||||||
<OLFormLabel>Library</OLFormLabel>
|
content={t('zotero_groups_loading_error', {
|
||||||
{loadingGroups ? (
|
provider: t('zotero'),
|
||||||
<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,61 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useAsync from '@/shared/hooks/use-async'
|
||||||
|
import { getJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
import ZoteroLogo from '@/shared/svgs/zotero-logo'
|
||||||
|
import IntegrationCard from '@/features/integrations-panel/integration-card'
|
||||||
|
import ZoteroLinkInfoModal from './zotero-link-info-modal'
|
||||||
|
|
||||||
|
const ZoteroLinkCard = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isError,
|
||||||
|
runAsync,
|
||||||
|
data: isConnected
|
||||||
|
} = useAsync<boolean>()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
runAsync(getJSON('/user/zotero/status'))
|
||||||
|
.then(isConnected => {
|
||||||
|
if (isConnected) {
|
||||||
|
setShowModal(true)
|
||||||
|
} else {
|
||||||
|
window.open(
|
||||||
|
'/user/zotero/oauth?popup=1',
|
||||||
|
'_blank',
|
||||||
|
'width=600,height=700'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
debugConsole.error(
|
||||||
|
err?.data?.message || err?.message || err
|
||||||
|
)
|
||||||
|
setShowModal(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IntegrationCard
|
||||||
|
title={t('zotero')}
|
||||||
|
description={t('cite_directly_or_import_references')}
|
||||||
|
icon={<ZoteroLogo size={32} />}
|
||||||
|
showPaywallBadge={false}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ZoteroLinkInfoModal
|
||||||
|
show={showModal}
|
||||||
|
handleHide={() => setShowModal(false)}
|
||||||
|
isError={isError}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZoteroLinkCard
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 {
|
||||||
|
OLModal,
|
||||||
|
OLModalHeader,
|
||||||
|
OLModalTitle,
|
||||||
|
OLModalBody,
|
||||||
|
OLModalFooter,
|
||||||
|
} from '@/shared/components/ol/ol-modal'
|
||||||
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
|
||||||
|
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
|
||||||
|
|
||||||
|
type ZoteroLinkInfoModalModalProps = {
|
||||||
|
show: boolean
|
||||||
|
isError: any
|
||||||
|
handleHide: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZoteroLinkInfoModal = ({ show, isError, handleHide }: ZoteroLinkInfoModalProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [showUnlinkInfo, setShowUnlinkInfo] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading: isUnlinking,
|
||||||
|
isError: isErrorUnlink,
|
||||||
|
runAsync: runAsyncUnlink,
|
||||||
|
} = useAsync<void>()
|
||||||
|
|
||||||
|
const handleUnlink = () => {
|
||||||
|
runAsyncUnlink(deleteJSON('/user/zotero'))
|
||||||
|
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||||
|
handleHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return
|
||||||
|
setShowUnlinkInfo(false)
|
||||||
|
}, [show])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OLModal show={show} onHide={handleHide} backdrop="static">
|
||||||
|
<OLModalHeader closeButton>
|
||||||
|
<OLModalTitle>{t('zotero_integration')}</OLModalTitle>
|
||||||
|
</OLModalHeader>
|
||||||
|
|
||||||
|
<OLModalBody>
|
||||||
|
{showUnlinkInfo ? (
|
||||||
|
<OLNotification
|
||||||
|
type="warning"
|
||||||
|
content={t('unlink_warning_reference', { provider: t('zotero') })}
|
||||||
|
/>
|
||||||
|
) : isError ? (
|
||||||
|
<OLNotification
|
||||||
|
type="error"
|
||||||
|
content={t('problem_checking_connection_with_provider', {
|
||||||
|
provider: t('zotero'),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OLNotification
|
||||||
|
type="info"
|
||||||
|
content={
|
||||||
|
<Trans
|
||||||
|
i18nKey="you_currently_have_x_linked_with_your_overleaf_account"
|
||||||
|
values={{ managers: t('zotero') }}
|
||||||
|
components={[<b />]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OLModalBody>
|
||||||
|
|
||||||
|
<OLModalFooter>
|
||||||
|
{showUnlinkInfo ? (
|
||||||
|
<OLButton
|
||||||
|
variant="danger"
|
||||||
|
disabled={isUnlinking}
|
||||||
|
onClick={handleUnlink}
|
||||||
|
>
|
||||||
|
{t('confirm')}
|
||||||
|
</OLButton>
|
||||||
|
) : (
|
||||||
|
<OLButton
|
||||||
|
variant="danger-ghost"
|
||||||
|
disabled={isUnlinking}
|
||||||
|
onClick={() => setShowUnlinkInfo(true)}
|
||||||
|
>
|
||||||
|
{t('unlink')}
|
||||||
|
</OLButton>
|
||||||
|
)}
|
||||||
|
<OLButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleHide}
|
||||||
|
>
|
||||||
|
{t('close')}
|
||||||
|
</OLButton>
|
||||||
|
</OLModalFooter>
|
||||||
|
</OLModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZoteroLinkInfoModal
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useCallback, useState } from 'react'
|
|
||||||
import { postJSON } from '@/infrastructure/fetch-json'
|
|
||||||
import getMeta from '@/utils/meta'
|
import getMeta from '@/utils/meta'
|
||||||
|
import { getJSON, deleteJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import useAsync from '@/shared/hooks/use-async'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
import OLButton from '@/shared/components/ol/ol-button'
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
|
import {
|
||||||
|
OLModal,
|
||||||
|
OLModalBody,
|
||||||
|
OLModalFooter,
|
||||||
|
OLModalHeader,
|
||||||
|
OLModalTitle,
|
||||||
|
} from '@/shared/components/ol/ol-modal'
|
||||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
|
||||||
import ZoteroLogo from '@/shared/svgs/zotero-logo'
|
import ZoteroLogo from '@/shared/svgs/zotero-logo'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,120 +24,159 @@ import ZoteroLogo from '@/shared/svgs/zotero-logo'
|
|||||||
*
|
*
|
||||||
* Registered via overleafModuleImports.referenceLinkingWidgets.
|
* Registered via overleafModuleImports.referenceLinkingWidgets.
|
||||||
*/
|
*/
|
||||||
export default function ZoteroWidget() {
|
export const ZoteroWidget = function ZoteroWidget() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const user = getMeta('ol-user')
|
const { appName } = getMeta('ol-ExposedSettings')
|
||||||
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(
|
const {
|
||||||
async (e: React.FormEvent) => {
|
isLoading: isCheckingConn,
|
||||||
e.preventDefault()
|
isError: isErrorConnCheck,
|
||||||
if (!apiKey) return
|
runAsync: runAsyncConnCheck,
|
||||||
setProcessing(true)
|
data: isConnected,
|
||||||
setError('')
|
setData: setConnState,
|
||||||
setSuccess('')
|
} = useAsync<boolean>()
|
||||||
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 () => {
|
const {
|
||||||
setProcessing(true)
|
isLoading: isUnlinking,
|
||||||
setError('')
|
isError: isErrorUnlink,
|
||||||
setSuccess('')
|
runAsync: runAsyncUnlink,
|
||||||
try {
|
} = useAsync<void>()
|
||||||
await postJSON('/zotero/unlink')
|
|
||||||
setProcessing(false)
|
const [showUnlinkModal, setShowUnlinkModal] = useState(false)
|
||||||
setIsLinked(false)
|
|
||||||
} catch (err) {
|
const handleConnCheck = useCallback(() => {
|
||||||
setError(t('generic_something_went_wrong'))
|
runAsyncConnCheck(getJSON('/user/zotero/status'))
|
||||||
setProcessing(false)
|
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||||
}
|
}, [runAsyncConnCheck])
|
||||||
}, [t])
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleConnCheck()
|
||||||
|
}, [handleConnCheck])
|
||||||
|
|
||||||
|
const handleUnlink = useCallback(() => {
|
||||||
|
runAsyncUnlink(deleteJSON('/user/zotero'))
|
||||||
|
.then(() => setConnState(false))
|
||||||
|
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||||
|
.finally(() => setShowUnlinkModal(false))
|
||||||
|
}, [runAsyncUnlink])
|
||||||
|
|
||||||
|
if (isCheckingConn) {
|
||||||
|
return (
|
||||||
|
<div className="settings-widget-container">
|
||||||
|
<div>
|
||||||
|
<ZoteroLogo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="description-container">
|
||||||
|
<div className="title-row">
|
||||||
|
<h4>GitHub</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="small">
|
||||||
|
<span>{t('loading')}…</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-widget-container">
|
<>
|
||||||
<div>
|
<div className="settings-widget-container">
|
||||||
<ZoteroLogo />
|
<div>
|
||||||
</div>
|
<ZoteroLogo size={40} />
|
||||||
<div className="description-container">
|
|
||||||
<div className="title-row">
|
|
||||||
<h4>{t('zotero')}</h4>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="small">
|
|
||||||
{t('zotero_sync_description', {
|
<div className="description-container">
|
||||||
appName:
|
<div className="title-row">
|
||||||
getMeta('ol-ExposedSettings')?.appName || 'Overleaf',
|
<h4 id="zotero-link">{t('zotero')}</h4>
|
||||||
})}
|
</div>
|
||||||
</p>
|
|
||||||
{error && <OLNotification type="error" content={error} />}
|
<p className="small">
|
||||||
{success && <OLNotification type="success" content={success} />}
|
{t('zotero_sync_description', { appName })}
|
||||||
{!isLinked && (
|
</p>
|
||||||
<form onSubmit={handleLink}>
|
|
||||||
<p className="small text-muted">
|
{isErrorConnCheck && (
|
||||||
Create an API key at{' '}
|
<OLNotification
|
||||||
<a
|
type="error"
|
||||||
href="https://www.zotero.org/settings/keys/new"
|
content={t('problem_checking_connection_with_provider', { provider: t('zotero') })}
|
||||||
target="_blank"
|
/>
|
||||||
rel="noopener noreferrer"
|
)}
|
||||||
>
|
|
||||||
zotero.org/settings/keys
|
{isErrorUnlink && (
|
||||||
</a>{' '}
|
<OLNotification
|
||||||
with <strong>Allow library access</strong> and{' '}
|
type="error"
|
||||||
<strong>Allow read access to all groups</strong> enabled.
|
content={t('generic_something_went_wrong')}
|
||||||
</p>
|
/>
|
||||||
<div className="form-group">
|
)}
|
||||||
<OLFormControl
|
</div>
|
||||||
type="text"
|
|
||||||
placeholder="Paste your Zotero API key"
|
<div>
|
||||||
value={apiKey}
|
{isConnected ? (
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setApiKey(e.target.value.trim())
|
|
||||||
}
|
|
||||||
disabled={processing}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<OLButton
|
<OLButton
|
||||||
variant="primary"
|
variant="danger-ghost"
|
||||||
type="submit"
|
onClick={() => setShowUnlinkModal(true)}
|
||||||
disabled={!apiKey}
|
disabled={isUnlinking}
|
||||||
isLoading={processing}
|
|
||||||
>
|
>
|
||||||
{t('link_to_zotero')}
|
{isUnlinking ? t('unlinking') : t('unlink')}
|
||||||
</OLButton>
|
</OLButton>
|
||||||
</form>
|
) : isErrorConnCheck ? (
|
||||||
)}
|
<OLButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleConnCheck}
|
||||||
|
>
|
||||||
|
{t('reconnect')}
|
||||||
|
</OLButton>
|
||||||
|
) : (
|
||||||
|
<OLButton
|
||||||
|
variant="secondary"
|
||||||
|
href="/user/zotero/oauth?popup=0"
|
||||||
|
>
|
||||||
|
{t('link')}
|
||||||
|
</OLButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
{isLinked && (
|
<OLModal
|
||||||
|
id="zotero-unlink-modal"
|
||||||
|
show={showUnlinkModal}
|
||||||
|
onHide={() => setShowUnlinkModal(false)}
|
||||||
|
backdrop="static"
|
||||||
|
>
|
||||||
|
<OLModalHeader>
|
||||||
|
<OLModalTitle>
|
||||||
|
{t('unlink_reference', {
|
||||||
|
provider: 'Zotero',
|
||||||
|
})}
|
||||||
|
</OLModalTitle>
|
||||||
|
</OLModalHeader>
|
||||||
|
|
||||||
|
<OLModalBody>
|
||||||
|
<p>
|
||||||
|
{t('unlink_warning_reference', {
|
||||||
|
provider: 'Zotero',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</OLModalBody>
|
||||||
|
|
||||||
|
<OLModalFooter>
|
||||||
|
<OLButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowUnlinkModal(false)}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</OLButton>
|
||||||
|
|
||||||
<OLButton
|
<OLButton
|
||||||
variant="danger-ghost"
|
variant="danger-ghost"
|
||||||
onClick={handleUnlink}
|
onClick={handleUnlink}
|
||||||
isLoading={processing}
|
disabled={isUnlinking}
|
||||||
>
|
>
|
||||||
{t('unlink')}
|
{isUnlinking ? t('unlinking') : t('unlink')}
|
||||||
</OLButton>
|
</OLButton>
|
||||||
)}
|
</OLModalFooter>
|
||||||
</div>
|
</OLModal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ if (Settings.enabledLinkedFileTypes?.includes('zotero')) {
|
|||||||
'./app/src/ZoteroLinkedFileAgent.mjs'
|
'./app/src/ZoteroLinkedFileAgent.mjs'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const siteUrl = Settings.siteUrl.replace(/\/+$/, '') || 'http://localhost'
|
||||||
|
Settings.zotero = {
|
||||||
|
clientKey: process.env.ZOTERO_CLIENT_KEY,
|
||||||
|
clientSecret: process.env.ZOTERO_CLIENT_SECRET,
|
||||||
|
callbackURL: `${siteUrl}/user/zotero/oauth/callback`,
|
||||||
|
},
|
||||||
|
|
||||||
ZoteroModule = {
|
ZoteroModule = {
|
||||||
router: ZoteroRouter,
|
router: ZoteroRouter,
|
||||||
linkedFileAgents: {
|
linkedFileAgents: {
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
"nocache": "^2.1.0",
|
"nocache": "^2.1.0",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
|
"oauth-1.0a": "^2.2.6",
|
||||||
"on-headers": "^1.0.2",
|
"on-headers": "^1.0.2",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"overleaf-editor-core": "workspace:*",
|
"overleaf-editor-core": "workspace:*",
|
||||||
|
|||||||
Reference in New Issue
Block a user