mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d796ccb39d | ||
|
|
b8a622a390 | ||
|
|
21fbf6c5dc | ||
|
|
11b21d470b | ||
|
|
566c41c7c9 | ||
|
|
ae07e781ab | ||
|
|
5c9fef4d03 | ||
|
|
6c4829eea4 | ||
|
|
2da513262a | ||
|
|
5c6ab5fad3 | ||
|
|
ca8fd03a2e | ||
|
|
01d5002ea9 | ||
|
|
5b6154bcde | ||
|
|
21165d882e | ||
|
|
a683019198 | ||
|
|
f1b9809f35 |
@@ -139,6 +139,10 @@ const rawLinkedFileData = z.discriminatedUnion('provider', [
|
||||
// default this to 'bibtex' (ZoteroAgent `_getFormat`)
|
||||
format: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
group_id: zz.routeSegment().nullish(),
|
||||
collection_key: z.union([
|
||||
z.literal('publications'),
|
||||
z.string().regex(/^[A-Za-z0-9]{8}$/),
|
||||
]).optional(),
|
||||
importer_id: z.string().optional(),
|
||||
importer_name: z.string().optional(),
|
||||
v1_importer_id: z.number().optional(),
|
||||
|
||||
@@ -8,6 +8,3 @@ server-ce/test
|
||||
services/web/scripts/plan-prices
|
||||
services/web/scripts/translations
|
||||
services/web/scripts/ukamf
|
||||
|
||||
# local LanguageTool ngrams data (deploy-only, not part of the image)
|
||||
tools/languagetools_server_compose
|
||||
|
||||
@@ -68,6 +68,8 @@ ADD server-ce/config/crontab-history /etc/cron.d/crontab-history
|
||||
RUN chmod 600 /etc/cron.d/crontab-history
|
||||
ADD server-ce/config/crontab-deletion /etc/cron.d/crontab-deletion
|
||||
RUN chmod 600 /etc/cron.d/crontab-deletion
|
||||
ADD server-ce/config/crontab-instance-stats /etc/cron.d/crontab-instance-stats
|
||||
RUN chmod 600 /etc/cron.d/crontab-instance-stats
|
||||
|
||||
# Copy Phusion Image startup and shutdown scripts to their locations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
&& apt-get install -y \
|
||||
unattended-upgrades \
|
||||
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
|
||||
qpdf imagemagick ghostscript \
|
||||
qpdf \
|
||||
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
|
||||
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
|
||||
# install Node.js https://github.com/nodesource/distributions#nodejs
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM pandoc/core:3.11-alpine
|
||||
|
||||
ENTRYPOINT []
|
||||
|
||||
RUN apk add --no-cache zip
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# replacement for quay.io/sharelatex/pdftocairo:24.02 (clsitex PDF→JPEG)
|
||||
#
|
||||
# ALIASES: pdftocairo-ol:25.12.0
|
||||
#
|
||||
# alpine:3.24 with poppler (25.12.0, >= 24.02 required by CLSI).
|
||||
#
|
||||
# Why: CLSI convertPDFToJPEG runs `pdftocairo -jpeg ...` against this image
|
||||
# for preview (794px, q90) and thumbnail (190px, q50) JPEGs of compiled PDFs.
|
||||
#
|
||||
# The SaaS image is Ubuntu-based; alpine 3.24's poppler 25.12.0 covers exactly
|
||||
# the flags CLSI uses (-jpeg -jpegopt -singlefile -scale-to-x -scale-to-y), and
|
||||
# the image is tiny.
|
||||
#
|
||||
# images/pandoc-amd64/Dockerfile is the sibling (pandoc/core + zip). Same pattern:
|
||||
# ENTRYPOINT [] because CLSI docker-runs with an explicit command (no
|
||||
# --entrypoint override).
|
||||
|
||||
FROM alpine:3.24
|
||||
|
||||
ENTRYPOINT []
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache poppler-utils \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
@@ -1,51 +0,0 @@
|
||||
# replacement for quay.io/sharelatex/png2pdf:2026-06-24 (clsitex PNG→PDF)
|
||||
#
|
||||
# Engine: img2pdf (josch) — lossless. For PNG input the IDAT streams are
|
||||
# embedded verbatim into the PDF (no re-encode); the alpha channel becomes
|
||||
# a PDF soft mask (/SMask), also lossless. That is the mechanism of the
|
||||
# whole feature: pdfTeX copies PDF image streams cheaply, while PNGs with
|
||||
# alpha/gamma/palette/interlace are re-decoded on every compile, which is
|
||||
# what made them "slow" and what fills CLSI's slow-PNG list.
|
||||
#
|
||||
# The SaaS tool is proprietary. The Go port (go/services/clsitex/png2pdf)
|
||||
# pins the exact container contract this image fulfils:
|
||||
# ENTRYPOINT + ["--in-place", "--", "<relpath>", ...] CWD=/compile
|
||||
# stdout: one "Converted <f> to PDF" line per file (counted by Go)
|
||||
# exit non-zero on first failure (CLSI falls back to the original PNGs)
|
||||
#
|
||||
# Why git@commit instead of `pip install img2pdf==0.6.3`:
|
||||
# 0.6.3 (2025-11-06) is the last PyPI release, and it PRE-DATES the
|
||||
# parse_png quadratic-rewrite fix (2025-11-16, "my 311MB ARCHD at 600dpi
|
||||
# with 17638 IDAT records went from many minutes to 320ms"). The slow-PNG
|
||||
# list is populated by BIG PNGs (min 1MB, no upper bound), so we pin main
|
||||
# at this commit: it is still reproducible (exact hash) and carries the
|
||||
# fix plus the JPEG2000/ICC and pikepdf-writer-close follow-ups.
|
||||
#
|
||||
# pikepdf + pillow have musllinux cp314 wheels for this python version, so
|
||||
# no compiler is needed; git is needed only for the pinned checkout.
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
ARG IMG2PDF_REF=0fafa2c312be50c68a07e2331a89bf4adbd274ed
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache git \
|
||||
&& pip install --no-cache-dir --disable-pip-version-check \
|
||||
"git+https://gitlab.mister-muffin.de/josch/img2pdf.git@${IMG2PDF_REF}#egg=img2pdf" \
|
||||
pikepdf==10.15.0 \
|
||||
pillow==12.3.0 \
|
||||
packaging \
|
||||
&& rm -rf /root/.cache/pip /usr/src/python/lib*
|
||||
|
||||
# shim: arg parsing, leading-dash path handling (paths derive from URL
|
||||
# paths and start with '-'), temp-file + atomic replace (input must survive
|
||||
# a failed conversion — CLSI renames the untouched original as fallback),
|
||||
# and the "Converted <f> to PDF" stdout line Go counts.
|
||||
COPY server-ce/png2pdf/png2pdf.sh /usr/local/bin/png2pdf
|
||||
RUN chmod 0755 /usr/local/bin/png2pdf
|
||||
|
||||
# The Go runner passes the arg list ["--in-place", "--", ...] as the container
|
||||
# command, never a binary name — see the pdftocairo image (ENTRYPOINT []) for
|
||||
# the contrast: that image provides the binary NAME in its command, this one
|
||||
# relies on the image's own ENTRYPOINT.
|
||||
ENTRYPOINT ["/usr/local/bin/png2pdf"]
|
||||
+4
-58
@@ -11,33 +11,20 @@ export OVERLEAF_BASE_TAG ?= sharelatex/sharelatex-base:$(BRANCH_NAME_TAG_SAFE)-$
|
||||
export OVERLEAF_BRANCH ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)
|
||||
export OVERLEAF_LATEST ?= sharelatex/sharelatex
|
||||
export OVERLEAF_TAG ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
|
||||
export OVERLEAF_PANDOC_BRANCH ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)
|
||||
export OVERLEAF_PANDOC_LATEST ?= sharelatex/sharelatex-pandoc
|
||||
export OVERLEAF_PANDOC_TAG ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
|
||||
export OVERLEAF_PDFTOCAIRO ?= sharelatex/pdftocairo:24.02
|
||||
export OVERLEAF_PNG2PDF ?= sharelatex/png2pdf:2026-06-24
|
||||
|
||||
|
||||
all: build-base build-community build-pandoc build-pdftocairo build-png2pdf
|
||||
all: build-base build-community
|
||||
|
||||
clean:
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG) $(OVERLEAF_PDFTOCAIRO) $(OVERLEAF_PNG2PDF)
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG)
|
||||
|
||||
refresh-cache: refresh-cache-branch
|
||||
refresh-cache-branch:
|
||||
docker inspect $(OVERLEAF_BASE_BRANCH) > /dev/null && docker pull $(OVERLEAF_BASE_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_BRANCH) > /dev/null && docker pull $(OVERLEAF_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_BRANCH) > /dev/null && docker pull $(OVERLEAF_PANDOC_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
refresh-cache: refresh-cache-latest
|
||||
refresh-cache-latest:
|
||||
docker inspect $(OVERLEAF_BASE_LATEST) > /dev/null && docker pull $(OVERLEAF_BASE_LATEST) || true
|
||||
docker inspect $(OVERLEAF_LATEST) > /dev/null && docker pull $(OVERLEAF_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_LATEST) > /dev/null && docker pull $(OVERLEAF_PANDOC_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
build-base:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
@@ -52,6 +39,7 @@ build-base:
|
||||
--tag $(OVERLEAF_BASE_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
|
||||
build-community:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
@@ -66,53 +54,11 @@ build-community:
|
||||
--tag $(OVERLEAF_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-pandoc:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PANDOC_LATEST) \
|
||||
--cache-from $(OVERLEAF_PANDOC_BRANCH) \
|
||||
--file Dockerfile-pandoc \
|
||||
--tag $(OVERLEAF_PANDOC_TAG) \
|
||||
--tag $(OVERLEAF_PANDOC_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-pdftocairo:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PDFTOCAIRO) \
|
||||
--file Dockerfile-pdftocairo \
|
||||
--tag $(OVERLEAF_PDFTOCAIRO) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-png2pdf:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PNG2PDF) \
|
||||
--file Dockerfile-png2pdf \
|
||||
--tag $(OVERLEAF_PNG2PDF) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
push:
|
||||
docker push $(OVERLEAF_BASE_TAG)
|
||||
docker push $(OVERLEAF_BASE_BRANCH)
|
||||
docker push $(OVERLEAF_TAG)
|
||||
docker push $(OVERLEAF_BRANCH)
|
||||
docker push $(OVERLEAF_PANDOC_TAG)
|
||||
docker push $(OVERLEAF_PANDOC_BRANCH)
|
||||
docker push $(OVERLEAF_PDFTOCAIRO)
|
||||
docker push $(OVERLEAF_PNG2PDF)
|
||||
|
||||
SHELLCHECK_OPTS = \
|
||||
--shell=bash \
|
||||
@@ -134,5 +80,5 @@ shellcheck_fix:
|
||||
done
|
||||
|
||||
.PHONY: all \
|
||||
build-base build-community build-pandoc build-pdftocairo build-png2pdf\
|
||||
build-base build-community \
|
||||
shellcheck shellcheck_fix
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
50 * * * * root bash /overleaf/cron/collect-instance-stats.sh >> /var/log/overleaf/cron-instance-stats.log 2>&1
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eu
|
||||
|
||||
echo "--------------------------"
|
||||
echo "Collect instance statistics"
|
||||
echo "--------------------------"
|
||||
date
|
||||
|
||||
source /etc/container_environment.sh
|
||||
source /etc/overleaf/env.sh
|
||||
|
||||
# `@overleaf/settings` resolves `config/settings.defaults.*` relative to
|
||||
# `process.cwd()`, so ensure we're running from `services/web`.
|
||||
cd /overleaf/services/web
|
||||
|
||||
node /overleaf/services/web/modules/instance-stats/app/src/collectInstanceStats.mjs
|
||||
|
||||
@@ -70,17 +70,6 @@ server {
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
# handle output.zip, which clsi builds on demand rather than writing to disk
|
||||
location ~ ^/project/[0-9a-f]+(/user/[0-9a-f]+)?/build/[0-9a-f-]+/output/output\.zip$ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 10m;
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
# handle output files for specific users
|
||||
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
|
||||
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CLSI "png2pdf" container contract shim (engine: img2pdf 0.6.3, josch).
|
||||
# Replaces the proprietary quay.io/sharelatex/png2pdf:2026-06-24.
|
||||
#
|
||||
# Invoked by the CLSI docker runner (go/services/clsitex/dockerrunner.go, go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# args: ["--in-place", "--", "<relpath1>", ...] CWD=/compile
|
||||
# the docker runner does NOT supply a binary name in the command, so the
|
||||
# image ENTRYPOINT is this script.
|
||||
#
|
||||
# Paths: each arg is a conversion path CLSI downloaded, RELATIVE to the
|
||||
# mounted cache dir (CWD /compile). Real CLSI derives it from the file
|
||||
# URL path (u.Path: '/'->'-') + mtime + ".opt<10-hex cacheKey>",
|
||||
# e.g. ".-uploads-filestore-a1b2c3d4-0-1-1756600000000.opta1b2c3d4a1bc"
|
||||
# (leading dash, no directory separator).
|
||||
#
|
||||
# "IN-PLACE" means "rewrite each input PATH as a PDF". CLSI then renames
|
||||
# each path to its own ".opt" cache entry and copies it into the compile
|
||||
# dir (urlcache.CommitConversion) — PER FILE and UNCONDITIONALLY (sync.go:
|
||||
# a conversion error only logs a warning). So each converted file lands
|
||||
# in the cache even in a partially-failed run; an unconverted file is
|
||||
# committed as the original-PNG fallback.
|
||||
#
|
||||
# Contract (go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# - stdout: one "Converted <f> to PDF" per converted file (Go counts
|
||||
# lines matching ^Converted .+ to PDF$ -> stats.png2pdf).
|
||||
# - exit non-zero if anything fails: CLSI logs a warning and commits
|
||||
# each file's current on-disk state (converted or original).
|
||||
#
|
||||
# Why img2pdf: LOSSLESS. PNG IDAT is embedded verbatim into the PDF (no
|
||||
# re-encode); the alpha channel becomes a PDF /SMask, also lossless.
|
||||
# That is the mechanism of the feature: pdfTeX copies PDF image streams
|
||||
# cheaply, whereas PNGs with alpha/gamma/palette/interlace are re-decoded
|
||||
# from the PNG on every compile — what made them "slow". Without
|
||||
# --imgsize the image keeps its pHYs DPI, falling back to 96dpi (the same
|
||||
# default pdfLaTeX itself uses).
|
||||
#
|
||||
# Failure handling: convert to a temp + atomic replace, and CONTINUE with
|
||||
# the remaining files after a failure (their valid conversions are
|
||||
# committed by CLSI even though the run is reported failed). A failed
|
||||
# file stays exactly the original PNG CLSI downloaded.
|
||||
|
||||
set -u
|
||||
|
||||
# argv is exactly [--in-place]... ["--"] <files...>. The loop terminates:
|
||||
# every iteration either shifts, breaks, or exits.
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift ; break ;;
|
||||
--in-place) shift ;;
|
||||
*) echo "png2pdf: unexpected argument: $1" >&2 ; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "png2pdf: no input files (usage: --in-place -- <png>...)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rc=0
|
||||
for file in "$@"; do
|
||||
# Resolve against CWD so a leading '-' (or '.') never lands in an argv
|
||||
# slot of img2pdf/mv, where it would be parsed as an option.
|
||||
case "$file" in
|
||||
/ | /*) abs="$file" ;;
|
||||
*) abs="$(pwd)/$file" ;;
|
||||
esac
|
||||
tmp="${abs}.tmp"
|
||||
|
||||
if ! img2pdf --output "$tmp" "$abs"; then
|
||||
echo "png2pdf: conversion failed for $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
if ! mv -- "$tmp" "$abs"; then
|
||||
echo "png2pdf: failed to replace $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
echo "Converted $file to PDF"
|
||||
done
|
||||
exit "$rc"
|
||||
@@ -1,18 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Grant the service user access to the Docker socket group, same as the CLSI
|
||||
# run script. Needed for CONVERTER=pdftocairo-sandbox (filestore launches the
|
||||
# sibling conversion container through /var/run/docker.sock). Idempotent: a
|
||||
# fresh container may have no such group yet, and CLSI's run script does the
|
||||
# same thing, so just append to the membership if it exists.
|
||||
if [ -e '/var/run/docker.sock' ]; then
|
||||
DOCKER_GROUP=$(stat -c '%g' /var/run/docker.sock)
|
||||
if ! getent group dockeronhost >/dev/null; then
|
||||
groupadd --non-unique --gid "${DOCKER_GROUP}" dockeronhost
|
||||
fi
|
||||
usermod -aG dockeronhost www-data
|
||||
fi
|
||||
|
||||
source /etc/overleaf/env.sh
|
||||
export LISTEN_ADDRESS=127.0.0.1
|
||||
|
||||
|
||||
@@ -48,20 +48,7 @@ const DockerRunner = {
|
||||
image = `${Settings.texliveImageNameOveride}/${img}`
|
||||
}
|
||||
|
||||
if (
|
||||
Settings.path.sandboxedCompilesHostDirCache &&
|
||||
directory.startsWith(Settings.path.clsiCacheDir + Path.sep)
|
||||
) {
|
||||
// In: directory = '<clsiCacheDir>/projectId'
|
||||
// sandboxedCompilesHostDirCache = '/host/cache'
|
||||
// Out: directory = '/host/cache/projectId'
|
||||
// (png2pdf & friends operate on CLSI's per-project cache dir, which
|
||||
// is not under the compiles dir)
|
||||
directory = Path.join(
|
||||
Settings.path.sandboxedCompilesHostDirCache,
|
||||
Path.basename(directory)
|
||||
)
|
||||
} else if (compileGroup === 'synctex-output') {
|
||||
if (compileGroup === 'synctex-output') {
|
||||
// In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId'
|
||||
// directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId'
|
||||
// sandboxedCompilesHostDirOutput = '/host/output'
|
||||
@@ -106,35 +93,32 @@ const DockerRunner = {
|
||||
// logOptions = _.clone(options)
|
||||
// logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
|
||||
logger.debug({ projectId }, 'running docker container')
|
||||
// Bounded retry: a permanent container create/start failure (e.g.
|
||||
// "unable to find user" on a FROM-scratch image) must not busy-loop
|
||||
// forever — surface the error to the caller after a couple of attempts.
|
||||
let retriesLeft = 2
|
||||
const runAttempt = () => {
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500 && retriesLeft > 0) {
|
||||
retriesLeft -= 1
|
||||
logger.debug(
|
||||
{ err: error, projectId, retriesLeft },
|
||||
'error running container so destroying and retrying'
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500) {
|
||||
logger.debug(
|
||||
{ err: error, projectId },
|
||||
'error running container so destroying and retrying'
|
||||
)
|
||||
DockerRunner.destroyContainer(name, null, true, error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
callback
|
||||
)
|
||||
DockerRunner.destroyContainer(name, null, true, err => {
|
||||
if (err != null) {
|
||||
return callback(err)
|
||||
}
|
||||
runAttempt()
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
}
|
||||
)
|
||||
}
|
||||
runAttempt()
|
||||
}
|
||||
)
|
||||
|
||||
// pass back the container name to allow it to be killed
|
||||
return name
|
||||
@@ -269,10 +253,11 @@ const DockerRunner = {
|
||||
? Path.posix.join('/compile', cwd)
|
||||
: '/compile',
|
||||
NetworkDisabled: true,
|
||||
Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb
|
||||
User: Settings.clsi.docker.user,
|
||||
Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
|
||||
HostConfig: {
|
||||
// Memory only takes effect inside HostConfig
|
||||
Memory: 1024 * 1024 * 1024, // 1 Gb
|
||||
Binds: Object.entries(volumes).map(
|
||||
([hostVol, dockerVol]) => `${hostVol}:${dockerVol}`
|
||||
),
|
||||
|
||||
@@ -172,15 +172,7 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') {
|
||||
wordcount: { 'HostConfig.AutoRemove': true },
|
||||
synctex: { 'HostConfig.AutoRemove': true },
|
||||
'synctex-output': { 'HostConfig.AutoRemove': true },
|
||||
// The pdftocairo image is also built FROM scratch with no passwd file,
|
||||
// so the default named user (www-data) fails at container create with
|
||||
// "unable to find user ... no matching entries in passwd file". Run it
|
||||
// as the numeric uid/gid of this clsi process (which owns the files it
|
||||
// converts), same as the png2pdf image below.
|
||||
conversions: {
|
||||
'HostConfig.AutoRemove': true,
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
},
|
||||
conversions: { 'HostConfig.AutoRemove': true },
|
||||
// The png2pdf image is built FROM scratch and has no 'tex' user, so run
|
||||
// it as the same uid/gid as this clsi process, which owns the cache files
|
||||
// it converts in place.
|
||||
|
||||
@@ -161,6 +161,7 @@ app.post(
|
||||
'/project/:project_id/doc/:doc_id/change/accept',
|
||||
HttpController.acceptChanges
|
||||
)
|
||||
app.post('/project/:project_id/doc/:doc_id/comment', HttpController.addComment)
|
||||
app.post(
|
||||
'/project/:project_id/doc/:doc_id/comment/:comment_id/resolve',
|
||||
HttpController.resolveComment
|
||||
|
||||
@@ -457,6 +457,62 @@ const DocumentManager = {
|
||||
}
|
||||
},
|
||||
|
||||
async addComment(
|
||||
projectId,
|
||||
docId,
|
||||
threadId,
|
||||
userId,
|
||||
text,
|
||||
offset,
|
||||
expectedVersion
|
||||
) {
|
||||
// Circular dependencies. Import at runtime.
|
||||
const UpdateManager = require('./UpdateManager')
|
||||
|
||||
const { lines, version, ranges, type } = await DocumentManager.getDoc(
|
||||
projectId,
|
||||
docId
|
||||
)
|
||||
if (lines == null || version == null) {
|
||||
throw new Errors.NotFoundError(`document not found: ${docId}`)
|
||||
}
|
||||
if (type !== 'sharejs-text-ot') {
|
||||
throw new Errors.OTTypeMismatchError(type, 'sharejs-text-ot')
|
||||
}
|
||||
// Everything below is checked here, under the lock, because a rejection
|
||||
// inside applyUpdate happens after ShareJS has broadcast the op and
|
||||
// disconnects every editor on the doc.
|
||||
if (expectedVersion != null && expectedVersion !== version) {
|
||||
throw new Errors.DocumentValidationError('document changed', {
|
||||
docId,
|
||||
expectedVersion,
|
||||
version,
|
||||
})
|
||||
}
|
||||
if ((ranges?.comments?.length ?? 0) >= RangesManager.MAX_COMMENTS) {
|
||||
throw new Errors.DocumentValidationError('too many comments', { docId })
|
||||
}
|
||||
|
||||
const docText = lines.join('\n')
|
||||
if (docText.slice(offset, offset + text.length) !== text) {
|
||||
throw new Errors.DocumentValidationError(
|
||||
'comment text does not match document at offset',
|
||||
{ docId, offset }
|
||||
)
|
||||
}
|
||||
|
||||
// No meta.type 'external': that would show every editor the "document
|
||||
// updated externally" modal. The op is broadcast to all clients anyway.
|
||||
const update = {
|
||||
doc: docId,
|
||||
op: [{ c: text, p: offset, t: threadId }],
|
||||
v: version,
|
||||
meta: { user_id: userId, source: 'add-comment' },
|
||||
}
|
||||
await UpdateManager.promises.applyUpdate(projectId, docId, update)
|
||||
// Only ranges changed; the usual flushing persists them.
|
||||
},
|
||||
|
||||
async getComment(projectId, docId, commentId) {
|
||||
let { lines, ranges, type } = await DocumentManager.getDoc(projectId, docId)
|
||||
|
||||
@@ -749,6 +805,28 @@ const DocumentManager = {
|
||||
)
|
||||
},
|
||||
|
||||
async addCommentWithLock(
|
||||
projectId,
|
||||
docId,
|
||||
threadId,
|
||||
userId,
|
||||
text,
|
||||
offset,
|
||||
expectedVersion
|
||||
) {
|
||||
const UpdateManager = require('./UpdateManager')
|
||||
await UpdateManager.promises.lockUpdatesAndDo(
|
||||
DocumentManager.addComment,
|
||||
projectId,
|
||||
docId,
|
||||
threadId,
|
||||
userId,
|
||||
text,
|
||||
offset,
|
||||
expectedVersion
|
||||
)
|
||||
},
|
||||
|
||||
async deleteCommentWithLock(projectId, docId, threadId, userId) {
|
||||
const UpdateManager = require('./UpdateManager')
|
||||
await UpdateManager.promises.lockUpdatesAndDo(
|
||||
|
||||
@@ -534,6 +534,35 @@ const commentWithUserSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const addCommentSchema = z.object({
|
||||
params: docParamsSchema,
|
||||
body: z.strictObject({
|
||||
thread_id: zz.objectId(),
|
||||
user_id: zz.objectId(),
|
||||
text: z.string().min(1),
|
||||
offset: z.number().int().nonnegative(),
|
||||
version: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
async function addComment(req, res) {
|
||||
const { params, body } = parseReq(req, addCommentSchema)
|
||||
const { project_id: projectId, doc_id: docId } = params
|
||||
const { thread_id: threadId, user_id: userId, text, offset, version } = body
|
||||
logger.debug({ projectId, docId, threadId }, 'adding comment via http')
|
||||
await DocumentManager.promises.addCommentWithLock(
|
||||
projectId,
|
||||
docId,
|
||||
threadId,
|
||||
userId,
|
||||
text,
|
||||
offset,
|
||||
version
|
||||
)
|
||||
logger.debug({ projectId, docId, threadId }, 'added comment via http')
|
||||
res.sendStatus(204) // No Content
|
||||
}
|
||||
|
||||
async function resolveComment(req, res) {
|
||||
const { params, body } = parseReq(req, commentWithUserSchema, {
|
||||
logOnly: true,
|
||||
@@ -868,6 +897,7 @@ module.exports = {
|
||||
deleteProject: expressify(deleteProject),
|
||||
deleteMultipleProjects: expressify(deleteMultipleProjects),
|
||||
acceptChanges: expressify(acceptChanges),
|
||||
addComment: expressify(addComment),
|
||||
resolveComment: expressify(resolveComment),
|
||||
reopenComment: expressify(reopenComment),
|
||||
deleteComment: expressify(deleteComment),
|
||||
|
||||
@@ -2,7 +2,6 @@ import metrics from '@overleaf/metrics'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { callbackify } from 'node:util'
|
||||
import SafeExec from './SafeExec.js'
|
||||
import SandboxConverterRunner from './SandboxConverterRunner.js'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
@@ -23,9 +22,6 @@ export default {
|
||||
}
|
||||
|
||||
async function convert(sourcePath, requestedFormat) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, requestedFormat, 1500)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 1500
|
||||
return await _convert(sourcePath, requestedFormat, [
|
||||
@@ -53,9 +49,6 @@ async function convert(sourcePath, requestedFormat) {
|
||||
}
|
||||
|
||||
async function thumbnail(sourcePath) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', 700)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 700
|
||||
return await _convert(sourcePath, 'png', [
|
||||
@@ -88,9 +81,6 @@ async function thumbnail(sourcePath) {
|
||||
|
||||
async function preview(sourcePath) {
|
||||
const width = 1000
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', width)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
return await _convert(sourcePath, 'png', [
|
||||
'pdftocairo',
|
||||
@@ -119,50 +109,6 @@ async function preview(sourcePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pdftocairo conversion inside a sandboxed sibling container (no
|
||||
* network, all caps dropped, memory cap — see SandboxConverterRunner.js).
|
||||
*
|
||||
* Keeps the same contract as the local path: the result is written to
|
||||
* `${sourcePath}.${requestedFormat}`.
|
||||
*
|
||||
* @param {string} sourcePath
|
||||
* @param {string} requestedFormat 'png' or 'jpg'
|
||||
* @param {number} width output width in px
|
||||
* @return {Promise<{outputHostPath: string}>}
|
||||
*/
|
||||
async function _convertInSandbox(sourcePath, requestedFormat, width) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', { format: requestedFormat })
|
||||
}
|
||||
const ext = requestedFormat
|
||||
const workDir = '/work'
|
||||
// poppler appends the device extension to the output argument, so the
|
||||
// argument must end WITHOUT an extension; the result lands at
|
||||
// /work/input.pdf.<png|jpg> next to the input.
|
||||
const device = requestedFormat === 'png' ? '-png' : '-jpeg'
|
||||
const result = await SandboxConverterRunner.runConversion({
|
||||
sourcePath,
|
||||
containerArgs: [
|
||||
'pdftocairo',
|
||||
device,
|
||||
'-singlefile',
|
||||
'-scale-to-x',
|
||||
width.toString(),
|
||||
'-scale-to-y',
|
||||
'-1', // maintain aspect ratio
|
||||
`${workDir}/input.pdf`,
|
||||
`${workDir}/input.pdf`,
|
||||
],
|
||||
workDir,
|
||||
outputBase: 'input.pdf',
|
||||
outputExt: ext,
|
||||
destPath: `${sourcePath}.${requestedFormat}`,
|
||||
})
|
||||
// Same contract as the local converters: return the destination path string.
|
||||
return result.outputHostPath
|
||||
}
|
||||
|
||||
async function _convert(sourcePath, requestedFormat, command) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', {
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
// SandboxConverterRunner.js
|
||||
//
|
||||
// Runs a PDF→image conversion (pdftocairo) in a throwaway sibling container,
|
||||
// the same isolation profile CLSI's DockerRunner gives its conversion
|
||||
// containers (see services/clsi/app/js/DockerRunner.mjs and the `conversions`
|
||||
// / `png2pdf` compile groups in services/clsi/config/settings.defaults.cjs):
|
||||
//
|
||||
// - no network access (HostConfig.NetworkMode: 'none')
|
||||
// - all capabilities dropped (HostConfig.CapDrop: ['ALL'])
|
||||
// - no new privileges (SecurityOpt: 'no-new-privileges')
|
||||
// - hard memory cap
|
||||
// - numeric uid:gid of the overleaf service user (the scratch-based
|
||||
// converter images have no passwd entries, so named users fail)
|
||||
// - container is always removed afterwards
|
||||
//
|
||||
// Implemented as a minimal Docker Engine API client over the unix socket so
|
||||
// that no new npm dependency is required (this service is installed with
|
||||
// Yarn PnP; cross-service packages such as dockerode are not resolvable
|
||||
// here).
|
||||
//
|
||||
// IMPORTANT (bind mounts): the daemon runs on the Docker host, so the
|
||||
// bind source MUST be a daemon-host-visible path (in this deployment:
|
||||
// under the overleaf `./data` volume), never a container-internal path
|
||||
// such as /tmp. The caller passes a `hostDir` that satisfies this.
|
||||
|
||||
import http from 'node:http'
|
||||
import Path from 'node:path'
|
||||
import Fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
|
||||
const API_VERSION = 'v1.43' // conservative; the engine API is backwards compatible
|
||||
|
||||
/**
|
||||
* One request against the Docker Engine API on a unix socket.
|
||||
*
|
||||
* @param {string} socketPath
|
||||
* @param {string} method
|
||||
* @param {string} apiPath e.g. `/v1.43/containers/create?name=xyz`
|
||||
* @param {object} [opts]
|
||||
* @param {object|string} [opts.body] JSON body
|
||||
* @param {number} [opts.timeoutMs] hard deadline for the whole request
|
||||
* @param {Buffer|null} [opts.expectRaw] pass a truthy value to keep the body
|
||||
* as a Buffer (used for the log stream)
|
||||
* @return {Promise<{status: number, headers: object, body: any, raw: Buffer}>}
|
||||
*/
|
||||
function request(socketPath, method, apiPath, { body, timeoutMs = 60000, raw = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined || body === null ? null : JSON.stringify(body)
|
||||
let settled = false
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath,
|
||||
method,
|
||||
path: apiPath,
|
||||
headers: data
|
||||
? {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(data),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
(res) => {
|
||||
const chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
const buf = Buffer.concat(chunks)
|
||||
if (raw) return resolve({ status: res.statusCode, headers: res.headers, body: buf, raw: buf })
|
||||
let parsed = buf
|
||||
if (buf.length && (buf[0] === 0x7b || buf[0] === 0x5b)) {
|
||||
try {
|
||||
parsed = JSON.parse(buf.toString('utf8'))
|
||||
} catch (e) {
|
||||
// non-JSON body (e.g. plain text) — keep buffer
|
||||
parsed = buf
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: buf })
|
||||
})
|
||||
}
|
||||
)
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
req.destroy()
|
||||
reject(new Error(`docker api ${method} ${apiPath} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
req.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
if (data) req.write(data)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Decode the framed (TTY=false) docker log stream. */
|
||||
function decodeLogStream(buf) {
|
||||
if (!buf || !buf.length) return ''
|
||||
// 8-byte header per frame: [stream(1)] [size_be32(4)] [reserved(3)] + payload
|
||||
// (older daemons use a 4-byte header; try the 8-byte layout first)
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i + 8 <= buf.length) {
|
||||
const size = buf.readUInt32BE(i + 4)
|
||||
if (i + 8 + size > buf.length) break
|
||||
if (buf[i] === 1 || buf[i] === 2) out += buf.subarray(i + 8, i + 8 + size).toString('utf8')
|
||||
i += 8 + size
|
||||
}
|
||||
return out || buf.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort sweep of leftover `filestore-convert-*` containers from crashes
|
||||
* or service restarts mid-run (a container that was created but never started
|
||||
* or removed otherwise would leak forever).
|
||||
*/
|
||||
async function _sweepLeftovers(socketPath) {
|
||||
let res
|
||||
try {
|
||||
res = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/json?all=1&limit=200&filters=%7B%22name%22%3A%5B%22filestore-convert-%22%5D%7D`,
|
||||
{ timeoutMs: 10000 }
|
||||
)
|
||||
} catch (err) {
|
||||
return // not fatal
|
||||
}
|
||||
if (res.status !== 200 || !Array.isArray(res.body)) return
|
||||
for (const c of res.body) {
|
||||
if (typeof c.Names?.[0] === 'string' && c.Names[0].startsWith('/filestore-convert-')) {
|
||||
const name = c.Names[0].slice(1)
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
logger.warn({ name }, 'sandbox conversion: removed leftover container')
|
||||
break
|
||||
} catch (err) {
|
||||
if (attempt === 0) continue
|
||||
logger.warn({ err, name }, 'sandbox conversion: failed to remove leftover container')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a conversion in a sandboxed container.
|
||||
*
|
||||
* Layout contract (mirrors CLSI's conversion runner):
|
||||
* host: <hostDir>/<runId>/input.pdf <- the source file (copied in)
|
||||
* host: <hostDir>/<runId>/<outputBase>.<ext> <- produced by the container
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.sourcePath absolute path to the source PDF (must be
|
||||
* inside a daemon-host-visible directory — the bind source is derived from it)
|
||||
* @param {string} opts.containerArgs full argv inside the container, e.g.
|
||||
* ['pdftocairo','-png','-singlefile','-scale-to-x','700','-scale-to-y','-1',
|
||||
* '/work/input.pdf','/work/output']
|
||||
* @param {string} opts.workDir basename mapping used in `containerArgs`; the
|
||||
* source file is placed at <workDir>/input.pdf inside the container
|
||||
* @param {string} opts.outputBase container-side output basename (without ext)
|
||||
* @param {string} opts.outputExt 'png' or 'jpg'
|
||||
* @param {string} opts.destPath absolute host path where the result must end up
|
||||
* @return {Promise<{container: string, exitCode: number, outputHostPath: string, durationMs: number}>}
|
||||
*/
|
||||
export async function runConversion(opts) {
|
||||
const {
|
||||
sourcePath,
|
||||
containerArgs,
|
||||
workDir = '/work',
|
||||
outputBase = 'output',
|
||||
outputExt = 'png',
|
||||
destPath,
|
||||
} = opts
|
||||
|
||||
const sandbox = Settings.sandboxConversion || {}
|
||||
const image = sandbox.image || 'sharelatex/pdftocairo:24.02'
|
||||
const socketPath = sandbox.dockerSocketPath || '/var/run/docker.sock'
|
||||
const hostDir = sandbox.hostDir // daemon-host side of the scratch dir
|
||||
const containerDir = sandbox.containerDir // overleaf-container side
|
||||
const memoryBytes = sandbox.memoryBytes || 512 * 1024 * 1024
|
||||
const timeoutMs = sandbox.timeoutMs || 40000
|
||||
|
||||
if (!hostDir || !containerDir) {
|
||||
throw new ConversionError(
|
||||
'sandbox conversion is not configured: set CONVERSION_SANDBOX_HOST_DIR (Docker-daemon side) and CONVERSION_SANDBOX_DIR (overleaf-container side) to the two views of the same directory'
|
||||
)
|
||||
}
|
||||
|
||||
const runId = crypto.randomBytes(8).toString('hex')
|
||||
const runName = `filestore-convert-${runId}`
|
||||
const runDir = Path.join(containerDir, runId) // path used by THIS process (container side)
|
||||
const hostRunDir = Path.join(hostDir, runId) // same dir from the daemon side (bind source)
|
||||
const inputHostPath = Path.join(runDir, 'input.pdf')
|
||||
const outputHostPath = Path.join(runDir, `${outputBase}.${outputExt}`)
|
||||
|
||||
const t0 = Date.now()
|
||||
let containerCreated = false
|
||||
try {
|
||||
// Guard against leftover containers from an interrupted previous run.
|
||||
await _sweepLeftovers(socketPath)
|
||||
|
||||
Fs.mkdirSync(runDir, { mode: 0o770 })
|
||||
Fs.copyFileSync(sourcePath, inputHostPath)
|
||||
|
||||
// Container-absolute path of the source file, for argv remapping sanity
|
||||
const sourceInContainer = Path.join(workDir, 'input.pdf')
|
||||
if (!containerArgs.some((a) => a === sourceInContainer)) {
|
||||
throw new ConversionError(
|
||||
`sandbox argv does not reference ${sourceInContainer} — refusing to run`
|
||||
)
|
||||
}
|
||||
|
||||
// Create (the converter images are scratch-based: no ENTRYPOINT, so a bare
|
||||
// Cmd list is what CLSI runs successfully too).
|
||||
const createBody = {
|
||||
Image: image,
|
||||
Cmd: containerArgs,
|
||||
WorkingDir: workDir,
|
||||
// scratch-based images have no passwd entries — run as the numeric
|
||||
// uid:gid of this service (it owns the files on both sides of the bind).
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
HostConfig: {
|
||||
Binds: [`${hostRunDir}:${workDir}:rw`],
|
||||
CapDrop: ['ALL'],
|
||||
SecurityOpt: ['no-new-privileges'],
|
||||
NetworkMode: 'none',
|
||||
Memory: memoryBytes,
|
||||
MemorySwap: 0,
|
||||
PidsLimit: 64,
|
||||
},
|
||||
}
|
||||
const created = await request(socketPath, 'POST', `/${API_VERSION}/containers/create?name=${runName}`, {
|
||||
body: createBody,
|
||||
timeoutMs: 60000,
|
||||
})
|
||||
if (created.status >= 300 || !created.body?.Id) {
|
||||
const msg = created.body?.message || JSON.stringify(created.body) || `status ${created.status}`
|
||||
throw new ConversionError(`failed to create sandbox container: ${msg}`)
|
||||
}
|
||||
containerCreated = true
|
||||
logger.info({ image, runName, hostDir: hostRunDir, containerDir: runDir }, 'sandbox conversion: container created')
|
||||
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/start`, { timeoutMs: 60000 })
|
||||
|
||||
// Wait with a real deadline; kill on breach (the /wait call is a blocking
|
||||
// HTTP call that carries no data, so it gets its own generous budget).
|
||||
let exitCode = -1
|
||||
let killed = false
|
||||
try {
|
||||
// NOTE: this daemon (Docker 29 / API ≥1.44) requires POST for /wait; GET
|
||||
// returns 404. (dockerode does the same, which is how CLSI waits.)
|
||||
const waited = await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/wait`, {
|
||||
timeoutMs: timeoutMs + 15000,
|
||||
})
|
||||
exitCode = waited.body?.StatusCode ?? -1
|
||||
if (exitCode === -1 && waited.status !== 200) {
|
||||
throw new Error(`unexpected /wait status ${waited.status}: ${waited.raw?.toString?.('utf8') || 'no body'}`)
|
||||
}
|
||||
} catch (waitErr) {
|
||||
killed = true
|
||||
await _killAndRemove(socketPath, runName)
|
||||
throw new ConversionError(`sandbox conversion timed out (${timeoutMs}ms): ${waitErr.message}`)
|
||||
}
|
||||
|
||||
let logs = ''
|
||||
try {
|
||||
const logsRes = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/${runName}/logs?stdout=1&stderr=1&stdin=0&follow=1`,
|
||||
{ raw: true, timeoutMs: 10000 }
|
||||
)
|
||||
logs = decodeLogStream(logsRes.raw)
|
||||
} catch (err) {
|
||||
logger.warn({ err, runName }, 'sandbox conversion: could not read container logs')
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new ConversionError(
|
||||
`sandbox conversion failed (exit ${exitCode}${killed ? ', killed' : ''})`,
|
||||
{ stderr: logs.trim().slice(-4000), exitCode }
|
||||
)
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = Fs.statSync(outputHostPath)
|
||||
} catch (err) {
|
||||
throw new ConversionError(`sandbox conversion produced no output file`, { stderr: logs.trim().slice(-4000) })
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new ConversionError('sandbox conversion output is not a regular file', {
|
||||
stderr: logs.trim().slice(-4000),
|
||||
})
|
||||
}
|
||||
|
||||
Fs.copyFileSync(outputHostPath, destPath)
|
||||
logger.info(
|
||||
{ runName, sizeBytes: stat.size, durationMs: Date.now() - t0 },
|
||||
'sandbox conversion completed'
|
||||
)
|
||||
return { container: runName, exitCode, outputHostPath: destPath, durationMs: Date.now() - t0 }
|
||||
} finally {
|
||||
// Best-effort cleanup: container + scratch dir (the dir holds the original
|
||||
// PDF — do not leave it behind).
|
||||
if (containerCreated) {
|
||||
await _killAndRemove(socketPath, runName).catch((e) => {
|
||||
logger.warn({ err: e, runName }, 'sandbox conversion: cleanup failed')
|
||||
})
|
||||
}
|
||||
Fs.rmSync(runDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function _killAndRemove(socketPath, name) {
|
||||
try {
|
||||
// POST /kill (GET is not served by this daemon); 409 = not running → fine
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${name}/kill`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// not running → fine
|
||||
}
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// already gone → fine
|
||||
}
|
||||
}
|
||||
|
||||
export default { runConversion }
|
||||
@@ -99,29 +99,8 @@ const settings = {
|
||||
|
||||
enableConversions: process.env.ENABLE_CONVERSIONS === 'true',
|
||||
|
||||
// PDF→image conversion backend (template gallery previews/thumbnails):
|
||||
// 'imagemagick' – local, via ImageMagick `convert` (default; binary required)
|
||||
// 'pdftocairo' – local, via the `pdftocairo` binary (poppler)
|
||||
// 'pdftocairo-sandbox' – runs `pdftocairo` in a throwaway sibling container:
|
||||
// no network, CapDrop ALL, no-new-privileges, memory
|
||||
// cap (see app/js/SandboxConverterRunner.js).
|
||||
// requires a Docker socket + a daemon-host-visible
|
||||
// scratch dir (CONVERSION_SANDBOX_HOST_DIR).
|
||||
converter: process.env.CONVERTER || 'imagemagick',
|
||||
|
||||
sandboxConversion: {
|
||||
image: process.env.CONVERSION_SANDBOX_IMAGE || 'sharelatex/pdftocairo:24.02',
|
||||
dockerSocketPath: process.env.CONVERSION_DOCKER_SOCKET || '/var/run/docker.sock',
|
||||
// The scratch dir has two views of the same path (the overleaf container
|
||||
// mounts ./data): `containerDir` for this process, `hostDir` for the Docker
|
||||
// daemon's bind mount. Both must be set when sandbox conversion is used.
|
||||
hostDir: process.env.CONVERSION_SANDBOX_HOST_DIR || null,
|
||||
containerDir: process.env.CONVERSION_SANDBOX_DIR || null,
|
||||
memoryBytes:
|
||||
parseInt(process.env.CONVERSION_SANDBOX_MEMORY_BYTES, 10) || 512 * 1024 * 1024,
|
||||
timeoutMs: parseInt(process.env.CONVERSION_SANDBOX_TIMEOUT_MS, 10) || 40000,
|
||||
},
|
||||
|
||||
gracefulShutdownDelayInMs:
|
||||
parseInt(process.env.GRACEFUL_SHUTDOWN_DELAY_SECONDS ?? '30', 10) * 1000,
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Build the a8m/envsubst binary, as it supports default values,
|
||||
# which the gnu envsubst (from gettext-base) does not.
|
||||
FROM golang:1.25.5-alpine AS envsubst_builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN go install github.com/a8m/envsubst/cmd/envsubst@latest
|
||||
|
||||
FROM maven:3-amazoncorretto-21-debian AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y make git sqlite3 curl \
|
||||
&& rm -rf /var/lib/apt/lists
|
||||
|
||||
COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst
|
||||
RUN chmod +x /opt/envsubst
|
||||
|
||||
RUN useradd --create-home node
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
COPY . /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN make package \
|
||||
# The name of the created jar contains the current version tag.
|
||||
# Rename it to a static path that can be used for copying.
|
||||
&& find /app/target \
|
||||
-name 'writelatex-git-bridge*jar-with-dependencies.jar' \
|
||||
-exec mv {} /git-bridge.jar \;
|
||||
|
||||
FROM amazoncorretto:21-alpine
|
||||
|
||||
RUN apk add --update --no-cache bash git sqlite procps htop net-tools jemalloc util-linux
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||
|
||||
RUN adduser -D node
|
||||
|
||||
COPY --from=builder /git-bridge.jar /
|
||||
|
||||
COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst
|
||||
RUN chmod +x /opt/envsubst
|
||||
|
||||
COPY conf/envsubst_template.json envsubst_template.json
|
||||
COPY start.sh start.sh
|
||||
COPY server-pro-start.sh server-pro-start.sh
|
||||
|
||||
RUN mkdir conf
|
||||
RUN chown node:node conf
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["/start.sh"]
|
||||
@@ -1,89 +0,0 @@
|
||||
# git-bridge makefile
|
||||
|
||||
MVN_OPTS := --no-transfer-progress
|
||||
MVN_TARGET := target/writelatex-git-bridge-1.0-SNAPSHOT-jar-with-dependencies.jar
|
||||
|
||||
export BUILD_NUMBER ?= local
|
||||
export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g')
|
||||
export COMMIT_SHA ?= $(shell git rev-parse HEAD)
|
||||
PROJECT_NAME = git-bridge
|
||||
|
||||
IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER)
|
||||
IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME)
|
||||
IMAGE_REPO_BRANCH ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)
|
||||
IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main
|
||||
IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER)
|
||||
|
||||
runtime-conf:
|
||||
/opt/envsubst < conf/envsubst_template.json > conf/runtime.json
|
||||
|
||||
|
||||
run: $(MVN_TARGET) runtime-conf
|
||||
@exec java $(GIT_BRIDGE_JVM_ARGS) -jar $(MVN_TARGET) conf/runtime.json
|
||||
|
||||
|
||||
$(MVN_TARGET): $(shell find src -type f) pom.xml
|
||||
mvn $(MVN_OPTS) package -DskipTests
|
||||
|
||||
build: $(MVN_TARGET)
|
||||
|
||||
docker_build_base:
|
||||
docker build --pull \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from $(IMAGE_REPO_BRANCH) \
|
||||
--cache-from $(IMAGE_REPO_MAIN) \
|
||||
--tag $(IMAGE_CI) \
|
||||
--target base \
|
||||
.
|
||||
|
||||
docker_build:
|
||||
docker build --pull \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from $(IMAGE_REPO_BRANCH) \
|
||||
--cache-from $(IMAGE_REPO_MAIN) \
|
||||
--tag $(IMAGE_REPO_BRANCH) \
|
||||
--tag $(IMAGE_REPO_FINAL) \
|
||||
.
|
||||
|
||||
format:
|
||||
mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:check
|
||||
|
||||
docker_format:
|
||||
docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make format
|
||||
|
||||
format_fix:
|
||||
mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:format
|
||||
|
||||
|
||||
test:
|
||||
mvn $(MVN_OPTS) test
|
||||
|
||||
docker_test:
|
||||
docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make test
|
||||
|
||||
clean:
|
||||
mvn $(MVN_OPTS) clean
|
||||
|
||||
|
||||
package: clean
|
||||
mvn $(MVN_OPTS) package -DskipTests
|
||||
|
||||
refresh_cache: refresh_cache_branch
|
||||
refresh_cache_branch:
|
||||
docker inspect $(IMAGE_REPO_BRANCH) > /dev/null && docker pull $(IMAGE_REPO_BRANCH) || true
|
||||
refresh_cache: refresh_cache_latest
|
||||
refresh_cache_latest:
|
||||
docker inspect $(IMAGE_REPO_MAIN) > /dev/null && docker pull $(IMAGE_REPO_MAIN) || true
|
||||
|
||||
push:
|
||||
docker push $(IMAGE_REPO_FINAL)
|
||||
|
||||
push_branch:
|
||||
docker push $(IMAGE_REPO_BRANCH)
|
||||
|
||||
clean_ci:
|
||||
-docker rmi -f $(IMAGE_CI) $(IMAGE_REPO_FINAL)
|
||||
-git clean -xdf .
|
||||
|
||||
.PHONY: run package build clean test runtime-conf
|
||||
@@ -1199,8 +1199,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
timeout: options.timeout,
|
||||
imageName: project.imageName,
|
||||
draft: Boolean(options.draft),
|
||||
// CE: premium-compile (alpha/priority) gate removed
|
||||
png2pdf: Boolean(options.png2pdf),
|
||||
// enable for premium compiles only
|
||||
png2pdf: Boolean(options.png2pdf) && hasPremiumCompiles,
|
||||
// enable for premium compiles on an image that has a checkpointing build
|
||||
enableCheckpoint:
|
||||
Boolean(options.checkpointing) &&
|
||||
|
||||
@@ -453,8 +453,7 @@ const _CompileController = {
|
||||
enablePng2Pdf,
|
||||
enableCheckpointCompiles,
|
||||
} = await _getSplitTestOptions(req, res)
|
||||
// CE: saas feature gate removed so png2pdf / compile-from-history options are sent
|
||||
if (true) {
|
||||
if (Features.hasFeature('saas')) {
|
||||
options.compileFromClsiCache = true
|
||||
options.populateClsiCache = true
|
||||
options.compileFromHistory = compileFromHistory
|
||||
|
||||
@@ -123,12 +123,7 @@ const setDocumentSchema = z.object({
|
||||
version: z.number().int(),
|
||||
ranges: rangesSchemas.ranges,
|
||||
lastUpdatedAt: z.coerce.number().int().positive().nullish(),
|
||||
// document-updater records the acting user of each update it applies and
|
||||
// flushes it back here as lastUpdatedBy (see its RedisManager's
|
||||
// updateMeta.user_id). For a link-sharing visitor with no account that
|
||||
// is the sentinel 'anonymous-user', which real-time sets in Router.js --
|
||||
// never a user id. It is accepted here and normalised to null below.
|
||||
lastUpdatedBy: zz.objectId().or(z.literal('anonymous-user')).nullish(),
|
||||
lastUpdatedBy: zz.objectId().nullish(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -137,11 +132,7 @@ async function setDocument(req, res) {
|
||||
logOnly: true,
|
||||
})
|
||||
const { Project_id: projectId, doc_id: docId } = params
|
||||
const { lines, version, ranges, lastUpdatedAt } = body
|
||||
// an anonymous editor has no user id to record on the project, and
|
||||
// Project.lastUpdatedBy is an ObjectId ref -- see setDocumentSchema
|
||||
const lastUpdatedBy =
|
||||
body.lastUpdatedBy === 'anonymous-user' ? null : body.lastUpdatedBy
|
||||
const { lines, version, ranges, lastUpdatedAt, lastUpdatedBy } = body
|
||||
const result = await ProjectEntityUpdateHandler.promises.updateDocLines(
|
||||
projectId,
|
||||
docId,
|
||||
|
||||
@@ -604,58 +604,6 @@ const EditorController = {
|
||||
)
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// LanguageTool settings (project-wide), mirrored to all clients
|
||||
// in the room (see use-project-wide-settings-socket-listener).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
setLanguageToolEnabled(projectId, enabled, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolEnabled(projectId, enabled, function (
|
||||
err
|
||||
) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolEnabledUpdated',
|
||||
enabled
|
||||
)
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
setLanguageToolPicky(projectId, picky, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolPicky(projectId, picky, function (err) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolPickyUpdated',
|
||||
picky
|
||||
)
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
setLanguageToolLanguage(projectId, language, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolLanguage(
|
||||
projectId,
|
||||
language,
|
||||
function (err) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolLanguageUpdated',
|
||||
language
|
||||
)
|
||||
callback()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
setPublicAccessLevel(projectId, newAccessLevel, callback) {
|
||||
async.series(
|
||||
[
|
||||
@@ -799,13 +747,6 @@ EditorController.promises = {
|
||||
setImageName: promisify(EditorController.setImageName),
|
||||
setPng2pdf: promisify(EditorController.setPng2pdf),
|
||||
setSpellCheckLanguage: promisify(EditorController.setSpellCheckLanguage),
|
||||
setLanguageToolEnabled: promisify(
|
||||
EditorController.setLanguageToolEnabled
|
||||
),
|
||||
setLanguageToolPicky: promisify(EditorController.setLanguageToolPicky),
|
||||
setLanguageToolLanguage: promisify(
|
||||
EditorController.setLanguageToolLanguage
|
||||
),
|
||||
setPublicAccessLevel: promisify(EditorController.setPublicAccessLevel),
|
||||
setRootDoc: promisify(EditorController.setRootDoc),
|
||||
setMainBibliographyDoc: promisify(EditorController.setMainBibliographyDoc),
|
||||
|
||||
@@ -112,6 +112,10 @@ const createLinkedFileSchema = z.object({
|
||||
data: z.strictObject({
|
||||
format: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
group_id: zz.routeSegment().optional(),
|
||||
collection_key: z.union([
|
||||
z.literal('publications'),
|
||||
z.string().regex(/^[A-Za-z0-9]{8}$/),
|
||||
]).optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
|
||||
@@ -109,9 +109,6 @@ const updateProjectSettingsSchema = z.object({
|
||||
rootDocId: zz.objectId().optional(),
|
||||
spellCheckLanguage: z.string().optional(),
|
||||
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
languageToolEnabled: z.boolean().optional(),
|
||||
languageToolPicky: z.boolean().optional(),
|
||||
languageToolLanguage: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -130,9 +127,6 @@ const updateProjectSettingsFallbackSchema = z.object({
|
||||
rootDocId: zz.objectId().optional(),
|
||||
spellCheckLanguage: z.string().optional(),
|
||||
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
languageToolEnabled: z.boolean().optional(),
|
||||
languageToolPicky: z.boolean().optional(),
|
||||
languageToolLanguage: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -234,27 +228,6 @@ const _ProjectController = {
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolEnabled != null) {
|
||||
await EditorController.promises.setLanguageToolEnabled(
|
||||
projectId,
|
||||
body.languageToolEnabled
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolPicky != null) {
|
||||
await EditorController.promises.setLanguageToolPicky(
|
||||
projectId,
|
||||
body.languageToolPicky
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolLanguage != null) {
|
||||
await EditorController.promises.setLanguageToolLanguage(
|
||||
projectId,
|
||||
body.languageToolLanguage
|
||||
)
|
||||
}
|
||||
|
||||
if (body.rootDocId != null) {
|
||||
await EditorController.promises.setRootDoc(projectId, body.rootDocId)
|
||||
}
|
||||
|
||||
@@ -127,29 +127,6 @@ const ProjectOptionsHandler = {
|
||||
const update = { referenceFormat: newReferenceFormat }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// LanguageTool settings (project-wide; shared across collaborators).
|
||||
// Unset values mean the client defaults: enabled=true, picky=true,
|
||||
// language='auto' — i.e. picky level is the default selection.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
async setLanguageToolEnabled(projectId, enabled) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolEnabled: Boolean(enabled) }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
async setLanguageToolPicky(projectId, picky) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolPicky: Boolean(picky) }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
async setLanguageToolLanguage(projectId, language) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolLanguage: language || 'auto' }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -167,14 +144,5 @@ export default {
|
||||
ProjectOptionsHandler.setHistoryRangesSupport
|
||||
),
|
||||
setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat),
|
||||
setLanguageToolEnabled: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolEnabled
|
||||
),
|
||||
setLanguageToolPicky: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolPicky
|
||||
),
|
||||
setLanguageToolLanguage: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolLanguage
|
||||
),
|
||||
promises: ProjectOptionsHandler,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expressify } from '@overleaf/promise-utils'
|
||||
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
|
||||
|
||||
const VALID_KEYS = [
|
||||
'reference-picker-shortcut',
|
||||
'react-history-buttons-tutorial',
|
||||
'writefull-integration',
|
||||
'writefull-oauth-promotion',
|
||||
|
||||
@@ -29,7 +29,6 @@ function _canHaveNoInitiatorId(operation, info) {
|
||||
if (operation === 'must-reset-password-set') return true
|
||||
if (operation === 'must-reset-password-unset') return true
|
||||
if (operation === 'account-suspension' && info.script) return true
|
||||
if (operation === 'account-suspension-lifted' && info.script) return true
|
||||
if (operation === 'release-managed-user' && info.script) return true
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,6 @@ const UserPagesController = {
|
||||
title: Settings.nav?.login_support_title || 'login',
|
||||
login_support_title: Settings.nav?.login_support_title,
|
||||
login_support_text: Settings.nav?.login_support_text,
|
||||
local_login_mask_enable: Settings.enableLocalLoginMask,
|
||||
metadata,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -633,23 +633,6 @@ async function suspendUser(userId, auditLog = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
async function unsuspendUser(userId, auditLog = {}) {
|
||||
const res = await updateUser(
|
||||
{ _id: userId, suspended: true },
|
||||
{ $set: { suspended: false } }
|
||||
)
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Errors.NotFoundError('user id not found or not suspended')
|
||||
}
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'account-suspension-lifted',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ip,
|
||||
auditLog.info || {}
|
||||
)
|
||||
}
|
||||
|
||||
function _securityAlertPrimaryEmailChangedExtraRecipients(
|
||||
emailsData,
|
||||
oldEmail,
|
||||
@@ -706,7 +689,6 @@ export default {
|
||||
migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
|
||||
updateUser: callbackify(updateUser),
|
||||
suspendUser: callbackify(suspendUser),
|
||||
unsuspendUser: callbackify(unsuspendUser),
|
||||
promises: {
|
||||
addAffiliationForNewUser,
|
||||
addEmailAddress,
|
||||
@@ -720,6 +702,5 @@ export default {
|
||||
migrateDefaultEmailAddress,
|
||||
updateUser,
|
||||
suspendUser,
|
||||
unsuspendUser,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,706 +0,0 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { db } from '../../infrastructure/mongodb.mjs'
|
||||
import Mongo from '../Helpers/Mongo.mjs'
|
||||
import { callbackify } from 'node:util'
|
||||
import UserGetter from './UserGetter.mjs'
|
||||
import InstitutionsAPI from '../Institutions/InstitutionsAPI.mjs'
|
||||
import Features from '../../infrastructure/Features.mjs'
|
||||
import FeaturesUpdater from '../Subscription/FeaturesUpdater.mjs'
|
||||
import EmailHandler from '../Email/EmailHandler.mjs'
|
||||
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import UserAuditLogHandler from './UserAuditLogHandler.mjs'
|
||||
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
|
||||
import EmailChangeHelper from '../Analytics/EmailChangeHelper.mjs'
|
||||
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
|
||||
import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
|
||||
import _ from 'lodash'
|
||||
import Modules from '../../infrastructure/Modules.mjs'
|
||||
import UserSessionsManager from './UserSessionsManager.mjs'
|
||||
import ThirdPartyIdentityManager from './ThirdPartyIdentityManager.mjs'
|
||||
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
|
||||
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
async function _sendSecurityAlertPrimaryEmailChanged(
|
||||
userId,
|
||||
oldEmail,
|
||||
email,
|
||||
deleteOldEmail
|
||||
) {
|
||||
// here
|
||||
// Send email to the following:
|
||||
// - the old primary
|
||||
// - the new primary
|
||||
// - for all other current (confirmed or recently-enough reconfirmed) email addresses, group by institution if we
|
||||
// have it, or domain if we don’t, and for each group send to the most recently reconfirmed (or confirmed if never
|
||||
// reconfirmed) address in that group.
|
||||
// See #6101.
|
||||
const emailOptions = {
|
||||
actionDescribed: `the primary email address on your account was changed to ${email}`,
|
||||
action: 'change of primary email address',
|
||||
message: deleteOldEmail
|
||||
? [
|
||||
`We also removed the previous primary email ${oldEmail} from the account.`,
|
||||
]
|
||||
: [],
|
||||
}
|
||||
|
||||
async function sendToRecipients(recipients) {
|
||||
// On failure, log the error and carry on so that one email failing does not prevent other emails sending
|
||||
for await (const recipient of recipients) {
|
||||
try {
|
||||
const opts = Object.assign({}, emailOptions, { to: recipient })
|
||||
await EmailHandler.promises.sendEmail('securityAlert', opts)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, userId },
|
||||
'could not send security alert email when primary email changed'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First, send notification to the old and new primary emails before getting other emails from v1 to ensure that these
|
||||
// are still sent in the event of not being able to reach v1
|
||||
const oldAndNewPrimaryEmails = [oldEmail, email]
|
||||
await sendToRecipients(oldAndNewPrimaryEmails)
|
||||
|
||||
// Next, get extra recipients with affiliation data
|
||||
const emailsData = await UserGetter.promises.getUserFullEmails(userId)
|
||||
const extraRecipients = _securityAlertPrimaryEmailChangedExtraRecipients(
|
||||
emailsData,
|
||||
oldEmail,
|
||||
email
|
||||
)
|
||||
|
||||
await sendToRecipients(extraRecipients)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new email address for the user. Email cannot be already used by this
|
||||
* or any other user
|
||||
*/
|
||||
async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
newEmail = EmailHelper.parseEmail(newEmail)
|
||||
if (!newEmail) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
await UserGetter.promises.ensureUniqueEmailAddress(newEmail)
|
||||
|
||||
AnalyticsManager.recordEventForUserInBackground(
|
||||
userId,
|
||||
'secondary-email-added'
|
||||
)
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'add-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
...auditLog.info,
|
||||
newSecondaryEmail: newEmail,
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await InstitutionsAPI.promises.addAffiliation(
|
||||
userId,
|
||||
newEmail,
|
||||
affiliationOptions
|
||||
)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, 'problem adding affiliation while adding email')
|
||||
}
|
||||
|
||||
const createdAt = new Date()
|
||||
let res
|
||||
try {
|
||||
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
|
||||
const update = {
|
||||
$push: {
|
||||
emails: { email: newEmail, createdAt, reversedHostname },
|
||||
},
|
||||
}
|
||||
res = await updateUser(
|
||||
{ _id: userId, 'emails.email': { $ne: newEmail } },
|
||||
update
|
||||
)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, 'problem updating users emails')
|
||||
}
|
||||
|
||||
if (res.matchedCount !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailCreation(userId, newEmail, {
|
||||
createdAt: new Date(),
|
||||
emailCreatedAt: createdAt,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId, newEmail },
|
||||
'Error registering email creation with analytics'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSAMLData(userId, auditLog, sendEmail) {
|
||||
const user = await UserGetter.promises.getUser(userId, {
|
||||
email: 1,
|
||||
emails: 1,
|
||||
})
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'clear-institution-sso-data',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{}
|
||||
)
|
||||
|
||||
const update = {
|
||||
$unset: {
|
||||
samlIdentifiers: 1,
|
||||
'emails.$[].samlProviderId': 1,
|
||||
'enrollment.sso': 1,
|
||||
},
|
||||
}
|
||||
|
||||
await updateUser(userId, update)
|
||||
|
||||
for (const emailData of user.emails) {
|
||||
await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
|
||||
}
|
||||
|
||||
await FeaturesUpdater.promises.refreshFeatures(
|
||||
userId,
|
||||
'clear-institution-sso-data'
|
||||
)
|
||||
|
||||
if (sendEmail) {
|
||||
await EmailHandler.promises.sendEmail('SAMLDataCleared', { to: user.email })
|
||||
}
|
||||
}
|
||||
|
||||
async function clearThirdPartyIdentifiers(userId, auditLog) {
|
||||
const user = await UserGetter.promises.getUser(userId, {
|
||||
thirdPartyIdentifiers: 1,
|
||||
})
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'clear-third-party-identifiers',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{}
|
||||
)
|
||||
for (const thirdPartyIdentifier of user.thirdPartyIdentifiers || []) {
|
||||
await ThirdPartyIdentityManager.promises.unlink(
|
||||
userId,
|
||||
thirdPartyIdentifier.providerId,
|
||||
auditLog
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* set the default email address by setting the `email` attribute. The email
|
||||
* must be one of the user's multiple emails (`emails` attribute)
|
||||
*/
|
||||
async function setDefaultEmailAddress(
|
||||
userId,
|
||||
email,
|
||||
allowUnconfirmed,
|
||||
auditLog,
|
||||
sendSecurityAlert,
|
||||
deleteOldEmail = false
|
||||
) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
email = EmailHelper.parseEmail(email)
|
||||
if (email == null) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
const user = await UserGetter.promises.getUser(userId, {
|
||||
email: 1,
|
||||
emails: 1,
|
||||
})
|
||||
if (!user) {
|
||||
throw new Error('invalid userId')
|
||||
}
|
||||
|
||||
const oldEmail = user.email
|
||||
const userEmail = user.emails.find(e => e.email === email)
|
||||
if (!userEmail) {
|
||||
throw new Error('Default email does not belong to user')
|
||||
}
|
||||
if (!userEmail.confirmedAt && !allowUnconfirmed) {
|
||||
throw new Errors.UnconfirmedEmailError()
|
||||
}
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'change-primary-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
newPrimaryEmail: email,
|
||||
oldPrimaryEmail: oldEmail,
|
||||
}
|
||||
)
|
||||
|
||||
const query = { _id: userId, 'emails.email': email }
|
||||
const update = { $set: { email, lastPrimaryEmailCheck: new Date() } }
|
||||
const res = await updateUser(query, update)
|
||||
|
||||
// this should not happen
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Error('email update error')
|
||||
}
|
||||
|
||||
AnalyticsManager.recordEventForUserInBackground(
|
||||
userId,
|
||||
'primary-email-address-updated'
|
||||
)
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailUpdate(userId, email, {
|
||||
isPrimary: true,
|
||||
action: 'updated',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, userId, email },
|
||||
'Error registering email change with analytics'
|
||||
)
|
||||
}
|
||||
|
||||
if (sendSecurityAlert) {
|
||||
// no need to wait, errors are logged and not passed back
|
||||
_sendSecurityAlertPrimaryEmailChanged(
|
||||
userId,
|
||||
oldEmail,
|
||||
email,
|
||||
deleteOldEmail
|
||||
).catch(err => {
|
||||
logger.error({ err }, 'failed to send security alert email')
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await Modules.promises.hooks.fire('userEmailChanged', user, email)
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, oldEmail, newEmail: email },
|
||||
'Failed to fire "userEmailChanged" hook'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await Modules.promises.hooks.fire(
|
||||
'updateAccountEmailAddress',
|
||||
user._id,
|
||||
email
|
||||
)
|
||||
} catch (error) {
|
||||
// errors are ignored
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites the primary email address of a user in the database in-place.
|
||||
* This function is only intended for use in scripts to migrate email addresses
|
||||
* where we do not want to trigger all the actions that happen when a user
|
||||
* changes their own email. It should not be used in any other circumstances.
|
||||
*/
|
||||
async function migrateDefaultEmailAddress(
|
||||
userId,
|
||||
oldEmail,
|
||||
newEmail,
|
||||
auditLog
|
||||
) {
|
||||
oldEmail = EmailHelper.parseEmail(oldEmail)
|
||||
if (oldEmail == null) {
|
||||
throw new Error('invalid old email')
|
||||
}
|
||||
newEmail = EmailHelper.parseEmail(newEmail)
|
||||
if (newEmail == null) {
|
||||
throw new Error('invalid new email')
|
||||
}
|
||||
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
|
||||
const query = {
|
||||
_id: userId,
|
||||
email: oldEmail,
|
||||
'emails.email': oldEmail,
|
||||
}
|
||||
const update = {
|
||||
$set: {
|
||||
email: newEmail,
|
||||
'emails.$.email': newEmail,
|
||||
'emails.$.reversedHostname': reversedHostname,
|
||||
},
|
||||
}
|
||||
const result = await updateUser(query, update)
|
||||
if (result.modifiedCount !== 1) {
|
||||
throw new Error('email update error')
|
||||
}
|
||||
// add a user audit log entry for the email change
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'migrate-default-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
oldEmail,
|
||||
newEmail,
|
||||
// Add optional extra info
|
||||
...(auditLog.extraInfo || {}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function confirmEmail(userId, email, affiliationOptions) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
// used for initial email confirmation (non-SSO and SSO)
|
||||
// also used for reconfirmation of non-SSO emails
|
||||
const confirmedAt = new Date()
|
||||
email = EmailHelper.parseEmail(email)
|
||||
if (email == null) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
logger.debug({ userId, email }, 'confirming user email')
|
||||
|
||||
try {
|
||||
affiliationOptions = affiliationOptions || {}
|
||||
affiliationOptions.confirmedAt = confirmedAt
|
||||
await InstitutionsAPI.promises.addAffiliation(
|
||||
userId,
|
||||
email,
|
||||
affiliationOptions
|
||||
)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, 'problem adding affiliation while confirming email')
|
||||
}
|
||||
|
||||
const query = {
|
||||
_id: userId,
|
||||
'emails.email': email,
|
||||
}
|
||||
|
||||
// only update confirmedAt if it was not previously set
|
||||
const update = {
|
||||
$set: {
|
||||
'emails.$.reconfirmedAt': confirmedAt,
|
||||
},
|
||||
$min: {
|
||||
'emails.$.confirmedAt': confirmedAt,
|
||||
},
|
||||
}
|
||||
|
||||
if (Features.hasFeature('affiliations')) {
|
||||
update.$unset = {
|
||||
'emails.$.affiliationUnchecked': 1,
|
||||
}
|
||||
}
|
||||
|
||||
const res = await updateUser(query, update)
|
||||
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Errors.NotFoundError('user id and email do no match')
|
||||
}
|
||||
await FeaturesUpdater.promises.refreshFeatures(userId, 'confirm-email')
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailUpdate(userId, email, {
|
||||
emailConfirmedAt: confirmedAt,
|
||||
action: 'updated',
|
||||
isPrimary: false,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId, email },
|
||||
'Error registering email confirmation with analytics'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await maybeCreateRedundantSubscriptionNotification(userId, email)
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
{ err: error },
|
||||
'error checking redundant subscription on email confirmation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeCreateRedundantSubscriptionNotification(userId, email) {
|
||||
const subscription =
|
||||
await SubscriptionLocator.promises.getUserIndividualSubscription(userId)
|
||||
if (!subscription || subscription.groupPlan) {
|
||||
return
|
||||
}
|
||||
|
||||
const affiliations =
|
||||
await InstitutionsAPI.promises.getUserAffiliations(userId)
|
||||
const confirmedAffiliation = affiliations.find(a => a.email === email)
|
||||
if (!confirmedAffiliation || confirmedAffiliation.licence === 'free') {
|
||||
return
|
||||
}
|
||||
|
||||
await NotificationsBuilder.promises
|
||||
.redundantPersonalSubscription(
|
||||
{
|
||||
institutionId: confirmedAffiliation.institution.id,
|
||||
institutionName: confirmedAffiliation.institution.name,
|
||||
},
|
||||
{ _id: userId }
|
||||
)
|
||||
.create()
|
||||
}
|
||||
|
||||
async function removeEmailAddress(
|
||||
userId,
|
||||
email,
|
||||
auditLog,
|
||||
skipParseEmail = false
|
||||
) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
// remove one of the user's email addresses. The email cannot be the user's
|
||||
// default email address
|
||||
if (!skipParseEmail) {
|
||||
email = EmailHelper.parseEmail(email)
|
||||
} else if (skipParseEmail && typeof email !== 'string') {
|
||||
throw new Error('email must be a string')
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
const isMainEmail = await UserGetter.promises.getUserByMainEmail(email, {
|
||||
_id: 1,
|
||||
})
|
||||
if (isMainEmail) {
|
||||
throw new Error('cannot remove primary email')
|
||||
}
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'remove-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
removedEmail: email,
|
||||
// Add optional extra info
|
||||
...(auditLog.extraInfo || {}),
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await InstitutionsAPI.promises.removeAffiliation(userId, email)
|
||||
} catch (error) {
|
||||
OError.tag(error, 'problem removing affiliation')
|
||||
throw error
|
||||
}
|
||||
|
||||
const query = { _id: userId, email: { $ne: email } }
|
||||
const update = { $pull: { emails: { email } } }
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await updateUser(query, update)
|
||||
} catch (error) {
|
||||
OError.tag(error, 'problem removing users email')
|
||||
throw error
|
||||
}
|
||||
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Error('Cannot remove email')
|
||||
}
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailDeletion(userId, email, {
|
||||
isPrimary: false,
|
||||
emailDeletedAt: new Date(),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId, email },
|
||||
'Error registering email deletion with analytics'
|
||||
)
|
||||
}
|
||||
|
||||
await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email')
|
||||
}
|
||||
|
||||
async function addAffiliationForNewUser(
|
||||
userId,
|
||||
email,
|
||||
affiliationOptions = {}
|
||||
) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
await InstitutionsAPI.promises.addAffiliation(
|
||||
userId,
|
||||
email,
|
||||
affiliationOptions
|
||||
)
|
||||
try {
|
||||
await updateUser(
|
||||
{ _id: userId, 'emails.email': email },
|
||||
{ $unset: { 'emails.$.affiliationUnchecked': 1 } }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
OError.tag(
|
||||
error,
|
||||
'could not remove affiliationUnchecked flag for user on create',
|
||||
{
|
||||
userId,
|
||||
email,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(query, update) {
|
||||
query = normalizeQuery(query)
|
||||
const result = await db.users.updateOne(query, update)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPRECATED
|
||||
*
|
||||
* Change the user's main email address by adding a new email, switching the
|
||||
* default email and removing the old email. Prefer manipulating multiple
|
||||
* emails and the default rather than calling this method directly
|
||||
*/
|
||||
async function changeEmailAddress(userId, newEmail, auditLog) {
|
||||
newEmail = EmailHelper.parseEmail(newEmail)
|
||||
if (newEmail == null) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
const oldEmail = await UserGetter.promises.getUserEmail(userId)
|
||||
await addEmailAddress(userId, newEmail, {}, auditLog)
|
||||
await setDefaultEmailAddress(userId, newEmail, true, auditLog, true)
|
||||
await removeEmailAddress(userId, oldEmail, auditLog)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {{initiatorId: string, ip: string}} auditLog
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function removeReconfirmFlag(userId, auditLog) {
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId.toString(),
|
||||
'must-reset-password-unset',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ip
|
||||
)
|
||||
await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
|
||||
}
|
||||
|
||||
async function suspendUser(userId, auditLog = {}) {
|
||||
const res = await updateUser(
|
||||
{ _id: userId, suspended: { $ne: true } },
|
||||
{ $set: { suspended: true } }
|
||||
)
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Errors.NotFoundError('user id not found or already suspended')
|
||||
}
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'account-suspension',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ip,
|
||||
auditLog.info || {}
|
||||
)
|
||||
await UserSessionsManager.promises.removeSessionsFromRedis({ _id: userId })
|
||||
await Modules.promises.hooks.fire(
|
||||
'removeDropbox',
|
||||
userId,
|
||||
'account-suspension'
|
||||
)
|
||||
}
|
||||
|
||||
function _securityAlertPrimaryEmailChangedExtraRecipients(
|
||||
emailsData,
|
||||
oldEmail,
|
||||
email
|
||||
) {
|
||||
// Group by institution if we have it, or domain if we don’t, and for each group send to the most recently
|
||||
// reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new
|
||||
// primary email addresses because they are emailed separately
|
||||
// See #6101.
|
||||
function sortEmailsByConfirmation(emails) {
|
||||
return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt)
|
||||
}
|
||||
|
||||
const recipients = new Set()
|
||||
const emailsToIgnore = new Set([oldEmail, email])
|
||||
|
||||
// Remove non-confirmed emails
|
||||
const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt)
|
||||
|
||||
// Group other emails by institution, separating out those with no institution and grouping them instead by domain.
|
||||
// The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format
|
||||
// to avoid any potential clash
|
||||
const groupedEmails = _.groupBy(confirmedEmails, emailData => {
|
||||
if (!emailData.affiliation || !emailData.affiliation.institution) {
|
||||
return `domain:${EmailHelper.getDomain(emailData.email)}`
|
||||
}
|
||||
return `institution_id:${emailData.affiliation.institution.id}`
|
||||
})
|
||||
|
||||
// For each group of emails, order the emails by (re-)confirmation date and pick the first
|
||||
for (const emails of Object.values(groupedEmails)) {
|
||||
// Sort by confirmation and pick the first
|
||||
sortEmailsByConfirmation(emails)
|
||||
|
||||
// Ignore original and new primary email addresses
|
||||
const recipient = emails[0].email
|
||||
if (!emailsToIgnore.has(recipient)) {
|
||||
recipients.add(emails[0].email)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(recipients)
|
||||
}
|
||||
|
||||
export default {
|
||||
addAffiliationForNewUser: callbackify(addAffiliationForNewUser),
|
||||
addEmailAddress: callbackify(addEmailAddress),
|
||||
changeEmailAddress: callbackify(changeEmailAddress),
|
||||
clearSAMLData: callbackify(clearSAMLData),
|
||||
confirmEmail: callbackify(confirmEmail),
|
||||
removeEmailAddress: callbackify(removeEmailAddress),
|
||||
removeReconfirmFlag: callbackify(removeReconfirmFlag),
|
||||
setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
|
||||
migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
|
||||
updateUser: callbackify(updateUser),
|
||||
suspendUser: callbackify(suspendUser),
|
||||
promises: {
|
||||
addAffiliationForNewUser,
|
||||
addEmailAddress,
|
||||
changeEmailAddress,
|
||||
clearSAMLData,
|
||||
clearThirdPartyIdentifiers,
|
||||
confirmEmail,
|
||||
removeEmailAddress,
|
||||
removeReconfirmFlag,
|
||||
setDefaultEmailAddress,
|
||||
migrateDefaultEmailAddress,
|
||||
updateUser,
|
||||
suspendUser,
|
||||
},
|
||||
}
|
||||
@@ -424,8 +424,14 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
cioSiteId: Settings.analytics?.cio?.siteId,
|
||||
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
|
||||
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
|
||||
gitlabSyncEnabled: !!Settings.gitlabSync?.clientID && !!Settings.gitlabSync?.clientSecret && !!Settings.gitlabSync?.url,
|
||||
gitlabUrl: Settings.gitlabSync?.url,
|
||||
gitlabTitle: Settings.gitlabSync?.title,
|
||||
giteaSyncEnabled: !!Settings.giteaSync?.clientID && !!Settings.giteaSync?.clientSecret && !!Settings.giteaSync?.url,
|
||||
giteaUrl: Settings.giteaSync?.url,
|
||||
giteaBrand: Settings.giteaSync?.brand,
|
||||
giteaTitle: Settings.giteaSync?.title,
|
||||
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
|
||||
languageToolAvailable: !!Settings.languageToolURL,
|
||||
enablePandocConversions: Settings.enablePandocConversions,
|
||||
mixpanelLabsToken:
|
||||
Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken,
|
||||
|
||||
@@ -16,6 +16,7 @@ const trackChangesModuleAvailable =
|
||||
* @property {Object | undefined} apis.linkedUrlProxy
|
||||
* @property {string | undefined} apis.linkedUrlProxy.url
|
||||
* @property {boolean | undefined} enableGithubSync
|
||||
* @property {boolean | undefined} enableGitLabSync
|
||||
* @property {boolean | undefined} enableGitBridge
|
||||
* @property {boolean | undefined} enableHomepage
|
||||
* @property {boolean | undefined} enableSaml
|
||||
@@ -61,6 +62,10 @@ const Features = {
|
||||
return Boolean(Settings.disableLinkSharing) === false
|
||||
case 'github-sync':
|
||||
return Boolean(Settings.enableGithubSync)
|
||||
case 'gitlab-sync':
|
||||
return Boolean(Settings.enableGitLabSync)
|
||||
case 'gitea-sync':
|
||||
return Boolean(Settings.enableGiteaSync)
|
||||
case 'git-bridge':
|
||||
return Boolean(Settings.enableGitBridge)
|
||||
case 'oauth':
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const thirdPartyIcons = [
|
||||
{ file: 'git.svg', label: 'Git' },
|
||||
{ file: 'github.svg', label: 'GitHub' },
|
||||
{ file: 'gitlab.svg', label: 'GitLab' },
|
||||
{ file: 'dropbox-transparent.svg', label: 'Dropbox' },
|
||||
{ file: 'zotero_64x64x32.png', label: 'Zotero' },
|
||||
{ file: 'mendeley.svg', label: 'Mendeley' },
|
||||
|
||||
@@ -15,6 +15,7 @@ block append meta
|
||||
- const canDisplaySplitTestMenu = hasFeature('saas') && hasNonAdminDomainCapability('view-split-test')
|
||||
- const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu && hasAdminCapability('manage-survey', false)
|
||||
- const canDisplayScriptLogMenu = hasFeature('saas') && hasAdminCapability('view-script-log', false) && canDisplayAdminMenu
|
||||
- const canDisplayInstanceStats = settings.instanceStats && settings.instanceStats.enabled
|
||||
- const enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on')
|
||||
- const showSignUpLink = hasFeature('registration-page')
|
||||
|
||||
@@ -31,6 +32,7 @@ block append meta
|
||||
canDisplaySplitTestMenu,
|
||||
canDisplaySurveyMenu,
|
||||
canDisplayScriptLogMenu,
|
||||
canDisplayInstanceStats,
|
||||
enableUpgradeButton,
|
||||
suppressNavbarRight: !!suppressNavbarRight,
|
||||
suppressNavContentLinks: !!suppressNavContentLinks,
|
||||
|
||||
@@ -68,6 +68,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
|
||||
+dropdown-menu-link-item(href='/admin/user') Manage Users
|
||||
if canDisplayProjectUrlLookup
|
||||
+dropdown-menu-link-item(href='/admin/project') Manage Projects
|
||||
if settings.instanceStats && settings.instanceStats.enabled
|
||||
+dropdown-menu-link-item(href='/admin/instance-stats') Instance Statistics
|
||||
if canDisplayAdminRedirect
|
||||
+dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin
|
||||
if canDisplaySplitTestMenu
|
||||
|
||||
@@ -36,7 +36,7 @@ footer.site-footer
|
||||
a(href=item.url class=item.class aria-label=item.label) !{item.text}
|
||||
else
|
||||
| !{item.text}
|
||||
if settings.env === 'server-ce' && settings.show_unsupport_warning
|
||||
if settings.env === 'server-ce'
|
||||
.row
|
||||
.col-12
|
||||
p.copyright-notice
|
||||
|
||||
@@ -2,7 +2,6 @@ extends ../layout-website-redesign
|
||||
|
||||
block vars
|
||||
- isWebsiteRedesign = true
|
||||
- suppressNavbarRight = true
|
||||
|
||||
block content
|
||||
main#main-content.content
|
||||
@@ -14,42 +13,41 @@ block content
|
||||
h1 !{login_support_title}
|
||||
else
|
||||
h1 #{translate("log_in")}
|
||||
if local_login_mask_enable
|
||||
form(name='loginForm' data-ol-async-form action='/login' method='POST')
|
||||
input(name='_csrf' type='hidden' value=csrfToken)
|
||||
+formMessagesNewStyle
|
||||
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
|
||||
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
|
||||
span.visually-hidden(id='resetPasswordDescription')
|
||||
| #{translate('reset_password_link')}
|
||||
+customFormMessageNewStyle('password-compromised')
|
||||
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input#email.form-control(
|
||||
name='email'
|
||||
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
|
||||
required
|
||||
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
|
||||
autofocus='true'
|
||||
autocomplete='username'
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input#password.form-control(
|
||||
name='password'
|
||||
type='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
|
||||
span(data-ol-inflight='idle') #{translate("login")}
|
||||
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
|
||||
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
|
||||
if login_support_text
|
||||
hr
|
||||
p.text-center !{login_support_text}
|
||||
form(name='loginForm' data-ol-async-form action='/login' method='POST')
|
||||
input(name='_csrf' type='hidden' value=csrfToken)
|
||||
+formMessagesNewStyle
|
||||
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
|
||||
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
|
||||
span.visually-hidden(id='resetPasswordDescription')
|
||||
| #{translate('reset_password_link')}
|
||||
+customFormMessageNewStyle('password-compromised')
|
||||
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input#email.form-control(
|
||||
name='email'
|
||||
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
|
||||
required
|
||||
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
|
||||
autofocus='true'
|
||||
autocomplete='username'
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input#password.form-control(
|
||||
name='password'
|
||||
type='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
|
||||
span(data-ol-inflight='idle') #{translate("login")}
|
||||
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
|
||||
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
|
||||
if login_support_text
|
||||
hr
|
||||
p.text-center !{login_support_text}
|
||||
if settings.saml && settings.saml.enable
|
||||
.actions(style='margin-top: 30px;')
|
||||
a.button.btn-secondary.btn(
|
||||
|
||||
@@ -79,8 +79,6 @@ const safeCompilers = ['xelatex', 'pdflatex', 'latex', 'lualatex']
|
||||
|
||||
module.exports = {
|
||||
env: 'server-ce',
|
||||
// show the CE "free unsupported software" disclaimer in the footer; set SHOW_UNSUPPORT_WARNING=false to hide it
|
||||
show_unsupport_warning: process.env.SHOW_UNSUPPORT_WARNING !== 'false',
|
||||
|
||||
limits: {
|
||||
httpGlobalAgentMaxSockets: 300,
|
||||
@@ -1037,7 +1035,7 @@ module.exports = {
|
||||
sourceEditorExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-extension'
|
||||
'../modules/bib-editor/frontend/js/extensions/bib-editor-extension.ts'
|
||||
),
|
||||
],
|
||||
sourceEditorVisualExtensions: [],
|
||||
@@ -1058,11 +1056,16 @@ module.exports = {
|
||||
sourceEditorToolbarButtonGroups: [],
|
||||
sourceEditorToolbarComponents: [],
|
||||
sourceEditorToolbarEndButtons: [],
|
||||
rootContextProviders: [],
|
||||
rootContextProviders: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/bib-editor/frontend/js/context/bib-editor-provider.tsx'
|
||||
),
|
||||
],
|
||||
mainEditorLayoutModals: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/reference-picker/frontend/components/reference-picker-controller.tsx'
|
||||
'../modules/reference-picker/frontend/js/components/reference-picker-controller.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
@@ -1081,7 +1084,15 @@ module.exports = {
|
||||
integrationLinkingWidgets: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
|
||||
'../modules/git-sync/github/frontend/js/components/github-sync-widget.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitlab/frontend/js/components/gitlab-sync-widget.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitea/frontend/js/components/gitea-sync-widget.tsx'
|
||||
),
|
||||
],
|
||||
referenceLinkingWidgets: [
|
||||
@@ -1093,13 +1104,37 @@ module.exports = {
|
||||
importProjectFromGithubModalWrapper: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/import-from-github-modal-wrapper.tsx'
|
||||
'../modules/git-sync/github/frontend/js/components/import-from-github-modal-wrapper.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGithubMenu: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/import-from-github-menu.tsx'
|
||||
'../modules/git-sync/github/frontend/js/components/import-from-github-menu.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGitLabModalWrapper: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitlab/frontend/js/components/import-from-gitlab-modal-wrapper.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGitLabMenu: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitlab/frontend/js/components/import-from-gitlab-menu.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGiteaModalWrapper: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitea/frontend/js/components/import-from-gitea-modal-wrapper.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGiteaMenu: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitea/frontend/js/components/import-from-gitea-menu.tsx'
|
||||
),
|
||||
],
|
||||
editorLeftMenuSync: [],
|
||||
@@ -1124,7 +1159,12 @@ module.exports = {
|
||||
domainVerificationToken: [],
|
||||
v1ImportDataScreen: [],
|
||||
snapshotUtils: [],
|
||||
visualEditorProviders: [],
|
||||
visualEditorProviders: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/bib-editor/frontend/js/bib-editor-visual-provider.ts'
|
||||
),
|
||||
],
|
||||
usGovBanner: [],
|
||||
rollingBuildsUpdatedAlert: [],
|
||||
offlineModeToolbarButtons: [],
|
||||
@@ -1132,7 +1172,7 @@ module.exports = {
|
||||
autoCompleteExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts'
|
||||
'../modules/reference-picker/frontend/js/extensions/reference-picker-keybinding.ts'
|
||||
),
|
||||
],
|
||||
sectionTitleGenerators: [],
|
||||
@@ -1167,7 +1207,15 @@ module.exports = {
|
||||
integrationPanelComponents: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-integration-card.tsx'
|
||||
'../modules/git-sync/github/frontend/js/components/github-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitlab/frontend/js/components/gitlab-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-sync/gitea/frontend/js/components/gitea-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
@@ -1180,18 +1228,13 @@ module.exports = {
|
||||
],
|
||||
referenceSearchSetting: [],
|
||||
settingsModalEditorTabSections: [],
|
||||
settingsModalSpellcheckSections: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-section'
|
||||
),
|
||||
],
|
||||
settingsModalSpellcheckSections: [],
|
||||
editorFloatingMenuActions: [],
|
||||
errorLogsComponents: [],
|
||||
referenceIndices: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts'
|
||||
'../modules/reference-picker/frontend/js/reference-index/advanced-reference-index.ts'
|
||||
),
|
||||
],
|
||||
railEntries: [],
|
||||
@@ -1208,16 +1251,20 @@ module.exports = {
|
||||
'symbol-palette',
|
||||
'reference-picker',
|
||||
'track-changes',
|
||||
'languagetool',
|
||||
'authentication/ldap',
|
||||
'authentication/saml',
|
||||
'authentication/oidc',
|
||||
'admin-tools', // import after authentication
|
||||
'registration-page', // import after authentication
|
||||
'instance-stats',
|
||||
'template-gallery',
|
||||
'git-bridge',
|
||||
'github-sync',
|
||||
'token-api',
|
||||
'git-sync/github',
|
||||
'git-sync/gitlab',
|
||||
'git-sync/gitea',
|
||||
'zotero',
|
||||
'bib-editor',
|
||||
],
|
||||
viewIncludes: {},
|
||||
|
||||
@@ -1245,8 +1292,6 @@ module.exports = {
|
||||
},
|
||||
|
||||
enablePandocConversions: process.env.ENABLE_PANDOC_CONVERSIONS === 'true',
|
||||
// local login form is shown by default; set ENABLE_LOCAL_LOGIN_MASK=false to hide it (e.g. SSO-only login)
|
||||
enableLocalLoginMask: process.env.ENABLE_LOCAL_LOGIN_MASK !== 'false',
|
||||
|
||||
oauthProviders: {
|
||||
...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && {
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
"ai_shortcut_on_empty_lines": "",
|
||||
"alignment": "",
|
||||
"all_borders": "",
|
||||
"all_collections": "",
|
||||
"all_events": "",
|
||||
"all_features_in_group_standard_plus": "",
|
||||
"all_logs": "",
|
||||
@@ -368,6 +369,7 @@
|
||||
"collabs_per_proj_plus": "",
|
||||
"collabs_per_proj_plus_plural": "",
|
||||
"collapse": "",
|
||||
"collection": "",
|
||||
"column_width": "",
|
||||
"column_width_is_custom_click_to_resize": "",
|
||||
"column_width_is_x_click_to_resize": "",
|
||||
@@ -425,7 +427,6 @@
|
||||
"contacting_the_sales_team": "",
|
||||
"continue": "",
|
||||
"continue_github_merge": "",
|
||||
"continue_github_merge": "",
|
||||
"continue_to": "",
|
||||
"continue_to_checkout": "",
|
||||
"continue_using_free_features": "",
|
||||
@@ -887,11 +888,28 @@
|
||||
"github_repo": "",
|
||||
"github_repository_diverged": "",
|
||||
"github_symlink_error": "",
|
||||
"github_sync_branch_name": "",
|
||||
"github_sync_description": "",
|
||||
"github_sync_error": "",
|
||||
"github_sync_file_limits_link": "",
|
||||
"github_sync_import_link_failed": "",
|
||||
"github_sync_import_pending": "",
|
||||
"github_sync_interrupted": "",
|
||||
"github_sync_invalid_branch_name": "",
|
||||
"github_sync_invalid_repository_name": "",
|
||||
"github_sync_linked_branch_moved": "",
|
||||
"github_sync_manual_resolution_allowed": "",
|
||||
"github_sync_merge_into_linked_branch": "",
|
||||
"github_sync_merge_recovery_branch": "",
|
||||
"github_sync_recovery_review_history": "",
|
||||
"github_sync_ref_not_found_description": "",
|
||||
"github_sync_repository_not_found_description": "",
|
||||
"github_sync_resolution_effect": "",
|
||||
"github_sync_resolution_reviewed_commit": "",
|
||||
"github_sync_resolution_unchanged": "",
|
||||
"github_sync_resolution_unverified": "",
|
||||
"github_sync_retry_link": "",
|
||||
"github_sync_unlink_error": "",
|
||||
"github_sync_upstream_error": "",
|
||||
"github_timeout_error": "",
|
||||
"github_too_many_files_error": "",
|
||||
@@ -1346,6 +1364,7 @@
|
||||
"move_to_the_left": "",
|
||||
"move_to_the_right": "",
|
||||
"my_library": "",
|
||||
"my_publications": "",
|
||||
"n_items": "",
|
||||
"n_items_plural": "",
|
||||
"n_more_collaborators": "",
|
||||
@@ -1759,6 +1778,8 @@
|
||||
"reference_manager_searched_groups": "",
|
||||
"reference_manager_unlinked": "",
|
||||
"reference_managers": "",
|
||||
"reference_picker_hide_hint": "",
|
||||
"reference_picker_shortcut_hint": "",
|
||||
"reference_search": "",
|
||||
"reference_search_setting": "",
|
||||
"reference_search_results": "",
|
||||
@@ -1828,6 +1849,7 @@
|
||||
"repository_visibility": "",
|
||||
"republish": "",
|
||||
"request_edit_access": "",
|
||||
"required_field_missing": "",
|
||||
"required_fields_missing": "",
|
||||
"resend": "",
|
||||
"resend_activation_email": "",
|
||||
@@ -2756,6 +2778,7 @@
|
||||
"your_git_access_info_bullet_3": "",
|
||||
"your_git_access_info_bullet_4": "",
|
||||
"your_git_access_info_bullet_5": "",
|
||||
"your_git_access_info_review_comments_api": "",
|
||||
"your_git_access_tokens": "",
|
||||
"your_limit_will_reset_in_time_or_speak_to_admin": "",
|
||||
"your_message_to_collaborators": "",
|
||||
@@ -2810,6 +2833,7 @@
|
||||
"zoom_to": "",
|
||||
"zotero": "",
|
||||
"zotero_account_linked_successfully": "",
|
||||
"zotero_could_not_load_collections": "",
|
||||
"zotero_dynamic_sync_description": "",
|
||||
"zotero_groups_loading_error": "",
|
||||
"zotero_groups_relink": "",
|
||||
@@ -2830,5 +2854,100 @@
|
||||
"zotero_sync_success_plural": "",
|
||||
"zotero_sync_up_to_date": "",
|
||||
"zotero_upgrade_prompt_content": "",
|
||||
"zotero_upgrade_prompt_title": ""
|
||||
"zotero_upgrade_prompt_title": "",
|
||||
"add_field": "",
|
||||
"add_name": "",
|
||||
"add_optional_field": "",
|
||||
"author_name": "",
|
||||
"badly_formatted_field": "",
|
||||
"badly_formatted_fields": "",
|
||||
"bib_field_group_books_and_volumes": "",
|
||||
"bib_field_group_contributors": "",
|
||||
"bib_field_group_digital_and_online": "",
|
||||
"bib_field_group_events_and_conferences": "",
|
||||
"bib_field_group_language_and_origin": "",
|
||||
"bib_field_group_periodicals_and_journals": "",
|
||||
"bib_field_group_publication_details": "",
|
||||
"bib_field_helper_doi_identifier": "",
|
||||
"bib_field_helper_page_range": "",
|
||||
"bib_field_helper_preprint_archive_identifier": "",
|
||||
"bib_syntax_duplicate_field": "",
|
||||
"bib_syntax_expected_comma_after_citation_key": "",
|
||||
"bib_syntax_expected_comma_before_field": "",
|
||||
"bib_syntax_expected_equals_after_field_name": "",
|
||||
"bib_syntax_invalid_entry_header": "",
|
||||
"bib_syntax_invalid_field": "",
|
||||
"bib_syntax_line": "",
|
||||
"bib_syntax_missing_closing_brace_opened_on_line": "",
|
||||
"bib_syntax_missing_closing_quote": "",
|
||||
"bib_syntax_unexpected_comma": "",
|
||||
"bibliography_editor_load_error": "",
|
||||
"bibtex_doi_description": "",
|
||||
"citation_key": "",
|
||||
"citation_key_contains_invalid_characters": "",
|
||||
"collapse_optional": "",
|
||||
"confirm_delete_entry": "",
|
||||
"confirm_delete_references_count": "",
|
||||
"correct_syntax_error_in_code_mode": "",
|
||||
"correct_syntax_errors_in_code_mode": "",
|
||||
"could_not_import_this_reference": "",
|
||||
"could_not_parse_this_reference": "",
|
||||
"could_not_repair_this_reference": "",
|
||||
"could_not_retrieve_doi_metadata": "",
|
||||
"could_not_save_bib_file": "",
|
||||
"curly_braces_must_be_balanced": "",
|
||||
"date_format_hint": "",
|
||||
"delete_entries": "",
|
||||
"delete_entry": "",
|
||||
"doi_format_hint": "",
|
||||
"either_a_or_b_is_required": "",
|
||||
"enter_field_name": "",
|
||||
"enter_orcid_id": "",
|
||||
"enter_type": "",
|
||||
"entry_has_syntax_error": "",
|
||||
"entry_has_syntax_errors": "",
|
||||
"entry_has_warnings": "",
|
||||
"entry_type": "",
|
||||
"expand_optional": "",
|
||||
"failed_to_fetch_doi": "",
|
||||
"failed_to_load_works": "",
|
||||
"field_is_required": "",
|
||||
"generate": "",
|
||||
"import_failed": "",
|
||||
"import_from_provider": "",
|
||||
"invalid_doi_format": "",
|
||||
"invalid_response_from_server": "",
|
||||
"load_works": "",
|
||||
"missing_field_for_entry_type": "",
|
||||
"missing_fields_for_entry_type": "",
|
||||
"move_down": "",
|
||||
"move_up": "",
|
||||
"no_authors_found": "",
|
||||
"no_bibliography_entries_yet_add_one": "",
|
||||
"no_entries_match_your_search": "",
|
||||
"no_name": "",
|
||||
"no_works_found_for_this_author": "",
|
||||
"orcid_crossref_rate_limited": "",
|
||||
"orcid_enriched_reference": "",
|
||||
"orcid_enriched_references": "",
|
||||
"orcid_enrichment_failed_reference": "",
|
||||
"orcid_enrichment_failed_references": "",
|
||||
"orcid_id": "",
|
||||
"orcid_imported_reference": "",
|
||||
"orcid_imported_references": "",
|
||||
"remove_author": "",
|
||||
"remove_field": "",
|
||||
"resolving": "",
|
||||
"search_bib_file": "",
|
||||
"search_by_name": "",
|
||||
"search_failed": "",
|
||||
"search_orcid_by_name_or_id": "",
|
||||
"select_a_type_to_see_the_required_fields": "",
|
||||
"separate_multiple_names_with_and": "",
|
||||
"some_works_could_not_be_fetched": "",
|
||||
"some_works_could_not_be_imported": "",
|
||||
"untitled": "",
|
||||
"url_format_hint": "",
|
||||
"works_by_author": "",
|
||||
"year_format_hint": ""
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function generateBaseCitationKey({
|
||||
}): string {
|
||||
const firstAuthor = authors.names[0]
|
||||
if (!firstAuthor) return ''
|
||||
const surname = firstAuthor.toLast().replace(/[^A-Za-z0-9]/g, '')
|
||||
const surname = firstAuthor.toLast().replace(/[^\p{L}\p{N}]/gu, '')
|
||||
if (!surname) return ''
|
||||
if (!YEAR_REGEX.test(year.trim())) return ''
|
||||
return surname + year.trim()
|
||||
|
||||
@@ -18,6 +18,7 @@ export type LinkedFileData = {
|
||||
zotero: {
|
||||
provider: 'zotero'
|
||||
group_id: string | null
|
||||
collection_key?: string | null
|
||||
format: 'bibtex' | 'biblatex'
|
||||
importedAt: Date | string
|
||||
importer_id: string
|
||||
|
||||
@@ -9,6 +9,8 @@ function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
|
||||
if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
|
||||
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
|
||||
if (origin?.kind === 'github') result = t('history_entry_origin_github')
|
||||
if (origin?.kind === 'gitlab') result = t('history_entry_origin_gitlab')
|
||||
if (origin?.kind === 'gitea') result = t('history_entry_origin_gitea')
|
||||
|
||||
if (result) {
|
||||
return <span className="history-version-origin">({result})</span>
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Meta {
|
||||
| 'upload'
|
||||
| 'git-bridge'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'gitea'
|
||||
| 'history-resync'
|
||||
| 'history-migration'
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const RailLayout = () => {
|
||||
const { features } = useProjectContext()
|
||||
const { isRestrictedTokenMember } = useEditorContext()
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const { isOverleaf, githubSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled, giteaSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const { view, setSettingsShown, focusMode } = useLayoutContext()
|
||||
|
||||
@@ -119,7 +119,7 @@ export const RailLayout = () => {
|
||||
icon: 'integration_instructions',
|
||||
title: t('integrations'),
|
||||
component: <IntegrationsPanel />,
|
||||
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !zoteroEnabled,
|
||||
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !gitlabSyncEnabled && !giteaSyncEnabled && !zoteroEnabled,
|
||||
},
|
||||
{
|
||||
key: 'review-panel',
|
||||
@@ -149,6 +149,8 @@ export const RailLayout = () => {
|
||||
isOverleaf,
|
||||
gitBridgeEnabled,
|
||||
githubSyncEnabled,
|
||||
gitlabSyncEnabled,
|
||||
giteaSyncEnabled,
|
||||
zoteroEnabled,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -251,7 +251,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
|
||||
}
|
||||
if (
|
||||
update.meta.type === 'external' &&
|
||||
(update.meta.source === 'git-bridge' || update.meta.source === 'github')
|
||||
(update.meta.source === 'git-bridge' || update.meta.source === 'github' || update.meta.source === 'gitlab' || update.meta.source === 'gitea')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,15 +13,6 @@ type ProjectSettingsSetterContextValue = {
|
||||
setSpellCheckLanguage: (
|
||||
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
|
||||
) => void
|
||||
setLanguageToolEnabled: (
|
||||
languageToolEnabled: ProjectSettings['languageToolEnabled']
|
||||
) => void
|
||||
setLanguageToolPicky: (
|
||||
languageToolPicky: ProjectSettings['languageToolPicky']
|
||||
) => void
|
||||
setLanguageToolLanguage: (
|
||||
languageToolLanguage: ProjectSettings['languageToolLanguage']
|
||||
) => void
|
||||
setReferenceFormat: (
|
||||
referenceFormat: ProjectSettings['referenceFormat']
|
||||
) => Promise<void>
|
||||
@@ -81,12 +72,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setSpellCheckLanguage,
|
||||
referenceFormat,
|
||||
setReferenceFormat,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
} = useProjectWideSettings()
|
||||
|
||||
const {
|
||||
@@ -150,12 +135,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
@@ -214,12 +193,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
|
||||
@@ -254,7 +254,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
|
||||
},
|
||||
{
|
||||
key: 'write-and-cite-settings',
|
||||
component: <ReferenceSearchSetting />,
|
||||
component: ReferenceSearchSetting ? <ReferenceSearchSetting /> : null,
|
||||
hidden: !ReferenceSearchSetting,
|
||||
},
|
||||
],
|
||||
|
||||
-42
@@ -53,33 +53,6 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolEnabled = useCallback(
|
||||
(languageToolEnabled: ProjectSettings['languageToolEnabled']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolEnabled })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolPicky = useCallback(
|
||||
(languageToolPicky: ProjectSettings['languageToolPicky']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolPicky })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolLanguage = useCallback(
|
||||
(languageToolLanguage: ProjectSettings['languageToolLanguage']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolLanguage })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// data is not available on initial mounting
|
||||
const dataAvailable = !!project
|
||||
@@ -90,9 +63,6 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
socket.on('png2pdfUpdated', setPng2pdf)
|
||||
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
|
||||
socket.on('referenceFormatUpdated', setReferenceFormat)
|
||||
socket.on('languageToolEnabledUpdated', setLanguageToolEnabled)
|
||||
socket.on('languageToolPickyUpdated', setLanguageToolPicky)
|
||||
socket.on('languageToolLanguageUpdated', setLanguageToolLanguage)
|
||||
return () => {
|
||||
socket.removeListener('compilerUpdated', setCompiler)
|
||||
socket.removeListener('imageNameUpdated', setImageName)
|
||||
@@ -102,15 +72,6 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setSpellCheckLanguage
|
||||
)
|
||||
socket.removeListener('referenceFormatUpdated', setReferenceFormat)
|
||||
socket.removeListener(
|
||||
'languageToolEnabledUpdated',
|
||||
setLanguageToolEnabled
|
||||
)
|
||||
socket.removeListener('languageToolPickyUpdated', setLanguageToolPicky)
|
||||
socket.removeListener(
|
||||
'languageToolLanguageUpdated',
|
||||
setLanguageToolLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -121,8 +82,5 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setReferenceFormat,
|
||||
setPng2pdf,
|
||||
setSpellCheckLanguage,
|
||||
setLanguageToolEnabled,
|
||||
setLanguageToolPicky,
|
||||
setLanguageToolLanguage,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ProjectSettings } from '../utils/api'
|
||||
import useRootDocId from './use-root-doc-id'
|
||||
import useSaveProjectSettings from './use-save-project-settings'
|
||||
import useSetSpellCheckLanguage from './use-set-spell-check-language'
|
||||
import useSetLanguageToolSetting from './use-set-language-tool'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
@@ -50,9 +49,6 @@ export default function useProjectWideSettings() {
|
||||
|
||||
const { setRootDocId, rootDocId } = useRootDocId()
|
||||
const setSpellCheckLanguage = useSetSpellCheckLanguage()
|
||||
const setLanguageToolEnabled = useSetLanguageToolSetting('languageToolEnabled')
|
||||
const setLanguageToolPicky = useSetLanguageToolSetting('languageToolPicky')
|
||||
const setLanguageToolLanguage = useSetLanguageToolSetting('languageToolLanguage')
|
||||
|
||||
return {
|
||||
compiler: project?.compiler,
|
||||
@@ -67,11 +63,5 @@ export default function useProjectWideSettings() {
|
||||
setRootDocId,
|
||||
spellCheckLanguage: project?.spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled: project?.languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky: project?.languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage: project?.languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { type ProjectSettings } from '../utils/api'
|
||||
import useSaveProjectSettings from './use-save-project-settings'
|
||||
|
||||
type LanguageToolSettingKey =
|
||||
| 'languageToolEnabled'
|
||||
| 'languageToolPicky'
|
||||
| 'languageToolLanguage'
|
||||
|
||||
/**
|
||||
* Setter for the LanguageTool project settings (enabled / picky / language),
|
||||
* following the `useSet*` hook pattern of this directory.
|
||||
*/
|
||||
export default function useSetLanguageToolSetting(
|
||||
key: LanguageToolSettingKey
|
||||
) {
|
||||
const saveProjectSettings = useSaveProjectSettings()
|
||||
|
||||
return useCallback(
|
||||
(newValue: ProjectSettings[LanguageToolSettingKey]) => {
|
||||
saveProjectSettings(key, newValue).catch(() => {})
|
||||
},
|
||||
[key, saveProjectSettings]
|
||||
)
|
||||
}
|
||||
@@ -15,11 +15,6 @@ export interface ProjectSettings {
|
||||
name: string
|
||||
mainBibliographyDocId?: string
|
||||
referenceFormat?: 'bibtex' | 'biblatex'
|
||||
// LanguageTool grammar check (project-wide; defaults when unset:
|
||||
// enabled=true, picky=true → level=picky, language='auto')
|
||||
languageToolEnabled?: boolean
|
||||
languageToolPicky?: boolean
|
||||
languageToolLanguage?: string
|
||||
}
|
||||
|
||||
type SaveUserSettings = Partial<
|
||||
|
||||
@@ -4,16 +4,19 @@ import { useTranslation } from 'react-i18next'
|
||||
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
|
||||
import getMeta from '../../utils/meta'
|
||||
|
||||
const allIntegrationPanelComponents = importOverleafModules(
|
||||
'integrationPanelComponents'
|
||||
) as { import: { default: ElementType }; path: string }[]
|
||||
const exposedSettings = getMeta('ol-ExposedSettings')
|
||||
const enabledModules = [
|
||||
...(getMeta('ol-gitBridgeEnabled') ? ['git-bridge'] : []),
|
||||
...(exposedSettings.githubSyncEnabled ? ['git-sync/github'] : []),
|
||||
...(exposedSettings.gitlabSyncEnabled ? ['git-sync/gitlab'] : []),
|
||||
...(exposedSettings.giteaSyncEnabled ? ['git-sync/gitea'] : []),
|
||||
...(exposedSettings.zoteroEnabled ? ['zotero'] : []),
|
||||
]
|
||||
|
||||
const integrationPanelComponents = allIntegrationPanelComponents.filter(
|
||||
({ path }) =>
|
||||
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
|
||||
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
|
||||
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
|
||||
)
|
||||
const integrationPanelComponents = (importOverleafModules(
|
||||
'integrationPanelComponents'
|
||||
) as { import: { default: ElementType }; path: string }[])
|
||||
.filter(({ path }) => enabledModules.some(modulePath => path.includes(modulePath)))
|
||||
|
||||
export default function IntegrationsPanel() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -58,7 +58,7 @@ function NewProjectButton({
|
||||
align = 'start',
|
||||
}: NewProjectButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { templateLinks, githubSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { templateLinks, githubSyncEnabled, gitlabSyncEnabled, giteaSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const [modal, setModal] =
|
||||
useState<Nullable<NewProjectButtonModalVariant>>(null)
|
||||
const portalTemplates = getMeta('ol-portalTemplates') || []
|
||||
@@ -167,11 +167,27 @@ function NewProjectButton({
|
||||
const [importProjectFromGithubMenu] = !githubSyncEnabled ? [] : importOverleafModules(
|
||||
'importProjectFromGithubMenu'
|
||||
)
|
||||
|
||||
const [importProjectFromGitLabMenu] = !gitlabSyncEnabled ? [] : importOverleafModules(
|
||||
'importProjectFromGitLabMenu'
|
||||
)
|
||||
|
||||
const [importProjectFromGiteaMenu] = !giteaSyncEnabled ? [] : importOverleafModules(
|
||||
'importProjectFromGiteaMenu'
|
||||
)
|
||||
|
||||
const ImportProjectFromGithubMenu: JSXElementConstructor<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}> = importProjectFromGithubMenu?.import.default
|
||||
|
||||
const ImportProjectFromGitLabMenu: JSXElementConstructor<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}> = importProjectFromGitLabMenu?.import.default
|
||||
|
||||
const ImportProjectFromGiteaMenu: JSXElementConstructor<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}> = importProjectFromGiteaMenu?.import.default
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLDropdown
|
||||
@@ -259,6 +275,30 @@ function NewProjectButton({
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{ImportProjectFromGitLabMenu && (
|
||||
<li role="none">
|
||||
<ImportProjectFromGitLabMenu
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'import_from_gitlab',
|
||||
dropdownMenuEvent: 'import-from-gitlab',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{ImportProjectFromGiteaMenu && (
|
||||
<li role="none">
|
||||
<ImportProjectFromGiteaMenu
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'import_from_gitea',
|
||||
dropdownMenuEvent: 'import-from-gitea',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
<OLDropdownDivider />
|
||||
<OLDropdownHeader aria-hidden="true">
|
||||
{t('templates')}
|
||||
|
||||
+20
@@ -15,6 +15,8 @@ export type NewProjectButtonModalVariant =
|
||||
| 'example_project'
|
||||
| 'upload_project'
|
||||
| 'import_from_github'
|
||||
| 'import_from_gitlab'
|
||||
| 'import_from_gitea'
|
||||
| 'import_docx'
|
||||
| 'import_markdown'
|
||||
|
||||
@@ -36,6 +38,20 @@ function NewProjectButtonModal({
|
||||
onHide: () => void
|
||||
}> = importProjectFromGithubModalWrapper?.import.default
|
||||
|
||||
const [importProjectFromGitLabModalWrapper] = importOverleafModules(
|
||||
'importProjectFromGitLabModalWrapper'
|
||||
)
|
||||
const ImportProjectFromGitLabModalWrapper: JSXElementConstructor<{
|
||||
onHide: () => void
|
||||
}> = importProjectFromGitLabModalWrapper?.import.default
|
||||
|
||||
const [importProjectFromGiteaModalWrapper] = importOverleafModules(
|
||||
'importProjectFromGiteaModalWrapper'
|
||||
)
|
||||
const ImportProjectFromGiteaModalWrapper: JSXElementConstructor<{
|
||||
onHide: () => void
|
||||
}> = importProjectFromGiteaModalWrapper?.import.default
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const openProject = useCallback(
|
||||
@@ -82,6 +98,10 @@ function NewProjectButtonModal({
|
||||
)
|
||||
case 'import_from_github':
|
||||
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
||||
case 'import_from_gitlab':
|
||||
return <ImportProjectFromGitLabModalWrapper onHide={onHide} />
|
||||
case 'import_from_gitea':
|
||||
return <ImportProjectFromGiteaModalWrapper onHide={onHide} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
+35
-1
@@ -67,7 +67,7 @@ function WelcomeMessageCreateNewProjectDropdown({
|
||||
useFeatureFlag('import-markdown') &&
|
||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||
|
||||
const { isOverleaf, githubSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled, giteaSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const handleDropdownItemClick = useCallback(
|
||||
(
|
||||
@@ -184,6 +184,40 @@ function WelcomeMessageCreateNewProjectDropdown({
|
||||
</OLDropdownItem>
|
||||
</li>
|
||||
)}
|
||||
{(isOverleaf || gitlabSyncEnabled) && (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(
|
||||
e,
|
||||
'import_from_gitlab',
|
||||
'import-from-gitlab'
|
||||
)
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('import_from_gitlab')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
{(isOverleaf || giteaSyncEnabled) && (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(
|
||||
e,
|
||||
'import_from_gitea',
|
||||
'import-from-gitea'
|
||||
)
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('import_from_gitea')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
{(portalTemplates?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<OLDropdownDivider />
|
||||
|
||||
@@ -19,11 +19,13 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
|
||||
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
|
||||
const gitlabSyncEnabled = getMeta('ol-ExposedSettings').gitlabSyncEnabled
|
||||
const giteaSyncEnabled = getMeta('ol-ExposedSettings').giteaSyncEnabled
|
||||
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
|
||||
|
||||
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
|
||||
({ path }) =>
|
||||
(githubSyncEnabled || !path.includes('github-sync'))
|
||||
(githubSyncEnabled || !path.includes('github-sync')) && (gitlabSyncEnabled || !path.includes('gitlab-sync')) && (giteaSyncEnabled || !path.includes('gitea-sync'))
|
||||
)
|
||||
|
||||
function LinkingSection() {
|
||||
@@ -55,7 +57,7 @@ function LinkingSection() {
|
||||
}[]
|
||||
|
||||
const renderSyncSection =
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || gitlabSyncEnabled || giteaSyncEnabled
|
||||
|
||||
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
|
||||
oauth2ServerComponents
|
||||
|
||||
@@ -55,7 +55,6 @@ import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
|
||||
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { SearchQuery } from '@codemirror/search'
|
||||
@@ -110,33 +109,6 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
|
||||
const hunspellManager = useHunspell(spellCheckLanguage)
|
||||
|
||||
// ── LanguageTool grammar check (project-wide settings) ─────────────────────
|
||||
// Stored on the project (shared by all collaborators). Defaults when unset:
|
||||
// enabled + picky ON → level=picky. Values are handed to the CodeMirror
|
||||
// extension through `createExtensions` below, and every change is
|
||||
// announced with a window event the extension listens for.
|
||||
const languageTool = {
|
||||
available:
|
||||
getMeta('ol-ExposedSettings')?.languageToolAvailable === true,
|
||||
// off for read-only views (like spell-checking)
|
||||
enabled:
|
||||
(permissions.write || permissions.trackedWrite) &&
|
||||
project?.languageToolEnabled !== false,
|
||||
picky: project?.languageToolPicky !== false,
|
||||
language: project?.languageToolLanguage || 'auto',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('lt:settings-changed', { detail: languageTool })
|
||||
)
|
||||
}, [
|
||||
languageTool.available,
|
||||
languageTool.enabled,
|
||||
languageTool.picky,
|
||||
languageTool.language,
|
||||
])
|
||||
|
||||
const { showVisual: visual, trackChanges } = useEditorPropertiesContext()
|
||||
|
||||
const { referenceKeys, searchLocalReferences } = useReferencesContext()
|
||||
@@ -390,7 +362,6 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
settings: settingsRef.current,
|
||||
phrases: phrasesRef.current,
|
||||
spelling: spellingRef.current,
|
||||
languageTool,
|
||||
showVisual: showVisualRef.current,
|
||||
previewByPath: previewByPathRef.current,
|
||||
currentDocFolder: dirnameRef.current(currentDocument.doc_id),
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function AdminMenu({
|
||||
canDisplaySplitTestMenu,
|
||||
canDisplaySurveyMenu,
|
||||
canDisplayScriptLogMenu,
|
||||
canDisplayInstanceStats,
|
||||
adminUrl,
|
||||
}: Pick<
|
||||
DefaultNavbarMetadata,
|
||||
@@ -19,6 +20,7 @@ export default function AdminMenu({
|
||||
| 'canDisplaySplitTestMenu'
|
||||
| 'canDisplaySurveyMenu'
|
||||
| 'canDisplayScriptLogMenu'
|
||||
| 'canDisplayInstanceStats'
|
||||
| 'adminUrl'
|
||||
>) {
|
||||
const sendProjectListMB = useSendProjectListMB()
|
||||
@@ -68,6 +70,11 @@ export default function AdminMenu({
|
||||
View Script Logs
|
||||
</NavDropdownLinkItem>
|
||||
) : null}
|
||||
{canDisplayInstanceStats ? (
|
||||
<NavDropdownLinkItem href="/admin/instance-stats">
|
||||
Instance Statistics
|
||||
</NavDropdownLinkItem>
|
||||
) : null}
|
||||
</NavDropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ function DefaultNavbar(
|
||||
canDisplaySplitTestMenu,
|
||||
canDisplaySurveyMenu,
|
||||
canDisplayScriptLogMenu,
|
||||
canDisplayInstanceStats,
|
||||
enableUpgradeButton,
|
||||
suppressNavbarRight,
|
||||
suppressNavContentLinks,
|
||||
@@ -117,6 +118,7 @@ function DefaultNavbar(
|
||||
canDisplaySplitTestMenu={canDisplaySplitTestMenu}
|
||||
canDisplaySurveyMenu={canDisplaySurveyMenu}
|
||||
canDisplayScriptLogMenu={canDisplayScriptLogMenu}
|
||||
canDisplayInstanceStats={canDisplayInstanceStats}
|
||||
adminUrl={adminUrl}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DefaultNavbarMetadata = {
|
||||
canDisplaySplitTestMenu: boolean
|
||||
canDisplaySurveyMenu: boolean
|
||||
canDisplayScriptLogMenu: boolean
|
||||
canDisplayInstanceStats: boolean
|
||||
enableUpgradeButton: boolean
|
||||
suppressNavbarRight: boolean
|
||||
suppressNavContentLinks: boolean
|
||||
|
||||
@@ -126,6 +126,9 @@
|
||||
background-color: var(--bg-disabled-themed);
|
||||
}
|
||||
}
|
||||
.form-check-label-description.form-text {
|
||||
color: var(--content-secondary-themed);
|
||||
}
|
||||
button.btn-link, a {
|
||||
color: var(--link-ui-themed) !important;
|
||||
&:hover {
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
@import '../../../modules/admin-tools/frontend/styles/manage-projects-page';
|
||||
@import '../../../modules/admin-tools/frontend/styles/manage-users-page';
|
||||
@import '../../../modules/admin-tools/frontend/styles/pagination-dark';
|
||||
@import '../../../modules/github-sync/frontend/styles/github-sync';
|
||||
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
|
||||
@import '../../../modules/git-sync/common/frontend/stylesheets/git-sync';
|
||||
@import '../../../modules/reference-picker/frontend/stylesheets/reference-picker';
|
||||
|
||||
@@ -50,13 +50,7 @@ body {
|
||||
.navbar-default {
|
||||
position: relative;
|
||||
|
||||
// Nudge the logo down and to the right so that it lines up with the
|
||||
// sidebar items below it. Instances that set both a custom header image
|
||||
// and a nav title (OVERLEAF_HEADER_IMAGE_URL and OVERLEAF_NAV_TITLE on
|
||||
// Server Pro) show the title next to the logo, and the nudge would move
|
||||
// only the logo and leave the two misaligned, so skip it in that case and
|
||||
// keep the pair centred in the navbar as it is on every other page.
|
||||
.navbar-header .navbar-brand:not(:has(.navbar-title)) .navbar-logo {
|
||||
.navbar-header .navbar-logo {
|
||||
@include media-breakpoint-up(md) {
|
||||
position: relative;
|
||||
top: calc(var(--spacing-05) * 2);
|
||||
|
||||
+175
-25
@@ -112,6 +112,7 @@
|
||||
"add_on": "Erweiterung",
|
||||
"add_ons": "Erweiterungen",
|
||||
"add_or_remove_project_from_tag": "Projekt zum Stichwort __tagName__ hinzufügen oder davon entfernen",
|
||||
"add_reference": "Referenz hinzufügen",
|
||||
"add_role_and_department": "Rolle und Abteilung hinzufügen",
|
||||
"add_to_dictionary": "Zum Wörterbuch hinzufügen",
|
||||
"add_to_tag": "Zu Stichwort hinzufügen",
|
||||
@@ -153,6 +154,7 @@
|
||||
"alignment": "Ausrichtung",
|
||||
"all": "Alle",
|
||||
"all_borders": "Alle Rahmen",
|
||||
"all_collections": "Alle Sammlungen",
|
||||
"all_events": "Alle Ereignisse",
|
||||
"all_features_in_group_standard_plus": "Alle Funktionen von Group Standard, plus:",
|
||||
"all_logs": "Alle Logs",
|
||||
@@ -201,7 +203,7 @@
|
||||
"ascending": "Aufsteigend",
|
||||
"ask_proj_owner_to_add_you_as_github_collaborator": "Du hast keine Berechtigung, in das verknüpfte Repository (<0>__repoFullName__</0>) zu pushen. Bitte den Projektinhaber (<1>__projectOwnerEmail__</1>), dich als Kollaborator zu diesem Repository hinzuzufügen.",
|
||||
"ask_proj_owner_to_export_to_github": "Bitte den Projektinhaber (<0>__projectOwnerEmail__</0>), das Projekt in ein Git-Repository zu exportieren und dich als Kollaborator hinzuzufügen.",
|
||||
"ask_proj_owner_to_unlink_from_current_github": "Bitte den Projektbesitzer (<0>__projectOwnerEmail__</0>), das Projekt vom aktuellen GitHub-Repository zu entkoppeln und eine Verbindung zu einem anderen herzustellen.",
|
||||
"ask_proj_owner_to_unlink_from_current_github": "Bitte den Projektbesitzer (<0>__projectOwnerEmail__</0>), das Projekt vom aktuellen __provider__-Repository zu entkoppeln und eine Verbindung zu einem anderen herzustellen.",
|
||||
"ask_proj_owner_to_upgrade_for_full_history": "Bitte den Projektbesitzer um ein Abonnement-Upgrade, um auf den vollständigen Verlauf dieses Projekts zugreifen zu können.",
|
||||
"ask_repo_owner_to_reconnect": "Bitte den Besitzer des GitHub-Repositorys (<0>__repoOwnerEmail__</0>), das Projekt neu zu verbinden.",
|
||||
"ask_repo_owner_to_renew_overleaf_subscription": "Bitte den Besitzer des GitHub-Repositorys (<0>__repoOwnerEmail__</0>), sein __appName__-Abonnement zu erneuern und das Projekt neu zu verbinden.",
|
||||
@@ -237,6 +239,7 @@
|
||||
"beta_program_opt_in_action": "Beta-Programm beitreten",
|
||||
"beta_program_opt_out_action": "Beta-Programm verlassen",
|
||||
"bibliographies": "Literaturverzeichnisse",
|
||||
"bibtex_duplicates_keys": "Dieser Zitationsschlüssel wird bereits von einem anderen Eintrag verwendet. Doppelte Schlüssel verursachen Zitierfehler – benenne diesen Schlüssel um oder lösche einen der Einträge.",
|
||||
"billed_annually_at": "Jährliche Abrechnung über <0>__price__</0> <1>(inklusive Abonnement und Erweiterungen)</1>",
|
||||
"billed_monthly_at": "Monatliche Abrechnung über <0>__price__</0> <1>(inklusive Abonnement und Erweiterungen)</1>",
|
||||
"billed_yearly": "jährliche Abrechnung",
|
||||
@@ -303,10 +306,11 @@
|
||||
"check_your_email": "Bitte prüfe deinen E-Mail-Posteingang.",
|
||||
"checking": "Überprüfe",
|
||||
"checking_dropbox_status": "Dropbox-Status prüfen",
|
||||
"checking_project_github_status": "Status auf GitHub abfragen",
|
||||
"checking_project_github_status": "Status auf __provider__ abfragen",
|
||||
"choose_a_custom_color": "Wähle eine eigene Farbe",
|
||||
"choose_your_plan": "Wähle deinen Kontotyp",
|
||||
"citation": "Zitat",
|
||||
"citation_key_autogenerate_helper": "Wird bei leerem Feld automatisch aus Autor und Jahr erzeugt",
|
||||
"cite_directly_or_import_references": "Referenzen importieren",
|
||||
"city": "Stadt",
|
||||
"clear_cached_files": "Zwischengespeicherte Dateien löschen",
|
||||
@@ -330,6 +334,7 @@
|
||||
"collabs_per_proj": "__count__ Mitarbeiter pro Projekt",
|
||||
"collabs_per_proj_plural": "__count__ Mitarbeiter pro Projekt",
|
||||
"collapse": "Einklappen",
|
||||
"collection": "Sammlung",
|
||||
"comment": "Kommentar",
|
||||
"comment_only": "Nur Kommentare",
|
||||
"common": "Häufige",
|
||||
@@ -390,7 +395,6 @@
|
||||
"coupon_code_is_not_valid_for_selected_plan": "Der Gutscheincode ist nicht gültig für das gewählte Abonnement",
|
||||
"coupons_not_included": "Dies beinhaltet nicht deine aktuellen Rabatte, die automatisch vor deiner nächsten Zahlung angewandt werden",
|
||||
"create": "Erstellen",
|
||||
"create_account": "Konto erstellen",
|
||||
"create_a_new_password_for_your_account": "Erstelle ein neues Passwort für dein Konto",
|
||||
"create_a_new_project": "Erstelle ein neues Projekt",
|
||||
"create_account": "Konto erstellen",
|
||||
@@ -398,7 +402,7 @@
|
||||
"create_new_account": "Neues Konto erstellen",
|
||||
"create_new_subscription": "Neues Abonnement erstellen",
|
||||
"create_new_tag": "Neues Stichwort erstellen",
|
||||
"create_project_in_github": "Ein GitHub Repository erstellen",
|
||||
"create_project_in_github": "Ein __provider__ Repository erstellen",
|
||||
"created_at": "Erstellt am",
|
||||
"creating": "Erstellung läuft",
|
||||
"credit_card": "Kreditkarte",
|
||||
@@ -448,6 +452,7 @@
|
||||
"department": "Abteilung",
|
||||
"descending": "Absteigend",
|
||||
"description": "Beschreibung",
|
||||
"details": "Details",
|
||||
"dictionary": "Wörterbuch",
|
||||
"did_you_know_institution_providing_professional": "Wusstest du, dass __institutionName__ allen bei __institutionName__ <0>kostenlose __appName__ „Professionell“-Funktionen</0> zur Verfügung stellt?",
|
||||
"disable_stop_on_first_error": "„Anhalten beim ersten Fehler“ deaktivieren",
|
||||
@@ -506,6 +511,7 @@
|
||||
"edit_dictionary_empty": "Dein benutzerdefiniertes Wörterbuch ist leer.",
|
||||
"edit_dictionary_remove": "Aus Wörterbuch entfernen",
|
||||
"edit_figure": "Abbildung bearbeiten",
|
||||
"edit_reference": "Referenz bearbeiten",
|
||||
"edit_tag": "Stichwort bearbeiten",
|
||||
"edit_tag_name": "Stichwortname bearbeiten",
|
||||
"edit_template": "Vorlage bearbeiten",
|
||||
@@ -547,6 +553,7 @@
|
||||
"enabling": "Wird aktiviert",
|
||||
"end_of_document": "Ende des Dokuments",
|
||||
"ensure_recover_account": "So kann sie zur Wiederherstellung deines __appName__-Kontos verwendet werden, falls du den Zugang zu deiner primären E-Mail-Adresse verlierst.",
|
||||
"enter_manually": "Manuell eingeben",
|
||||
"enter_the_code": "Gib den 6-stelligen Code ein, der an __email__ gesendet wurde.",
|
||||
"enter_the_confirmation_code": "Gib den 6-stelligen Bestätigungscode ein, der an <0>__email__</0> gesendet wurde.",
|
||||
"enter_your_email": "Gib deine E-Mail ein",
|
||||
@@ -570,7 +577,7 @@
|
||||
"explore_features": "Funktionen erkunden",
|
||||
"explore_plans": "Abonnements erkunden",
|
||||
"export_csv": "CSV-Datei exportieren",
|
||||
"export_project_to_github": "Projekt nach GitHub exportieren",
|
||||
"export_project_to_github": "Projekt nach __provider__ exportieren",
|
||||
"failed_to_publish_as_a_template": "Veröffentlichung als Vorlage fehlgeschlagen.",
|
||||
"failed_to_send_managed_user_invite_to_email": "Der Versand der Einladung für Verwaltete Benutzer an <0>__email__</0> hat nicht funktioniert. Bitte versuche es später noch einmal.",
|
||||
"failed_to_send_registration_email": "Die Registrierungs-E-Mail konnte nicht gesendet werden. Bitte überprüfe die E-Mail-Adresse.",
|
||||
@@ -620,6 +627,7 @@
|
||||
"for_publishers": "Für Verlage",
|
||||
"for_students": "Für Studierende",
|
||||
"for_universities": "Für Universitäten",
|
||||
"format": "Format",
|
||||
"forgot_your_password": "Passwort vergessen",
|
||||
"found_matching_deleted_users": "__count__ übereinstimmende gelöschte Benutzer gefunden",
|
||||
"fr": "Französisch",
|
||||
@@ -677,19 +685,41 @@
|
||||
"github_is_premium": "GitHub-Sync ist eine Premiumfunktion",
|
||||
"github_large_files_error": "Zusammenführung fehlgeschlagen: Dein GitHub-Repository enthält Dateien mit einer Dateigröße von mehr als 50 MB ",
|
||||
"github_manual_merge_user_prompt": "Bitte führe den <0>__sharelatex_branch__</0> mit dem Standard-Branch in Git zusammen. Klicke unten um fortzufahren, nachdem du manuell zusammengeführt hast.",
|
||||
"github_merge_failed_error": "Deine Änderungen in __appName__ und GitHub konnten nicht automatisch zusammengeführt werden.",
|
||||
"github_merge_failed_error": "Deine Änderungen in __appName__ und __provider__ konnten nicht automatisch zusammengeführt werden.",
|
||||
"github_no_master_branch_error": "Dieses Repository kann nicht importiert werden, da ihm ein Standard-Branch fehlt. Stell sicher, dass das Projekt einen Standard-Branch hat",
|
||||
"github_private_description": "Du wählst, wer dieses Repository sehen und etwas übergeben kann.",
|
||||
"github_public_description": "Jeder kann dieses Repository sehen. Du entscheidest wer committen darf.",
|
||||
"github_repository_diverged": "Der Standard-Branch des verknüpften Repositorys wurde forciert gepusht. Die Liste der letzten Commits unten kann aufgrund einer umgeschriebenen Historie seit der letzten Synchronisierung unvollständig sein.",
|
||||
"github_successfully_linked_description": "Danke, wir haben dein GitHub-Nutzerkonto erfolgreich mit __appName__ verknüpft. Du kannst die __appName__-Projekte jetzt in GitHub exportieren oder Projekte aus deinen GitHub-Repositories importieren.",
|
||||
"github_successfully_linked_description": "Danke, wir haben dein __provider__-Nutzerkonto erfolgreich mit __appName__ verknüpft. Du kannst die __appName__-Projekte jetzt in __provider__ exportieren oder Projekte aus deinen __provider__-Repositories importieren.",
|
||||
"github_symlink_error": "Dein GitHub-Repository enthält Dateien mit symbolischen Links, was derzeit von Overleaf nicht unterstützt wird. Entferne diese und versuche es erneut.",
|
||||
"github_sync_description": "Mit GitHub-Synchronisierung kannst du deine __appName__-Projekte mit GitHub-Repositories verlinken. Erstelle neue Commits aus __appName__ und führe sie mit Commits in GitHub zusammen.",
|
||||
"github_sync_error": "Entschuldigung, es gab ein Problem mit unserem GitHub-Dienst. Bitte versuche es später erneut.",
|
||||
"github_sync_repository_not_found_description": "Das verknüpfte Repository wurde entweder entfernt oder du hast keinen Zugriff mehr darauf. Du kannst die Synchronisierung mit einem neuen Repository einrichten, indem du das Projekt klonst und den Menüpunkt „GitHub“ verwendest. Du kannst das Repository auch von diesem Projekt trennen.",
|
||||
"github_sync_branch_name": "Branch-Name",
|
||||
"github_sync_description": "Mit __provider__-Synchronisierung kannst du deine __appName__-Projekte mit __provider__-Repositories verlinken. Erstelle neue Commits aus __appName__ und führe sie mit Commits in __provider__ zusammen.",
|
||||
"github_sync_error": "Entschuldigung, es gab ein Problem mit unserem __provider__-Dienst. Bitte versuche es später erneut.",
|
||||
"github_sync_file_limits_link": "Mehr über die Dateigrößenbeschränkungen von GitHub Sync erfahren",
|
||||
"github_sync_import_link_failed": "Dein Projekt wurde erstellt, aber die Verknüpfung mit dem Repository ist fehlgeschlagen. Versuche erneut, dieses Projekt zu verknüpfen, oder öffne es in Overleaf.",
|
||||
"github_sync_import_pending": "Ein Git-Import ist noch nicht abgeschlossen. Wähle Sync, um es erneut zu versuchen. Falls die Wiederherstellung wiederholt fehlschlägt, kann der Projekteigentümer Unlink wählen, um die Synchronisierung mit diesem Repository zu beenden.",
|
||||
"github_sync_interrupted": "Die Synchronisierung wurde unterbrochen. Einige Änderungen wurden möglicherweise bereits übernommen. Wähle Sync, um es erneut zu versuchen.",
|
||||
"github_sync_invalid_branch_name": "Ungültiger Branch-Name.",
|
||||
"github_sync_invalid_repository_name": "Ungültiger Repository-Name.",
|
||||
"github_sync_linked_branch_moved": "Der verknüpfte Branch hat sich geändert. Synchronisiere erneut, um seine Änderungen zu übernehmen.",
|
||||
"github_sync_manual_resolution_allowed": "Du kannst die Änderungen auch manuell in <0>__branch__</0> zusammenführen. Mit Fortfahren wird der Git-Verlauf überprüft und eine Bestätigung angefordert, falls sich die Zusammenführung nicht verifizieren lässt.",
|
||||
"github_sync_merge_into_linked_branch": "Führe <0>__source__</0> auf __provider__ mit <1>__target__</1> zusammen und löse die Dateikonflikte.",
|
||||
"github_sync_merge_recovery_branch": "Führe __branch__ mit __targetBranch__ zusammen und synchronisiere erneut, um die Wiederherstellung der Repository-Änderungen abzuschließen.",
|
||||
"github_sync_recovery_review_history": "Jemand hat das Projekt bearbeitet, während Git Sync Dateien importiert hat. Dabei wurden möglicherweise neuere Änderungen überschrieben. Überprüfe die betroffenen Dateien in Overleaf und stelle fehlende Änderungen aus dem Projektverlauf wieder her. Wähle Fortfahren, sobald das Projekt für die Zusammenführung mit Branch <0>__branch__</0> bereit ist.",
|
||||
"github_sync_ref_not_found_description": "Wir konnten GitHub nicht auf Änderungen an diesem Projekt überprüfen. Bitte versuche es in einigen Augenblicken erneut. Falls das Problem weiterhin besteht, prüfe status.overleaf.com und githubstatus.com auf gemeldete Störungen. Falls dort keine Störungen gemeldet werden, kannst du <0>die Verknüpfung dieses Projekts</0> mit seinem GitHub-Repository aufheben und die Synchronisierung erneut einrichten.",
|
||||
"github_sync_repository_not_found_description": "Das verknüpfte Repository wurde entweder entfernt oder du hast keinen Zugriff mehr darauf. Du kannst die Synchronisierung mit einem neuen Repository einrichten, indem du das Projekt klonst und den Menüpunkt „__provider__“ verwendest. Du kannst das Repository auch von diesem Projekt trennen.",
|
||||
"github_sync_resolution_effect": "Beim Fortfahren wird <0>__branch__</0> in Overleaf importiert. Overleaf-Änderungen, die seit dem Auftreten des Konflikts vorgenommen wurden, werden separat zusammengeführt.",
|
||||
"github_sync_resolution_reviewed_commit": "Letzter Commit in <0>__branch__</0>: <1>__head__</1>",
|
||||
"github_sync_resolution_unchanged": "Der verknüpfte Branch <0>__branch__</0> hat sich seit dem Auftreten dieses Konflikts nicht geändert.",
|
||||
"github_sync_resolution_unverified": "Der Git-Verlauf zeigt nicht, dass die Overleaf-Änderungen in <0>__branch__</0> zusammengeführt wurden. Überprüfe, ob dieser Branch die gewünschte Konfliktlösung enthält.",
|
||||
"github_sync_retry_link": "Verknüpfung erneut versuchen",
|
||||
"github_sync_unlink_error": "Leider konnten wir die Verknüpfung deines GitHub-Kontos nicht aufheben. Bitte versuche es in einigen Augenblicken erneut.",
|
||||
"github_sync_upstream_error": "Wir konnten GitHub nicht erreichen. Bitte versuche es in einigen Augenblicken erneut. Falls das Problem weiterhin besteht, prüfe status.overleaf.com und githubstatus.com auf gemeldete Störungen.",
|
||||
"github_timeout_error": "Zeitüberschreitung beim Synchronisieren deines Overleaf-Projekts mit GitHub. Dies kann daran liegen, dass die Gesamtgröße deines Projekts oder die Anzahl der zu synchronisierenden Dateien/Änderungen zu groß ist.",
|
||||
"github_too_many_files_error": "Dieses Repository kann nicht importiert werden, da es die maximal zulässige Anzahl von Dateien überschreitet",
|
||||
"github_validation_check": "Bitte prüfe ob der Repository-Name gültig ist und ob du die Rechte hast ein Git-Repository zu erstellen.",
|
||||
"github_validation_check_auth": "Bitte prüfe, ob du die Berechtigung hast, das Repository zu erstellen.",
|
||||
"github_validation_name_exists": "Ein Repository mit diesem Namen existiert bereits.",
|
||||
"github_workflow_authorize": "Autorisiere GitHub-Workflow-Dateien",
|
||||
"github_workflow_files_delete_github_repo": "Das Repository wurde auf GitHub erstellt, aber die Verknüpfung war nicht erfolgreich. Lösche das GitHub-Repository oder wähle einen neuen Namen.",
|
||||
"github_workflow_files_error": "Der GitHub-Synchronisierungsdienst __appName__ konnte GitHub-Workflow-Dateien (in .github/workflows/) nicht synchronisieren. Autorisiere __appName__ zum Bearbeiten deiner GitHub-Workflow-Dateien und versuche es erneut.",
|
||||
@@ -859,7 +889,8 @@
|
||||
"image_file": "Bild-Datei",
|
||||
"image_url": "Bild-URL",
|
||||
"image_width": "Bildbreite",
|
||||
"import_from_github": "Von GitHub importieren",
|
||||
"import": "Importieren",
|
||||
"import_from_github": "Von __provider__ importieren",
|
||||
"import_to_sharelatex": "In __appName__ importieren",
|
||||
"import_word_document": "Word-Dokument importieren",
|
||||
"imported_from_another_project_at_date": "Importiert aus <0>einem anderen Projekt</0> /__sourceEntityPathHTML__, am __formattedDate__ __relativeDate__",
|
||||
@@ -869,7 +900,7 @@
|
||||
"imported_from_zotero_at_date": "Importiert aus Zotero am __formattedDate__ __relativeDate__",
|
||||
"imported_from_zotero_at_date_by": "Importiert aus Zotero am __formattedDate__ __relativeDate__ von __importedByName__",
|
||||
"importing": "Importieren",
|
||||
"importing_and_merging_changes_in_github": "Änderungen werden in GitHub importiert und zusammengeführt.",
|
||||
"importing_and_merging_changes_in_github": "Änderungen werden in __provider__ importiert und zusammengeführt.",
|
||||
"in_order_to_have_a_secure_account_make_sure_your_password": "Um dein Konto abzusichern, stelle sicher, dass dein Passwort",
|
||||
"in_order_to_match_institutional_metadata_2": "Um deine institutionellen Metadaten abzugleichen, haben wir dein Konto mit <0>__email__</0> verknüpft.",
|
||||
"in_order_to_match_institutional_metadata_associated": "Um deine institutionellen Metadaten abzugleichen, wird dein Konto mit der E-Mail-Adresse <b>__email__</b> verknüpft.",
|
||||
@@ -1005,8 +1036,8 @@
|
||||
"link_institutional_email_get_started": "Verknüpfe eine institutionelle E-Mail-Adresse mit deinem Konto, um anzufangen.",
|
||||
"link_sharing": "Linkfreigabe",
|
||||
"link_sharing_is_on": "Linkfreigabe ist aktiviert",
|
||||
"link_to_github": "Verbinde mit deinem GitHub-Nutzerkonto",
|
||||
"link_to_github_description": "Du musst __appName__ erlauben, auf dein GitHub-Nutzerkonto zuzugreifen und deine Projekte zu synchronisieren.",
|
||||
"link_to_github": "Verbinde mit deinem __provider__-Nutzerkonto",
|
||||
"link_to_github_description": "Du musst __appName__ erlauben, auf dein __provider__-Nutzerkonto zuzugreifen und deine Projekte zu synchronisieren.",
|
||||
"link_to_mendeley": "Link zu Mendeley",
|
||||
"link_to_readcube": "Link zu ReadCube",
|
||||
"link_to_zotero": "Link zu Zotero",
|
||||
@@ -1017,7 +1048,7 @@
|
||||
"links": "Links",
|
||||
"loading": "Laden",
|
||||
"loading_content": "Erstelle Projekt",
|
||||
"loading_github_repositories": "Deine GitHub-Repositories werden geladen",
|
||||
"loading_github_repositories": "Deine __provider__-Repositories werden geladen",
|
||||
"loading_prices": "Preise werden geladen",
|
||||
"loading_recent_github_commits": "Neueste Commits werden geladen",
|
||||
"log_entry_description": "Protokolleintrag mit Level: __level__",
|
||||
@@ -1114,6 +1145,7 @@
|
||||
"more_than_one_kind_of_snippet_was_requested": "Der Link zum Öffnen dieses Inhalts auf Overleaf enthielt einige ungültige Parameter. Wenn dies bei Links auf einer bestimmten Website weiterhin auftritt, melde dies bitte dort.",
|
||||
"must_be_email_address": "Es muss eine E-Mail-Adresse sein!",
|
||||
"my_library": "Meine Bibliothek",
|
||||
"my_publications": "Meine Publikationen",
|
||||
"n_items": "__count__ Artikel",
|
||||
"n_items_plural": "__count__ Artikel",
|
||||
"name": "Name",
|
||||
@@ -1143,6 +1175,7 @@
|
||||
"newsletter_info_title": "Newsletter-Einstellungen",
|
||||
"newsletter_info_unsubscribed": "Du bist derzeit vom __appName__-Newsletter <0>abgemeldet</0>.",
|
||||
"next_payment_of_x_collectected_on_y": "Die nächste Zahlung von <0>__paymentAmmount__</0> wird am <1>__collectionDate__</1> abgebucht.",
|
||||
"next_reference": "Nächste Referenz",
|
||||
"nl": "Niederländisch",
|
||||
"no": "Norwegisch",
|
||||
"no_add_on_purchase_while_paused": "Du musst dein bestehendes Abonnement fortsetzen oder kündigen, um eine Erweiterung zu kaufen.",
|
||||
@@ -1151,7 +1184,7 @@
|
||||
"no_existing_password": "Bitte verwende das Formular zum Zurücksetzen des Passworts, um dein Passwort festzulegen",
|
||||
"no_featured_templates": "Keine Vorlagen ausgewählt",
|
||||
"no_members": "Keine Mitglieder",
|
||||
"no_new_commits_in_github": "Es gibt bei GitHub keine neuen Commits seit dem letzten Merge",
|
||||
"no_new_commits_in_github": "Es gibt bei __provider__ keine neuen Commits seit dem letzten Merge",
|
||||
"no_other_projects_found": "Keine anderen Projekte gefunden, bitte erstelle zuerst ein anderes Projekt",
|
||||
"no_other_sessions": "Keine andere Session aktiv",
|
||||
"no_pdf_error_explanation": "Dieser Kompiliervorgang hat kein PDF erzeugt. Das kann passieren, wenn:",
|
||||
@@ -1163,6 +1196,7 @@
|
||||
"no_preview_available": "Entschuldigung, es ist keine Vorschau verfügbar.",
|
||||
"no_project_notifications_description": "Du wirst keine Benachrichtigungen über dieses Projekt erhalten.",
|
||||
"no_projects": "Keine Projekte",
|
||||
"no_references_found": "Keine Referenzen gefunden",
|
||||
"no_search_results": "Keine Suchergebnisse",
|
||||
"no_selection_select_file": "Derzeit ist keine Datei ausgewählt. Bitte wähle eine Datei aus dem Dateibaum aus.",
|
||||
"no_symbols_found": "Keine Symbole gefunden",
|
||||
@@ -1236,6 +1270,8 @@
|
||||
"password_reset_token_expired": "Dein Passwortzurücksetz-Token ist nicht mehr gültig. Bitte fordere eine neue Passwortzurücksetz-Mail an und folge dem darin enthaltenen Link.",
|
||||
"password_too_long_please_reset": "Maximale Passwortlänge überschritten. Bitte setze dein Passwort zurück.",
|
||||
"password_was_detected_on_a_public_list_of_known_compromised_passwords": "Dieses Passwort wurde auf einer <0>öffentlichen Liste bekannter kompromittierter Passwörter</0> gefunden.",
|
||||
"paste_bibtex_or_dois_here": "BibTeX oder DOIs hier einfügen.",
|
||||
"paste_references": "Referenzen einfügen",
|
||||
"pause_subscription": "Abonnement pausieren",
|
||||
"pause_subscription_for": "Abonnement pausieren für",
|
||||
"payment_error_3ds_failed": "Wir konnten deine Zahlung nicht abschließen, da die Authentifizierung nicht erfolgreich war. Bitte versuche es erneut oder wähle eine andere Zahlungsmethode. Wenn das Problem weiterhin besteht, <0>kontaktiere uns</0> bitte.",
|
||||
@@ -1305,7 +1341,9 @@
|
||||
"premium_plan_label": "Du nutzt <b>Overleaf Premium</b>",
|
||||
"presentation": "Präsentation",
|
||||
"presentations": "Präsentationen",
|
||||
"preview": "Vorschau",
|
||||
"preview_editor_tabs": "Editor-Tab-Vorschau",
|
||||
"previous_reference": "Vorherige Referenz",
|
||||
"price": "Preis",
|
||||
"pricing": "Preise",
|
||||
"primary_email_check_question": "Ist <0>__email__</0> immer noch deine E-Mail-Adresse?",
|
||||
@@ -1333,7 +1371,7 @@
|
||||
"project_last_published_at": "Dein Projekt wurde zuletzt veröffentlicht am",
|
||||
"project_linked_to": "Dieses Projekt ist verlinkt mit",
|
||||
"project_name": "Projektname",
|
||||
"project_not_linked_to_github": "Dieses Projekt ist nicht mit einem GitHub Repository verlinkt. Du kannst ein neues Repository in GitHub erstellen:",
|
||||
"project_not_linked_to_github": "Dieses Projekt ist nicht mit einem __provider__ Repository verlinkt. Du kannst ein neues Repository in __provider__ erstellen:",
|
||||
"project_notifications": "Projektbenachrichtigungen",
|
||||
"project_notifications_muted_description": "Du erhältst keine Benachrichtigungen, da du alle Projektbenachrichtigungen deaktiviert hast.",
|
||||
"project_ownership_transfer_confirmation_1": "Möchtest du <0>__user__</0> wirklich zum Eigentümer von <1>__project__</1> machen?",
|
||||
@@ -1345,6 +1383,7 @@
|
||||
"project_url": "Betroffene Projekt-URL",
|
||||
"projects": "Projekte",
|
||||
"projects_list": "Projektliste",
|
||||
"provider_rate_limited": "Der Referenzanbieter begrenzt derzeit die Anzahl der Anfragen. Bitte versuche es später erneut.",
|
||||
"pt": "Portugiesisch",
|
||||
"public": "Öffentlich",
|
||||
"publish": "Veröffentlichen",
|
||||
@@ -1366,7 +1405,7 @@
|
||||
"reauthorize_github_account": "Autorisiere dein GitHub-Konto erneut",
|
||||
"recaptcha_conditions": "Diese Website ist durch reCAPTCHA geschützt.",
|
||||
"recent": "Kürzlich",
|
||||
"recent_commits_in_github": "Neueste Commits auf GitHub",
|
||||
"recent_commits_in_github": "Neueste Commits auf __provider__",
|
||||
"recompile": "Aktualisieren",
|
||||
"recompile_from_scratch": "Von Grund auf neu kompilieren",
|
||||
"recompile_pdf": "PDF erneut kompilieren",
|
||||
@@ -1382,10 +1421,14 @@
|
||||
"redirecting": "Weiterleitung",
|
||||
"reduce_costs_group_licenses": "Mit unseren ermäßigten Gruppenlizenzen kannst du den Papierkram reduzieren und die Kosten senken.",
|
||||
"reduces_visual_distraction_by_keeping_the_cursor_solid": "Reduziert visuelle Ablenkung durch einen dauerhaft sichtbaren Cursor",
|
||||
"reference": "Referenz",
|
||||
"reference_error_relink_hint": "Wenn dieser Fehler weiterhin auftritt, versuche dein Konto hier neu zu verlinken:",
|
||||
"reference_managers": "Referenzmanager",
|
||||
"reference_picker_hide_hint": "Nicht mehr anzeigen",
|
||||
"reference_picker_shortcut_hint": "Drücke __shortcut__, um Literaturstellen zu suchen.",
|
||||
"reference_search": "Erweiterte Referenzsuche",
|
||||
"reference_sync": "Referenzmanager synchronisieren",
|
||||
"references": "Referenzen",
|
||||
"refresh": "Aktualisieren",
|
||||
"refresh_page_after_linking_dropbox": "Bitte aktualisiere diese Seite, nachdem du dein Konto mit Dropbox verknüpft hast.",
|
||||
"refresh_page_after_starting_free_trial": "Bitte aktualisiere diese Seite, nachdem du deinen kostenlosen Test gestartet hast.",
|
||||
@@ -1428,6 +1471,8 @@
|
||||
"request_sent_thank_you": "Anforderung gesendet, danke.",
|
||||
"requesting_password_reset": "Zurücksetzen des Passworts anfordern",
|
||||
"required": "Erforderlich",
|
||||
"required_field_missing": "Pflichtfeld fehlt",
|
||||
"required_fields_missing": "Pflichtfelder fehlen",
|
||||
"resend": "Sende erneut",
|
||||
"resend_activation_email": "Aktivierungs-E-Mail erneut senden",
|
||||
"resend_confirmation_code": "Bestätigungscode erneut senden",
|
||||
@@ -1484,18 +1529,23 @@
|
||||
"security": "Sicherheit",
|
||||
"see_all_plans": "Alle Abonnements anzeigen",
|
||||
"see_your_current_location_in_the_project": "Zeige deinen aktuellen Standort im Projekt an",
|
||||
"select": "Auswählen",
|
||||
"select_a_file": "Datei auswählen",
|
||||
"select_a_new_owner_for_projects": "Neuen Besitzer für die Projekte dieses Benutzers auswählen",
|
||||
"select_a_project": "Projekt auswählen",
|
||||
"select_all": "Alle auswählen",
|
||||
"select_all_entries": "Alle Einträge auswählen",
|
||||
"select_all_projects": "Alle Projekte auswählen",
|
||||
"select_all_users": "Alle Benutzer auswählen",
|
||||
"select_an_output_file": "Ausgabedatei auswählen",
|
||||
"select_color": "Farbe __name__ auswählen",
|
||||
"select_entry": "Eintrag auswählen",
|
||||
"select_from_output_files": "aus Ausgabedateien auswählen",
|
||||
"select_from_source_files": "aus Quelldateien auswählen",
|
||||
"select_github_repository": "Wähle ein GitHub-Repository, das du in __appName__ importieren möchtest.",
|
||||
"select_github_repository": "Wähle ein __provider__-Repository, das du in __appName__ importieren möchtest.",
|
||||
"select_project": "__project__ auswählen",
|
||||
"select_projects": "Projekte auswählen",
|
||||
"select_reference": "Wähle mindestens eine Referenz aus",
|
||||
"select_tag": "Stichwort __tagName__ auswählen",
|
||||
"select_user": "Benutzer auswählen",
|
||||
"select_users": "Benutzer auswählen",
|
||||
@@ -1610,9 +1660,10 @@
|
||||
"symbol_palette": "Symbolpalette",
|
||||
"sync": "Sync",
|
||||
"sync_dropbox_github": "Mit Dropbox und GitHub synchronisieren",
|
||||
"sync_project_to_github_explanation": "Alle Änderungen die du in __appName__ vornimmst werden in GitHub festgelegt und mit allen Updates in GitHub zusammengeführt.",
|
||||
"sync_project_to_github_explanation": "Alle Änderungen die du in __appName__ vornimmst werden in __provider__ festgelegt und mit allen Updates in __provider__ zusammengeführt.",
|
||||
"sync_to_dropbox": "Synchronisierung mit Dropbox",
|
||||
"sync_with_github": "Mit GitHub synchronisieren",
|
||||
"sync_with_a_github_repository": "Mit einem __provider__-Repository synchronisieren.",
|
||||
"sync_with_github": "Mit __provider__ synchronisieren",
|
||||
"synctex_failed": "Die entsprechende Quelldatei konnte nicht gefunden werden",
|
||||
"syntax_validation": "Syntaxüberprüfung",
|
||||
"tab_connecting": "Verbindung mit dem Editor wird hergestellt",
|
||||
@@ -1696,7 +1747,9 @@
|
||||
"toolbar_selected_projects_remove": "Ausgewählte Projekte entfernen",
|
||||
"toolbar_selected_projects_restore": "Ausgewählte Projekte wiederherstellen",
|
||||
"tools": "Werkzeuge",
|
||||
"tooltip_hide_panel": "Klicken, um den Bereich auszublenden",
|
||||
"tooltip_hide_pdf": "Klicken, um das PDF auszublenden",
|
||||
"tooltip_show_panel": "Klicken, um den Bereich einzublenden",
|
||||
"tooltip_show_pdf": "Klicken, um das PDF anzuzeigen",
|
||||
"total": "Insgesamt",
|
||||
"total_per_month": "Insgesamt pro Monat",
|
||||
@@ -1749,12 +1802,13 @@
|
||||
"unlink_commons_sso_troubleshoot": "Das Entfernen und erneute Hinzufügen deiner institutionellen Anmeldung kann häufig Anmeldeprobleme beheben.",
|
||||
"unlink_dropbox_folder": "Verknüpfung zum Dropbox-Konto aufheben",
|
||||
"unlink_dropbox_warning": "Alle Projekte, die du mit Dropbox synchronisiert hast, werden getrennt und nicht mehr mit Dropbox synchronisiert. Möchtest du die Verknüpfung deines Dropbox-Kontos wirklich aufheben?",
|
||||
"unlink_github_repository": "Verknüpfung zum GitHub-Repository aufheben",
|
||||
"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_github_repository": "Verknüpfung zum __provider__-Repository aufheben",
|
||||
"unlink_github_warning": "Bei allen Projekten, die mit __provider__ synchronisiert sind, wird die Verlinkung entfernt und nicht länger mit __provider__ synchronisiert. Bist du sicher, dass du die Verbindung zu deinem __provider__-Nutzerkonto lösen möchtest?",
|
||||
"unlink_provider_account_title": "Verknüpfung mit __provider__-Konto aufheben",
|
||||
"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 __provider__ entfernen",
|
||||
"unlink_sso": "SSO-Verknüpfung aufheben",
|
||||
"unlink_the_project_from_the_current_github_repo": "Trenne das Projekt vom aktuellen __provider__-Repository und verbinde es mit einem Repository, das dir gehört.",
|
||||
"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",
|
||||
"unpause_subscription": "Abonnement fortsetzen",
|
||||
@@ -1909,6 +1963,7 @@
|
||||
"zip_contents_too_large": "ZIP-Inhalt zu groß",
|
||||
"zotero": "Zotero",
|
||||
"zotero_account_linked_successfully": "Zotero-Konto erfolgreich verknüpft.",
|
||||
"zotero_could_not_load_collections": "Zotero-Sammlungen konnten nicht geladen werden",
|
||||
"zotero_groups_loading_error": "Beim Laden von Gruppen von Zotero ist ein Fehler aufgetreten",
|
||||
"zotero_groups_relink": "Beim Zugriff auf die Zotero-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.",
|
||||
"zotero_integration": "Zotero-Integration",
|
||||
@@ -1917,5 +1972,100 @@
|
||||
"zotero_reference_loading_error_expired": "Zotero-Token abgelaufen, bitte verknüpfe dein Konto neu",
|
||||
"zotero_reference_loading_error_forbidden": "Referenzen konnten nicht von Zotero geladen werden. Bitte verlinke dein Zotero-Konto erneut und versuche es nochmal",
|
||||
"zotero_reference_loading_error_not_linked": "Aktualisierung fehlgeschlagen: Das zum Importieren der Datei verwendete Konto ist nicht mehr mit Zotero verknüpft oder wurde entfernt.",
|
||||
"zotero_sync_description": "Mit der Zotero-Integration kannst du deine Referenzen von Zotero in deine __appName__ Projekte importieren."
|
||||
"zotero_sync_description": "Mit der Zotero-Integration kannst du deine Referenzen von Zotero in deine __appName__ Projekte importieren.",
|
||||
"add_field": "Feld hinzufügen",
|
||||
"add_name": "__name__ hinzufügen",
|
||||
"add_optional_field": "Optionales Feld hinzufügen",
|
||||
"author_name": "Name des Autors",
|
||||
"badly_formatted_field": "Falsch formatiertes Feld",
|
||||
"badly_formatted_fields": "Falsch formatierte Felder",
|
||||
"bib_field_group_books_and_volumes": "Bücher und Bände",
|
||||
"bib_field_group_contributors": "Mitwirkende",
|
||||
"bib_field_group_digital_and_online": "Digitale und Online-Quellen",
|
||||
"bib_field_group_events_and_conferences": "Veranstaltungen und Konferenzen",
|
||||
"bib_field_group_language_and_origin": "Sprache und Herkunft",
|
||||
"bib_field_group_periodicals_and_journals": "Zeitschriften und Journale",
|
||||
"bib_field_group_publication_details": "Publikationsangaben",
|
||||
"bib_field_helper_doi_identifier": "Nur die Kennung, nicht die vollständige URL, z. B. 10.1000/xyz123",
|
||||
"bib_field_helper_page_range": "Seitenbereich",
|
||||
"bib_field_helper_preprint_archive_identifier": "Archivkennung des Preprints, z. B. math/0307200v3",
|
||||
"bib_syntax_duplicate_field": "doppeltes Feld '__field__'",
|
||||
"bib_syntax_expected_comma_after_citation_key": "Komma ',' nach dem Zitationsschlüssel erwartet; stattdessen Feld '__field__' gefunden",
|
||||
"bib_syntax_expected_comma_before_field": "Komma ',' vor Feld '__field__' erwartet",
|
||||
"bib_syntax_expected_equals_after_field_name": "Gleichheitszeichen '=' nach Feldname '__field__' erwartet",
|
||||
"bib_syntax_invalid_entry_header": "Ungültiger oder unvollständiger BibTeX-Eintragskopf",
|
||||
"bib_syntax_invalid_field": "ungültige BibTeX-Feldsyntax",
|
||||
"bib_syntax_line": "Zeile __line__: __problem__",
|
||||
"bib_syntax_missing_closing_brace_opened_on_line": "schließende Klammer '}' für die in Zeile __line__ geöffnete '{' fehlt",
|
||||
"bib_syntax_missing_closing_quote": "schließendes Anführungszeichen \" fehlt (geöffnet in Zeile __line__)",
|
||||
"bib_syntax_unexpected_comma": "Unerwartetes Komma im BibTeX-Eintrag",
|
||||
"bibliography_editor_load_error": "Beim Laden des Literaturverzeichnis-Editors ist ein Fehler aufgetreten.",
|
||||
"bibtex_doi_description": "BibTeX, DOI",
|
||||
"citation_key": "Zitationsschlüssel",
|
||||
"citation_key_contains_invalid_characters": "Der Zitationsschlüssel enthält ungültige Zeichen",
|
||||
"collapse_optional": "Optionale Felder einklappen",
|
||||
"confirm_delete_entry": "Möchtest du diesen Eintrag wirklich löschen?",
|
||||
"confirm_delete_references_count": "__count__ Referenzen löschen?",
|
||||
"correct_syntax_error_in_code_mode": "Behebe diesen Fehler im Code-Modus. Die visuelle Bearbeitung dieses Eintrags ist deaktiviert.",
|
||||
"correct_syntax_errors_in_code_mode": "Behebe diese Fehler im Code-Modus. Die visuelle Bearbeitung dieses Eintrags ist deaktiviert.",
|
||||
"could_not_import_this_reference": "Diese Referenz konnte nicht importiert werden",
|
||||
"could_not_parse_this_reference": "Diese Referenz konnte nicht eingelesen werden.",
|
||||
"could_not_repair_this_reference": "Diese Referenz konnte nicht repariert werden.",
|
||||
"could_not_retrieve_doi_metadata": "Die Metadaten für diesen DOI konnten nicht abgerufen werden. Prüfe den DOI und versuche es erneut.",
|
||||
"could_not_save_bib_file": "Die .bib-Datei konnte nicht gespeichert werden. Etwas ist schiefgelaufen.",
|
||||
"curly_braces_must_be_balanced": "Geschweifte Klammern müssen paarweise geschlossen sein",
|
||||
"date_format_hint": "Datumsformat: JJJJ, JJJJ-MM oder JJJJ-MM-TT; optional mit Uhrzeit und Zeitzone; Zeiträume möglich (z. B. 2026, 2026-10, 2026-10-17, 2026-10-17T14:30Z, 2026-10-17/2026-10-20)",
|
||||
"delete_entries": "Einträge löschen",
|
||||
"delete_entry": "Eintrag löschen",
|
||||
"doi_format_hint": "DOI-Format: 10.xxxx/xxxxx (z. B. 10.1234/example)",
|
||||
"either_a_or_b_is_required": "Entweder __a__ oder __b__ ist erforderlich",
|
||||
"enter_field_name": "Feldnamen eingeben",
|
||||
"enter_orcid_id": "ORCID iD eingeben",
|
||||
"enter_type": "Typ eingeben",
|
||||
"entry_has_syntax_error": "Dieser Eintrag enthält einen Syntaxfehler",
|
||||
"entry_has_syntax_errors": "Dieser Eintrag enthält Syntaxfehler",
|
||||
"entry_has_warnings": "Eintrag enthält Warnungen",
|
||||
"entry_type": "Eintragstyp",
|
||||
"expand_optional": "Optionale Felder ausklappen",
|
||||
"failed_to_fetch_doi": "DOI konnte nicht abgerufen werden",
|
||||
"failed_to_load_works": "Publikationen konnten nicht geladen werden",
|
||||
"field_is_required": "__a__ ist erforderlich",
|
||||
"generate": "Generieren",
|
||||
"import_failed": "Import fehlgeschlagen",
|
||||
"import_from_provider": "Von __provider__ importieren",
|
||||
"invalid_doi_format": "Ungültiges DOI-Format (erwartet: 10.xxxx/…)",
|
||||
"invalid_response_from_server": "Ungültige Antwort vom Server",
|
||||
"load_works": "Publikationen laden",
|
||||
"missing_field_for_entry_type": "Fehlendes Feld für __entryType__",
|
||||
"missing_fields_for_entry_type": "Fehlende Felder für __entryType__",
|
||||
"move_down": "Nach unten verschieben",
|
||||
"move_up": "Nach oben verschieben",
|
||||
"no_authors_found": "Keine Autoren gefunden",
|
||||
"no_bibliography_entries_yet_add_one": "Noch keine Literaturverzeichniseinträge. Klicke auf „+ Hinzufügen“, um einen zu erstellen.",
|
||||
"no_entries_match_your_search": "Keine Einträge entsprechen deiner Suche.",
|
||||
"no_name": "(kein Name)",
|
||||
"no_works_found_for_this_author": "Keine Publikationen für diesen Autor gefunden",
|
||||
"orcid_crossref_rate_limited": "Anfragelimit erreicht. Bitte versuche es später erneut.",
|
||||
"orcid_enriched_reference": "__count__ Referenz mit Crossref-Metadaten ergänzt",
|
||||
"orcid_enriched_references": "__count__ Referenzen mit Crossref-Metadaten ergänzt",
|
||||
"orcid_enrichment_failed_reference": "Crossref-Anreicherung für __count__ Referenz fehlgeschlagen:",
|
||||
"orcid_enrichment_failed_references": "Crossref-Anreicherung für __count__ Referenzen fehlgeschlagen:",
|
||||
"orcid_id": "ORCID iD",
|
||||
"orcid_imported_reference": "__count__ Referenz erfolgreich importiert",
|
||||
"orcid_imported_references": "__count__ Referenzen erfolgreich importiert",
|
||||
"remove_author": "Autor entfernen",
|
||||
"remove_field": "Feld __a__ entfernen",
|
||||
"resolving": "Wird aufgelöst …",
|
||||
"search_bib_file": "__fileName__ durchsuchen",
|
||||
"search_by_name": "Nach Namen suchen",
|
||||
"search_failed": "Suche fehlgeschlagen",
|
||||
"search_orcid_by_name_or_id": "ORCID nach Namen oder iD durchsuchen",
|
||||
"select_a_type_to_see_the_required_fields": "Wähle einen Typ, um die Pflichtfelder anzuzeigen",
|
||||
"separate_multiple_names_with_and": "Mehrere Namen mit „and“ trennen",
|
||||
"some_works_could_not_be_fetched": "Einige Referenzen konnten nicht abgerufen werden",
|
||||
"some_works_could_not_be_imported": "Einige Publikationen konnten nicht importiert werden",
|
||||
"untitled": "Ohne Titel",
|
||||
"url_format_hint": "URL-Format: example.com oder https://example.com",
|
||||
"works_by_author": "Publikationen von __author__",
|
||||
"year_format_hint": "Jahresformat: JJJJ (z. B. 2026)"
|
||||
}
|
||||
|
||||
+143
-24
@@ -194,6 +194,7 @@
|
||||
"alignment": "Alignment",
|
||||
"all": "All",
|
||||
"all_borders": "All borders",
|
||||
"all_collections": "All collections",
|
||||
"all_events": "All events",
|
||||
"all_features_in_group_standard_plus": "All features in Standard group, plus:",
|
||||
"all_logs": "All logs",
|
||||
@@ -406,7 +407,7 @@
|
||||
"check_your_inbox": "Check your inbox",
|
||||
"checking": "Checking",
|
||||
"checking_dropbox_status": "Checking Dropbox status",
|
||||
"checking_project_github_status": "Checking project status in GitHub",
|
||||
"checking_project_github_status": "Checking project status in __provider__",
|
||||
"checking_your_student_status": "We’re checking your student status. We’ll email you if your document upload needs review.",
|
||||
"choose_a_custom_color": "Choose a custom color",
|
||||
"choose_a_new_password": "Choose a new password",
|
||||
@@ -460,6 +461,7 @@
|
||||
"collabs_per_proj_plus": "__count__ collaborator per project, plus:",
|
||||
"collabs_per_proj_plus_plural": "__count__ collaborators per project, plus:",
|
||||
"collapse": "Collapse",
|
||||
"collection": "Collection",
|
||||
"column_width": "Column width",
|
||||
"column_width_is_custom_click_to_resize": "Column width is custom. Click to resize",
|
||||
"column_width_is_x_click_to_resize": "Column width is __width__. Click to resize",
|
||||
@@ -574,7 +576,7 @@
|
||||
"create_new_subscription": "Create new subscription",
|
||||
"create_new_tag": "Create new tag",
|
||||
"create_password": "Create password",
|
||||
"create_project_in_github": "Create a GitHub repository",
|
||||
"create_project_in_github": "Create a __provider__ repository",
|
||||
"create_your_account": "Create your account",
|
||||
"created": "Created",
|
||||
"created_at": "Created at",
|
||||
@@ -923,7 +925,7 @@
|
||||
"export_as_html": "Export as HTML (.html)",
|
||||
"export_as_markdown": "Export as Markdown (.md)",
|
||||
"export_csv": "Export CSV",
|
||||
"export_project_to_github": "Export Project to GitHub",
|
||||
"export_project_to_github": "Export Project to __provider__",
|
||||
"failed": "Failed",
|
||||
"failed_to_consent_to_workbench_terms": "Failed to consent to workbench terms. Please try again later.",
|
||||
"failed_to_publish_as_a_template": "Failed to publish as a template.",
|
||||
@@ -1108,20 +1110,36 @@
|
||||
"github_is_premium": "GitHub Sync is a premium feature",
|
||||
"github_large_files_error": "Merge failed: your GitHub repository contains files over the 50mb file size limit ",
|
||||
"github_manual_merge_user_prompt": "Please manually merge the <0>__sharelatex_branch__</0> branch into the default branch in git. Click below to continue, after you have manually merged.",
|
||||
"github_merge_failed_error": "Your changes in __appName__ and GitHub could not be automatically merged.",
|
||||
"github_merge_failed_error": "Your changes in __appName__ and __provider__ could not be automatically merged.",
|
||||
"github_no_master_branch_error": "This repository cannot be imported as it is missing a default branch. Please make sure the project has a default branch",
|
||||
"github_private_description": "You choose who can see and commit to this repository.",
|
||||
"github_project_too_large_error": "This project is too large to sync with GitHub. There’s a limit on the total size of a project’s editable text files. Try removing or shrinking large text files, then sync again.",
|
||||
"github_public_description": "Anyone can see this repository. You choose who can commit.",
|
||||
"github_repo": "GitHub repo",
|
||||
"github_repository_diverged": "The default branch of the linked repository has been force-pushed. The list of recent commits below may be incomplete due to rewritten history since the last sync.",
|
||||
"github_successfully_linked_description": "Thanks, we’ve successfully linked your GitHub account to __appName__. You can now export your __appName__ projects to GitHub, or import projects from your GitHub repositories.",
|
||||
"github_successfully_linked_description": "Thanks, we’ve successfully linked your __provider__ account to __appName__. You can now export your __appName__ projects to __provider__, or import projects from your __provider__ repositories.",
|
||||
"github_symlink_error": "Your GitHub repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.",
|
||||
"github_sync_description": "With GitHub Sync you can link your __appName__ projects to GitHub repositories, create new commits from __appName__, and merge commits from GitHub.",
|
||||
"github_sync_error": "Sorry, there was a problem checking our GitHub service. Please try again in a few moments.",
|
||||
"github_sync_branch_name": "Branch name",
|
||||
"github_sync_description": "With __provider__ Sync you can link your __appName__ projects to __provider__ repositories, create new commits from __appName__, and merge commits from __provider__.",
|
||||
"github_sync_error": "Sorry, there was a problem checking our __provider__ service. Please try again in a few moments.",
|
||||
"github_sync_file_limits_link": "Learn about GitHub sync file limits",
|
||||
"github_sync_import_link_failed": "Your project was created. Linking to the repository failed. Retry linking this project or open it in Overleaf.",
|
||||
"github_sync_import_pending": "A Git import is unfinished. Choose Sync to retry. If recovery keeps failing, the project owner can choose Unlink to stop syncing with this repository.",
|
||||
"github_sync_interrupted": "Sync was interrupted. Some changes may already have been applied. Choose Sync to try again.",
|
||||
"github_sync_invalid_branch_name": "Invalid branch name.",
|
||||
"github_sync_invalid_repository_name": "Invalid repository name.",
|
||||
"github_sync_linked_branch_moved": "The linked branch moved. Sync again to include its changes.",
|
||||
"github_sync_manual_resolution_allowed": "You can also resolve the changes manually in <0>__branch__</0>. Continue will check Git history and ask for confirmation if the merge cannot be verified.",
|
||||
"github_sync_merge_into_linked_branch": "Merge <0>__source__</0> into <1>__target__</1> on __provider__ and resolve the conflicting files.",
|
||||
"github_sync_merge_recovery_branch": "Merge __branch__ into __targetBranch__, then sync again to finish restoring the repository changes.",
|
||||
"github_sync_recovery_review_history": "Someone edited the project while Git Sync was importing files. The import may have overwritten recent edits. Check the affected files in Overleaf and restore any missing edits from project history. Choose Continue when the project is ready to merge with branch <0>__branch__</0>.",
|
||||
"github_sync_ref_not_found_description": "We couldn’t check GitHub for changes to this project. Please try again in a few moments. If this keeps happening, check status.overleaf.com and githubstatus.com for reported incidents. If neither reports a problem, you can <0>unlink this project</0> from its GitHub repository and set up sync again.",
|
||||
"github_sync_repository_not_found_description": "The linked repository has either been removed, or you no longer have access to it. You can set up sync with a new repository by cloning the project and using the ‘GitHub’ menu item. You can also unlink the repository from this project.",
|
||||
"github_sync_repository_not_found_description": "The linked repository has either been removed, or you no longer have access to it. You can set up sync with a new repository by cloning the project and using the ‘__provider__’ menu item. You can also unlink the repository from this project.",
|
||||
"github_sync_resolution_effect": "Continuing will import <0>__branch__</0> into Overleaf. Overleaf edits made since the conflict appeared will be merged separately.",
|
||||
"github_sync_resolution_reviewed_commit": "Last commit in <0>__branch__</0>: <1>__head__</1>",
|
||||
"github_sync_resolution_unchanged": "The linked branch <0>__branch__</0> has not moved since this conflict appeared.",
|
||||
"github_sync_resolution_unverified": "Git history does not show that the Overleaf changes were merged into <0>__branch__</0>. Verify that this branch contains the resolution you want.",
|
||||
"github_sync_retry_link": "Retry linking",
|
||||
"github_sync_unlink_error": "Sorry, we weren’t able to unlink your GitHub account. Please try again in a few moments.",
|
||||
"github_sync_upstream_error": "We couldn’t reach GitHub. Please try again in a few moments. If this keeps happening, check status.overleaf.com and githubstatus.com for reported incidents.",
|
||||
"github_timeout_error": "Syncing your Overleaf project with GitHub has timed out. This may be due to the overall size of your project, or the number of files/changes to sync, being too large.",
|
||||
@@ -1352,7 +1370,7 @@
|
||||
"import_a_bibtex_file_from_your_provider_account": "Import a BibTeX file from your __provider__ account",
|
||||
"import_document_description": "Content will be imported from the selected document. Formatting may not be reproduced exactly.",
|
||||
"import_existing_projects_from_github": "Import existing projects from GitHub",
|
||||
"import_from_github": "Import from GitHub",
|
||||
"import_from_github": "Import from __provider__",
|
||||
"import_idp_metadata": "Import IdP metadata",
|
||||
"import_markdown_file": "Import Markdown file",
|
||||
"import_to_sharelatex": "Import to __appName__",
|
||||
@@ -1366,7 +1384,7 @@
|
||||
"imported_from_zotero_at_date": "Imported from Zotero at __formattedDate__ __relativeDate__",
|
||||
"imported_from_zotero_at_date_by": "Imported from Zotero at __formattedDate__ __relativeDate__ by __importedByName__",
|
||||
"importing": "Importing",
|
||||
"importing_and_merging_changes_in_github": "Importing and merging changes in GitHub",
|
||||
"importing_and_merging_changes_in_github": "Importing and merging changes in __provider__",
|
||||
"in_editor_chat": "In-editor chat",
|
||||
"in_editor_chat_managed_by_overleaf_support": "In-editor chat is managed by Overleaf support.",
|
||||
"in_editor_chat_section_description": "Lets collaborators leave and reply to comments directly within a project.",
|
||||
@@ -1615,8 +1633,8 @@
|
||||
"link_sharing": "Link sharing",
|
||||
"link_sharing_is_off_short": "Link sharing is off",
|
||||
"link_sharing_is_on": "Link sharing is on",
|
||||
"link_to_github": "Link to your GitHub account",
|
||||
"link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
|
||||
"link_to_github": "Link to your __provider__ account",
|
||||
"link_to_github_description": "You need to authorise __appName__ to access your __provider__ account to allow us to sync your projects.",
|
||||
"link_to_mendeley": "Link to Mendeley",
|
||||
"link_to_readcube": "Link to ReadCube",
|
||||
"link_to_zotero": "Link to Zotero",
|
||||
@@ -1629,7 +1647,7 @@
|
||||
"load_domains_failed": "There was an error loading the domains. Please reload the page to try again. If the problem continues please <0>contact us</0>.",
|
||||
"loading": "Loading",
|
||||
"loading_content": "Creating Project",
|
||||
"loading_github_repositories": "Loading your GitHub repositories",
|
||||
"loading_github_repositories": "Loading your __provider__ repositories",
|
||||
"loading_prices": "loading prices",
|
||||
"loading_python_runtime": "Loading Python runtime...",
|
||||
"loading_recent_github_commits": "Loading recent commits",
|
||||
@@ -1785,6 +1803,7 @@
|
||||
"must_be_email_address": "Must be an email address.",
|
||||
"must_be_purchased_online": "Must be purchased online",
|
||||
"my_library": "My Library",
|
||||
"my_publications": "My Publications",
|
||||
"n_items": "__count__ item",
|
||||
"n_items_plural": "__count__ items",
|
||||
"n_more_collaborators": "__count__ more collaborator",
|
||||
@@ -1857,7 +1876,7 @@
|
||||
"no_matches": "No matches",
|
||||
"no_members": "No members",
|
||||
"no_messages_yet": "No messages yet",
|
||||
"no_new_commits_in_github": "No new commits in GitHub since last merge.",
|
||||
"no_new_commits_in_github": "No new commits in __provider__ since last merge.",
|
||||
"no_one_has_commented_or_left_any_suggestions_yet": "No one has commented or left any suggestions yet.",
|
||||
"no_one_invited_yet": "No one invited yet",
|
||||
"no_one_sharing_link_is_disabled": "No one—sharing link is disabled",
|
||||
@@ -2192,7 +2211,7 @@
|
||||
"project_last_published_at": "Your project was last published at",
|
||||
"project_linked_to": "This project is linked to",
|
||||
"project_name": "Project name",
|
||||
"project_not_linked_to_github": "This project is not linked to a GitHub repository. You can create a repository for it in GitHub:",
|
||||
"project_not_linked_to_github": "This project is not linked to a __provider__ repository. You can create a repository for it in __provider__:",
|
||||
"project_notifications": "Project notifications",
|
||||
"project_notifications_muted_description": "You are not receiving any notifications, as you have disabled all project notifications.",
|
||||
"project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__</0> the owner of <1>__project__</1>?",
|
||||
@@ -2275,7 +2294,7 @@
|
||||
"reauthorize_github_account": "Reauthorize your GitHub Account",
|
||||
"recaptcha_conditions": "The site is protected by reCAPTCHA.",
|
||||
"recent": "Recent",
|
||||
"recent_commits_in_github": "Recent commits in GitHub",
|
||||
"recent_commits_in_github": "Recent commits in __provider__",
|
||||
"recommended": "Recommended",
|
||||
"recompile": "Recompile",
|
||||
"recompile_from_scratch": "Recompile from scratch",
|
||||
@@ -2310,6 +2329,8 @@
|
||||
"reference_manager_searched_groups": "__provider__ search groups",
|
||||
"reference_manager_unlinked": "Reference manager unlinked",
|
||||
"reference_managers": "Reference managers",
|
||||
"reference_picker_hide_hint": "Don’t show again",
|
||||
"reference_picker_shortcut_hint": "Press __shortcut__ to find references.",
|
||||
"reference_search": "Advanced reference search",
|
||||
"reference_search_info_new": "Find your references easily—search by author, title, year, or journal.",
|
||||
"reference_search_results": "Reference search results",
|
||||
@@ -2399,6 +2420,7 @@
|
||||
"request_sent_thank_you": "Message sent! Our team will review it and reply by email.",
|
||||
"requesting_password_reset": "Requesting password reset",
|
||||
"required": "Required",
|
||||
"required_field_missing": "Required field missing",
|
||||
"required_fields_missing": "Required fields missing",
|
||||
"resend": "Resend",
|
||||
"resend_activation_email": "Resend activation email",
|
||||
@@ -2556,7 +2578,7 @@
|
||||
"select_from_project_files": "select from project files",
|
||||
"select_from_source_files": "select from source files",
|
||||
"select_from_your_computer": "select from your computer",
|
||||
"select_github_repository": "Select a GitHub repository to import into __appName__.",
|
||||
"select_github_repository": "Select a __provider__ repository to import into __appName__.",
|
||||
"select_image_from_project_files": "Select image from project files",
|
||||
"select_project": "Select __project__",
|
||||
"select_projects": "Select Projects",
|
||||
@@ -2847,13 +2869,13 @@
|
||||
"sync_dropbox_github": "Sync with Dropbox and GitHub",
|
||||
"sync_failed": "Sync failed. Please try again.",
|
||||
"sync_now": "Sync now",
|
||||
"sync_project_to_github_explanation": "Any changes you have made in __appName__ will be committed and merged with any updates in GitHub.",
|
||||
"sync_project_to_github_explanation": "Any changes you have made in __appName__ will be committed and merged with any updates in __provider__.",
|
||||
"sync_settings": "Sync settings",
|
||||
"sync_to_dropbox": "Sync to Dropbox",
|
||||
"sync_to_github": "Sync to GitHub",
|
||||
"sync_with_a_github_repository": "Sync with a GitHub repository.",
|
||||
"sync_with_a_github_repository": "Sync with a __provider__ repository.",
|
||||
"sync_with_dropbox": "Sync with Dropbox",
|
||||
"sync_with_github": "Sync with GitHub",
|
||||
"sync_with_github": "Sync with __provider__",
|
||||
"sync_with_zotero": "Sync with Zotero",
|
||||
"syncing": "Syncing",
|
||||
"synctex_error_recompile_and_try_again": "That didn’t work. Recompile and try again.",
|
||||
@@ -3161,15 +3183,15 @@
|
||||
"unlink_dropbox_folder": "Unlink Dropbox Account",
|
||||
"unlink_dropbox_warning": "Any projects that you have synced with Dropbox will be disconnected and no longer kept in sync with Dropbox. Are you sure you want to unlink your Dropbox account?",
|
||||
"unlink_from_sso": "Unlink from SSO",
|
||||
"unlink_github_repository": "Unlink GitHub repository",
|
||||
"unlink_github_warning": "Syncing will pause until you relink your GitHub account. Your projects’ GitHub connections won’t be lost, and syncing will resume automatically once you relink. Are you sure you want to unlink your GitHub account?",
|
||||
"unlink_github_repository": "Unlink __provider__ repository",
|
||||
"unlink_github_warning": "Syncing will pause until you relink your __provider__ account. Your projects’ __provider__ connections won’t be lost, and syncing will resume automatically once you relink. Are you sure you want to unlink your __provider__ account?",
|
||||
"unlink_linked_accounts": "Unlink any linked accounts (such as ORCID ID, IEEE). <0>Remove them 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_warning": "Warning: When you unlink your account from __provider__ you will not be able to sign in using __provider__ anymore.",
|
||||
"unlink_reference": "Unlink __provider__ References Provider",
|
||||
"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 __provider__ 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_users": "Unlink users",
|
||||
"unlink_warning_reference": "Warning: When you unlink your account from __provider__ you will not be able to import references into your projects.",
|
||||
@@ -3476,6 +3498,7 @@
|
||||
"your_git_access_info_bullet_3": "You can generate a token using the <0>Generate token</0> button.",
|
||||
"your_git_access_info_bullet_4": "You won’t be able to view the full token after the first time you generate it. Please copy it and keep it safe",
|
||||
"your_git_access_info_bullet_5": "Previously generated tokens will be shown here.",
|
||||
"your_git_access_info_review_comments_api": "The same tokens also authenticate the review comments API of your projects, documented at /api/v1.",
|
||||
"your_git_access_tokens": "Your Git authentication tokens",
|
||||
"your_limit_will_reset_in_time_or_speak_to_admin": "Your limit will reset in __time__. Or speak to your group admin about upgrading.",
|
||||
"your_message_to_collaborators": "Send a message to your collaborators",
|
||||
@@ -3537,6 +3560,7 @@
|
||||
"zoom_to": "Zoom to",
|
||||
"zotero": "Zotero",
|
||||
"zotero_account_linked_successfully": "Zotero account linked successfully",
|
||||
"zotero_could_not_load_collections": "Could not load Zotero collections",
|
||||
"zotero_dynamic_sync_description": "With the Zotero integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Zotero library directly from __appName__.",
|
||||
"zotero_groups_loading_error": "There was an error loading groups from Zotero",
|
||||
"zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.",
|
||||
@@ -3557,5 +3581,100 @@
|
||||
"zotero_sync_success_plural": "Synced __count__ references from Zotero.",
|
||||
"zotero_sync_up_to_date": "Your Zotero references are already up to date.",
|
||||
"zotero_upgrade_prompt_content": "Link your Zotero account to search and add your references from Zotero directly in your project—they’ll automatically be added to your .bib file. Or import them as a file into your __appName__ projects.",
|
||||
"zotero_upgrade_prompt_title": "Cite from Zotero"
|
||||
"zotero_upgrade_prompt_title": "Cite from Zotero",
|
||||
"add_field": "Add field",
|
||||
"add_name": "Add __name__",
|
||||
"add_optional_field": "Add optional field",
|
||||
"author_name": "Author name",
|
||||
"badly_formatted_field": "Badly formatted field",
|
||||
"badly_formatted_fields": "Badly formatted fields",
|
||||
"bib_field_group_books_and_volumes": "Books and volumes",
|
||||
"bib_field_group_contributors": "Contributors",
|
||||
"bib_field_group_digital_and_online": "Digital and online",
|
||||
"bib_field_group_events_and_conferences": "Events and conferences",
|
||||
"bib_field_group_language_and_origin": "Language and origin",
|
||||
"bib_field_group_periodicals_and_journals": "Periodicals and journals",
|
||||
"bib_field_group_publication_details": "Publication details",
|
||||
"bib_field_helper_doi_identifier": "The identifier only, not the full URL, e.g. 10.1000/xyz123",
|
||||
"bib_field_helper_page_range": "Page range",
|
||||
"bib_field_helper_preprint_archive_identifier": "The preprint archive identifier, e.g. math/0307200v3",
|
||||
"bib_syntax_duplicate_field": "duplicate field '__field__'",
|
||||
"bib_syntax_expected_comma_after_citation_key": "expected ',' after citation key, got field '__field__'",
|
||||
"bib_syntax_expected_comma_before_field": "expected ',' before field '__field__'",
|
||||
"bib_syntax_expected_equals_after_field_name": "expected '=' after field name '__field__'",
|
||||
"bib_syntax_invalid_entry_header": "Invalid or incomplete BibTeX entry header",
|
||||
"bib_syntax_invalid_field": "invalid BibTeX field syntax",
|
||||
"bib_syntax_line": "Line __line__, __problem__",
|
||||
"bib_syntax_missing_closing_brace_opened_on_line": "missing '}' for '{' opened on line __line__",
|
||||
"bib_syntax_missing_closing_quote": "missing '\"' (opened on line __line__)",
|
||||
"bib_syntax_unexpected_comma": "Unexpected comma in BibTeX entry",
|
||||
"bibliography_editor_load_error": "Something went wrong loading the bibliography editor.",
|
||||
"bibtex_doi_description": "BibTeX, DOI",
|
||||
"citation_key": "Citation key",
|
||||
"citation_key_contains_invalid_characters": "Citation key contains invalid characters",
|
||||
"collapse_optional": "Collapse Optional",
|
||||
"confirm_delete_entry": "Are you sure you want to delete this entry?",
|
||||
"confirm_delete_references_count": "Delete __count__ references?",
|
||||
"correct_syntax_error_in_code_mode": "Correct this error in code mode. Visual editing is disabled for this entry.",
|
||||
"correct_syntax_errors_in_code_mode": "Correct these errors in code mode. Visual editing is disabled for this entry.",
|
||||
"could_not_import_this_reference": "Could not import this reference",
|
||||
"could_not_parse_this_reference": "Could not parse this reference.",
|
||||
"could_not_repair_this_reference": "Could not repair this reference.",
|
||||
"could_not_retrieve_doi_metadata": "Could not retrieve metadata for this DOI. Check the DOI and try again.",
|
||||
"could_not_save_bib_file": "Could not save the .bib file. Something went wrong.",
|
||||
"curly_braces_must_be_balanced": "Curly braces must be balanced",
|
||||
"date_format_hint": "Date format: YYYY, YYYY-MM, or YYYY-MM-DD; optional time and time zone; ranges allowed (e.g. 2026, 2026-10, 2026-10-17, 2026-10-17T14:30Z, 2026-10-17/2026-10-20)",
|
||||
"delete_entries": "Delete entries",
|
||||
"delete_entry": "Delete entry",
|
||||
"doi_format_hint": "DOI format: 10.xxxx/xxxxx (e.g. 10.1234/example)",
|
||||
"either_a_or_b_is_required": "Either __a__ or __b__ is required",
|
||||
"enter_field_name": "Enter field name",
|
||||
"enter_orcid_id": "Enter ORCID iD",
|
||||
"enter_type": "Enter type",
|
||||
"entry_has_syntax_error": "This entry has syntax error",
|
||||
"entry_has_syntax_errors": "This entry has syntax errors",
|
||||
"entry_has_warnings": "Entry has warnings",
|
||||
"entry_type": "Entry type",
|
||||
"expand_optional": "Expand Optional",
|
||||
"failed_to_fetch_doi": "Failed to fetch DOI",
|
||||
"failed_to_load_works": "Failed to load works",
|
||||
"field_is_required": "__a__ is required",
|
||||
"generate": "Generate",
|
||||
"import_failed": "Import failed",
|
||||
"import_from_provider": "Import from __provider__",
|
||||
"invalid_doi_format": "Invalid DOI format (expected 10.xxxx/…)",
|
||||
"invalid_response_from_server": "Invalid response from server",
|
||||
"load_works": "Load works",
|
||||
"missing_field_for_entry_type": "Missing field for __entryType__",
|
||||
"missing_fields_for_entry_type": "Missing fields for __entryType__",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"no_authors_found": "No authors found",
|
||||
"no_bibliography_entries_yet_add_one": "No bibliography entries yet. Click \"+ Add\" to create one.",
|
||||
"no_entries_match_your_search": "No entries match your search.",
|
||||
"no_name": "(no name)",
|
||||
"no_works_found_for_this_author": "No works found for this author",
|
||||
"orcid_crossref_rate_limited": "Rate limit reached. Please try again later.",
|
||||
"orcid_enriched_reference": "__count__ reference enriched via Crossref",
|
||||
"orcid_enriched_references": "__count__ references enriched via Crossref",
|
||||
"orcid_enrichment_failed_reference": "Crossref enrichment failed for __count__ reference:",
|
||||
"orcid_enrichment_failed_references": "Crossref enrichment failed for __count__ references:",
|
||||
"orcid_id": "ORCID iD",
|
||||
"orcid_imported_reference": "__count__ reference imported successfully",
|
||||
"orcid_imported_references": "__count__ references imported successfully",
|
||||
"remove_author": "Remove author",
|
||||
"remove_field": "Remove __a__ field",
|
||||
"resolving": "Resolving…",
|
||||
"search_bib_file": "Search __fileName__",
|
||||
"search_by_name": "Search by name",
|
||||
"search_failed": "Search failed",
|
||||
"search_orcid_by_name_or_id": "Search ORCID by name or iD",
|
||||
"select_a_type_to_see_the_required_fields": "Select a type to see the required fields",
|
||||
"separate_multiple_names_with_and": "Separate multiple names with \"and\"",
|
||||
"some_works_could_not_be_fetched": "Some references could not be fetched",
|
||||
"some_works_could_not_be_imported": "Some works could not be imported",
|
||||
"untitled": "Untitled",
|
||||
"url_format_hint": "URL format: example.com or https://example.com",
|
||||
"works_by_author": "Works by __author__",
|
||||
"year_format_hint": "Year format: YYYY (e.g. 2026)"
|
||||
}
|
||||
|
||||
+181
-23
@@ -58,6 +58,8 @@
|
||||
"add": "Добавить",
|
||||
"add_another_token": "Добавить ещё один токен",
|
||||
"add_ons": "Расширения",
|
||||
"add_reference": "Добавить ссылку",
|
||||
"add_references": "Добавить ссылки",
|
||||
"added": "добавлены",
|
||||
"adding": "Добавление",
|
||||
"address": "Адрес",
|
||||
@@ -68,6 +70,7 @@
|
||||
"ai_assistant": "ИИ-ассистент",
|
||||
"ai_assistant_and_higher_ai_allowance": "ИИ-ассистент и более высокий лимит ИИ",
|
||||
"ai_assistant_and_max_ai_allowance": "ИИ-ассистент и максимальный лимит ИИ",
|
||||
"all_collections": "Все коллекции",
|
||||
"all_premium_features": "Все премиум-функции",
|
||||
"all_projects": "Все проекты",
|
||||
"all_templates": "Все шаблоны",
|
||||
@@ -76,6 +79,7 @@
|
||||
"and": "и",
|
||||
"annual": "Цена за год",
|
||||
"anonymous": "Аноним",
|
||||
"apply": "Применить",
|
||||
"apply_educational_discount_description_with_group_discount": "Получите суммарную скидку 40% для групп, использующих __appName__ в обучении",
|
||||
"are_you_sure_you_want_to_cancel": "Вы уверены, что хотите отменить подписку?",
|
||||
"are_you_sure_you_want_to_cancel_add_on": "Вы уверены, что хотите отменить расширение <strong>__addOnName__</strong>?",
|
||||
@@ -83,7 +87,7 @@
|
||||
"ask_proj_owner_to_export_to_github": "Попросите владельца проекта (<0>__projectOwnerEmail__</0>) экспортировать проект в Git-репозиторий и добавить вас в качестве коллаборатора.",
|
||||
"at_most_x_libraries_can_be_selected": "Можно выбрать не более __count__ библиотек",
|
||||
"audit_logs": "Журналы аудита",
|
||||
"ask_proj_owner_to_unlink_from_current_github": "Попросите владельца проекта (<0>projectOwnerEmail</0>) отключить проект от текущего Git-репозитория и создать подключение к другому репозиторию.",
|
||||
"ask_proj_owner_to_unlink_from_current_github": "Попросите владельца проекта (<0>__projectOwnerEmail__</0>) отключить проект от текущего Git-репозитория и создать подключение к другому репозиторию.",
|
||||
"auto_complete": "Автодополнение",
|
||||
"autocomplete": "Автозавершение",
|
||||
"autocomplete_references": "Автодополнение ссылок (внутри блока <code>\\cite{}</code>)",
|
||||
@@ -96,6 +100,7 @@
|
||||
"basic_compile_timeout_on_fast_servers": "Базовый таймаут компиляции на быстрых серверах",
|
||||
"beta": "Бета",
|
||||
"bibliographies": "Библиография",
|
||||
"bibtex_duplicates_keys": "Этот ключ цитирования уже используется другой записью. Повторяющиеся ключи приводят к ошибкам цитирования — переименуйте ключ или удалите одну из записей.",
|
||||
"billed_annually_at": "Ежегодная оплата <0>__price__</0> <1>(включая тариф и все расширения)</1>",
|
||||
"billed_monthly_at": "Ежемесячная оплата <0>__price__</0> <1>(включая тариф и все расширения)</1>",
|
||||
"billing": "Оплата",
|
||||
@@ -121,11 +126,13 @@
|
||||
"change_to_this_plan": "Перейти на этот тариф",
|
||||
"chat": "Чат",
|
||||
"checking_dropbox_status": "Проверка состояния Dropbox",
|
||||
"checking_project_github_status": "Проверка статуса проекта на GitHub",
|
||||
"checking_project_github_status": "Проверка статуса проекта на __provider__",
|
||||
"choose_your_plan": "Выберите тариф",
|
||||
"citation_key_autogenerate_helper": "Если оставить поле пустым, ключ будет автоматически создан из имени автора и года",
|
||||
"cite_directly_or_import_references": "Импортировать ссылки",
|
||||
"city": "Город",
|
||||
"clear_cached_files": "Очистить кэшированные файлы",
|
||||
"clear_search": "Очистить поиск",
|
||||
"clear_sessions": "Завершить сессии",
|
||||
"clear_sessions_description": "Это список всех активных сессий Вашего аккаунта, за исключением Вашей текущей сессии. Нажмите кнопку «Завершить сессии» для закрытия всех активных сеансов.",
|
||||
"clear_sessions_success": "Сессии завершены",
|
||||
@@ -137,6 +144,7 @@
|
||||
"collaboration": "Совместная разработка",
|
||||
"collaborator": "Соавтор",
|
||||
"collabs_per_proj": "Максимальное число соавторов на проект: __count__",
|
||||
"collection": "Коллекция",
|
||||
"comment": "Комментарии",
|
||||
"common": "Общие",
|
||||
"commons": "Commons",
|
||||
@@ -155,7 +163,8 @@
|
||||
"contact_group_admin": "Пожалуйста, обратитесь к администратору Вашей группы.",
|
||||
"contact_message_label": "Сообщение",
|
||||
"contact_us": "Связаться с нами",
|
||||
"continue_github_merge": "Я провел(-а) слияние вручную. Продолжить",
|
||||
"continue": "Продолжить",
|
||||
"continue_github_merge": "Слияние вручную проведено. Продолжить",
|
||||
"continue_using_free_features": "Продолжать пользоваться бесплатными функциями",
|
||||
"cookie_banner": "Баннер файлов cookie",
|
||||
"cookie_banner_info": "Мы используем файлы cookie, чтобы улучшить Ваш опыт использования нашего сайта, для аналитики и для поддержки маркетинга, что может включать передачу данных. Подробнее можно узнать в нашей <0>политике использования файлов cookie</0>.",
|
||||
@@ -168,7 +177,7 @@
|
||||
"create": "Создать",
|
||||
"create_account": "Создать аккаунт",
|
||||
"create_new_subscription": "Создать новую подписку",
|
||||
"create_project_in_github": "Создать проект на GitHub",
|
||||
"create_project_in_github": "Создать проект на __provider__",
|
||||
"created_at": "Создано",
|
||||
"creating": "Создание",
|
||||
"credit_card": "банковская карта",
|
||||
@@ -189,6 +198,7 @@
|
||||
"deleted_at": "Удалено",
|
||||
"deleted_projects": "Удаленные проекты",
|
||||
"deleting": "Удаление",
|
||||
"details": "Подробности",
|
||||
"disconnected": "Разъединен",
|
||||
"discover_latex": "Узнать о LaTeX",
|
||||
"do_you_want_to_overwrite_it": "Перезаписать?",
|
||||
@@ -203,6 +213,7 @@
|
||||
"dropbox_sync": "Синхронизация с Dropbox",
|
||||
"dropbox_sync_description": "Синхронизируйте Ваши __appName__ проекты с Вашим Dropbox. Изменения в __appName__ автоматически сохраняются в Вашем Dropbox, и наоборот.",
|
||||
"each_user_will_have_access_to": "Каждому пользователю будут доступны",
|
||||
"edit_reference": "Редактировать ссылку",
|
||||
"edit_template": "Редактировать шаблон",
|
||||
"editing": "Редактор",
|
||||
"educational_discount_tooltip": "Доступно для групп из высших учебных заведений.",
|
||||
@@ -213,6 +224,7 @@
|
||||
"email_link_expired": "Срок действия ссылки истёк. Пожалуйста, повторите запрос!",
|
||||
"email_or_password_wrong_try_again": "Неверный адрес электронной почты или пароль. Пожалуйста, попробуйте снова",
|
||||
"en": "Английский",
|
||||
"enter_manually": "Ввести вручную",
|
||||
"es": "Испанский",
|
||||
"every": "каждый",
|
||||
"example_project": "Использовать пример",
|
||||
@@ -223,7 +235,7 @@
|
||||
"explore_customer_stories": "Посмотреть истории клиентов",
|
||||
"explore_features": "Посмотреть функции",
|
||||
"explore_plans": "Посмотреть тарифы",
|
||||
"export_project_to_github": "Экспорт проекта на GitHub",
|
||||
"export_project_to_github": "Экспорт проекта на __provider__",
|
||||
"failed_to_publish_as_a_template": "Не удалось создать шаблон.",
|
||||
"failed_to_send_registration_email": "Не удалось отправить письмо для подтверждения регистрации. Пожалуйста, проверьте адрес электронной почты.",
|
||||
"fast": "быстрый",
|
||||
@@ -241,6 +253,7 @@
|
||||
"folders": "Папки",
|
||||
"footer_about_us": "О нас",
|
||||
"forgot_your_password": "Забыли пароль?",
|
||||
"format": "Формат",
|
||||
"found_matching_deleted_users": "Найдено совпадающих удалённых пользователей: __count__",
|
||||
"fr": "Французский",
|
||||
"free": "Бесплатно",
|
||||
@@ -268,13 +281,37 @@
|
||||
"github_integration_lowercase": "Интеграция с Git и GitHub",
|
||||
"github_is_premium": "Синхронизация с GitHub доступна только в премиум аккаунте",
|
||||
"github_manual_merge_user_prompt": "Пожалуйста, вручную объедините ветку <0>__sharelatex_branch__</0> с веткой по умолчанию в git. Нажмите ниже, чтобы продолжить после ручного объединения.",
|
||||
"github_merge_failed_error": "Ваши изменения в __appName__ и GitHub не были автоматически объединены.",
|
||||
"github_merge_failed_error": "Ваши изменения в __appName__ и __provider__ не были автоматически объединены.",
|
||||
"github_private_description": "Вы выбираете, кто может просматривать этот репозиторий и вносить в него изменения.",
|
||||
"github_public_description": "Этот репозиторий может просмотреть каждый. Вы выбираете, кто может править.",
|
||||
"github_repository_diverged": "Ветка по умолчанию подключённого репозитория была принудительно обновлена. Список последних коммитов ниже может быть неполным из-за переписанной истории с момента последней синхронизации.",
|
||||
"github_successfully_linked_description": "Спасибо, мы успешно связали ваш аккаунт на GitHub с __appName__. Теперь вы можете экспортировать проекты с __appName__ в GitHub или импортировать в обратном направлении.",
|
||||
"github_sync_description": "Вы можете связать ваши проекты __appName__ с репозиториями на GitHub. Создавайте коммиты в __appName__ и объединяйте их с коммитами, сделанными оффлайн или на GitHub.",
|
||||
"github_sync_error": "Извините, произошла ошибка в общении с сервисом GitHub. Пожалуйста, попробуйте ещё раз позднее.",
|
||||
"github_successfully_linked_description": "Спасибо, мы успешно связали ваш аккаунт на __provider__ с __appName__. Теперь вы можете экспортировать проекты с __appName__ в __provider__ или импортировать в обратном направлении.",
|
||||
"github_sync_branch_name": "Имя ветки",
|
||||
"github_sync_description": "Вы можете связать ваши проекты __appName__ с репозиториями на __provider__. Создавайте коммиты в __appName__ и объединяйте их с коммитами, сделанными оффлайн или на __provider__.",
|
||||
"github_sync_error": "Извините, произошла ошибка в общении с сервисом __provider__. Пожалуйста, попробуйте ещё раз позднее.",
|
||||
"github_sync_file_limits_link": "Подробнее об ограничениях на файлы при синхронизации с GitHub",
|
||||
"github_sync_import_link_failed": "Ваш проект создан, но связать его с репозиторием не удалось. Попробуйте связать проект ещё раз или откройте его в Overleaf.",
|
||||
"github_sync_import_pending": "Импорт из Git не завершён. Выберите Sync, чтобы повторить попытку. Если восстановление продолжает завершаться ошибкой, владелец проекта может выбрать Unlink, чтобы прекратить синхронизацию с этим репозиторием.",
|
||||
"github_sync_interrupted": "Синхронизация была прервана. Некоторые изменения, возможно, уже были применены. Выберите Sync, чтобы повторить попытку.",
|
||||
"github_sync_invalid_branch_name": "Недопустимое имя ветки.",
|
||||
"github_sync_invalid_repository_name": "Недопустимое имя репозитория.",
|
||||
"github_sync_linked_branch_moved": "Связанная ветка изменилась. Повторите синхронизацию, чтобы получить её изменения.",
|
||||
"github_sync_manual_resolution_allowed": "Вы также можете объединить изменения вручную в ветке <0>__branch__</0>. При нажатии Продолжить будет проверена история Git. Если подтвердить слияние по истории не удастся, потребуется ваше подтверждение.",
|
||||
"github_sync_merge_into_linked_branch": "Объедините <0>__source__</0> с <1>__target__</1> на __provider__ и разрешите конфликты в файлах.",
|
||||
"github_sync_merge_recovery_branch": "Объедините __branch__ с __targetBranch__, затем повторите синхронизацию, чтобы завершить восстановление изменений из репозитория.",
|
||||
"github_sync_recovery_review_history": "Во время импорта файлов через Git Sync кто-то редактировал проект. При импорте последние изменения могли быть перезаписаны. Проверьте затронутые файлы в Overleaf и восстановите отсутствующие изменения из истории проекта. Выберите Продолжить, когда проект будет готов к слиянию с веткой <0>__branch__</0>.",
|
||||
"github_sync_repository_not_found_description": "Связанный репозиторий был удалён или у вас больше нет к нему доступа. Вы можете настроить синхронизацию с другим репозиторием, клонировав проект и выбрав пункт «__provider__» в меню. Также можно отключить этот репозиторий от проекта.",
|
||||
"github_sync_repository_not_found_description": "Связанный репозиторий был удалён или у вас больше нет доступа к нему. Вы можете настроить синхронизацию с новым репозиторием, клонировав проект и выбрав пункт меню «__provider__». Вы также можете отвязать репозиторий от этого проекта.",
|
||||
"github_sync_resolution_effect": "При продолжении ветка <0>__branch__</0> будет импортирована в Overleaf. Изменения в Overleaf, сделанные после возникновения конфликта, будут объединены отдельно.",
|
||||
"github_sync_resolution_reviewed_commit": "Последний коммит в ветке <0>__branch__</0>: <1>__head__</1>",
|
||||
"github_sync_resolution_unchanged": "Связанная ветка <0>__branch__</0> не изменилась с момента возникновения конфликта.",
|
||||
"github_sync_resolution_unverified": "История Git не подтверждает, что изменения из Overleaf были объединены с веткой <0>__branch__</0>. Убедитесь, что эта ветка содержит нужное вам разрешение конфликта.",
|
||||
"github_sync_retry_link": "Повторить привязку",
|
||||
"github_sync_unlink_error": "К сожалению, не удалось отвязать вашу учётную запись GitHub. Попробуйте ещё раз через некоторое время.",
|
||||
"github_sync_upstream_error": "Не удалось подключиться к GitHub. Попробуйте ещё раз через некоторое время. Если проблема сохраняется, проверьте сообщения о сбоях на status.overleaf.com и githubstatus.com.",
|
||||
"github_validation_check": "Пожалуйста, проверьте правильность имени хранилища и права доступа на создание хранилища",
|
||||
"github_validation_check_auth": "Проверьте, есть ли у вас права на создание репозитория.",
|
||||
"github_validation_name_exists": "Репозиторий с таким именем уже существует.",
|
||||
"global": "глобальное",
|
||||
"go_to_code_location_in_pdf": "Перейти к местоположению кода в PDF",
|
||||
"go_to_pdf_location_in_code": "Перейти к коду в редакторе",
|
||||
@@ -370,13 +407,14 @@
|
||||
"i_want_to_stay": "Я хочу остаться",
|
||||
"ignore_validation_errors": "Не проверять синтаксис",
|
||||
"ill_take_it": "Беру!",
|
||||
"import": "Импортировать",
|
||||
"import_a_bibtex_file_from_your_provider_account": "Импортируйте BibTeX файл из Вашего аккаунта __provider__",
|
||||
"import_from_github": "Импорт с GitHub",
|
||||
"import_from_github": "Импорт с __provider__",
|
||||
"import_to_sharelatex": "Импортировать в __appName__",
|
||||
"imported_from_zotero_at_date": "Импортировано из Zotero в __formattedDate__ __relativeDate__",
|
||||
"imported_from_zotero_at_date_by": "Импортировано из Zotero в __formattedDate__ __relativeDate__ пользователем __importedByName__",
|
||||
"importing": "Импорт",
|
||||
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub",
|
||||
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в __provider__",
|
||||
"inactive_projects": "Неактивные проекты",
|
||||
"included_as_part_of_your_writefull_subscription": "Входит в Вашу подписку Writefull",
|
||||
"including_all_subdomains": "включая все поддомены",
|
||||
@@ -413,11 +451,11 @@
|
||||
"leave_projects": "Оставить проекты",
|
||||
"lets_get_those_premium_features": "Давайте сразу подключим Ваши премиум-функции. Мы спишем <0>__paymentAmount__</0> с указанных Вами платёжных данных.",
|
||||
"library": "Библиотека",
|
||||
"link_to_github": "Привязать аккаунт на GitHub",
|
||||
"link_to_github_description": "Вам необходимо авторизовать __appName__ для доступа к Вашему GitHub аккаунту, чтобы разрешить нам синхронизацию Ваших проектов.",
|
||||
"link_to_github": "Привязать аккаунт на __provider__",
|
||||
"link_to_github_description": "Вам необходимо авторизовать __appName__ для доступа к Вашему __provider__ аккаунту, чтобы разрешить нам синхронизацию Ваших проектов.",
|
||||
"links": "Ссылки",
|
||||
"loading": "Загрузка",
|
||||
"loading_github_repositories": "Загрузка ваших проектов с GitHub",
|
||||
"loading_github_repositories": "Загрузка ваших проектов с __provider__",
|
||||
"loading_prices": "загрузка цен",
|
||||
"loading_recent_github_commits": "Загрузка последних изменений",
|
||||
"log_hint_extra_info": "Узнать больше",
|
||||
@@ -461,6 +499,7 @@
|
||||
"more_options": "Дополнительные параметры",
|
||||
"must_be_email_address": "Введите правильный адрес электронной почты.",
|
||||
"my_library": "Моя библиотека",
|
||||
"my_publications": "Мои публикации",
|
||||
"name": "Имя",
|
||||
"native": "браузер",
|
||||
"navigation": "Навигация",
|
||||
@@ -477,16 +516,18 @@
|
||||
"new_project": "Создать проект",
|
||||
"new_subscription_will_be_billed_immediately": "Оплата новой подписки будет списана сразу с текущего способа оплаты.",
|
||||
"next_payment_of_x_collectected_on_y": "Следующий платёж в размере <0>__paymentAmmount__</0> будет списан <1>__collectionDate__</1>",
|
||||
"next_reference": "Следующая ссылка",
|
||||
"nl": "Голландский",
|
||||
"no": "Норвежский",
|
||||
"no_add_on_purchase_while_paused": "Чтобы купить расширение, возобновите или отмените текущую подписку.",
|
||||
"no_ai_assistant": "Без ИИ-ассистента",
|
||||
"no_members": "Нет участников",
|
||||
"no_new_commits_in_github": "Нет новых коммитов на GitHub с момента последнего слияния.",
|
||||
"no_new_commits_in_github": "Нет новых коммитов на __provider__ с момента последнего слияния.",
|
||||
"no_other_sessions": "Нет других активных сессий",
|
||||
"no_planned_maintenance": "В настоящее время плановые работы не осуществляются",
|
||||
"no_preview_available": "К сожалению, предпросмотр не доступен",
|
||||
"no_projects": "Нет проектов",
|
||||
"no_references_found": "Ссылки не найдены",
|
||||
"no_search_results": "Ничего не найдено",
|
||||
"no_templates_found": "Шаблоны не найдены.",
|
||||
"no_thanks_cancel_now": "Нет, спасибо, я всё равно хочу отменить подписку",
|
||||
@@ -516,6 +557,8 @@
|
||||
"password_reset": "Сбросить пароль",
|
||||
"password_reset_email_sent": "На ваш электронный адрес было отправлено письмо с инструкцией по восстановлению пароля.",
|
||||
"password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.",
|
||||
"paste_bibtex_or_dois_here": "Вставьте BibTeX или DOI.",
|
||||
"paste_references": "Вставить ссылки",
|
||||
"pause_subscription": "Приостановить подписку",
|
||||
"pause_subscription_for": "Приостановить подписку на",
|
||||
"payment_error_3ds_failed": "Не удалось завершить платёж: аутентификация не прошла. Попробуйте снова или выберите другой способ оплаты. Если проблема сохраняется, <0>свяжитесь с нами</0>.",
|
||||
@@ -552,6 +595,8 @@
|
||||
"position": "Должность",
|
||||
"presentation": "Презентация",
|
||||
"presentations": "Презентации",
|
||||
"preview": "Предпросмотр",
|
||||
"previous_reference": "Предыдущая ссылка",
|
||||
"price": "Цена",
|
||||
"privacy": "Конфиденциальность",
|
||||
"privacy_policy": "Конфиденциальность",
|
||||
@@ -569,12 +614,13 @@
|
||||
"project_last_published_at": "В последний раз проект был опубликован",
|
||||
"project_linked_to": "Этот проект связан с репозиторием",
|
||||
"project_name": "Название проекта",
|
||||
"project_not_linked_to_github": "Этот проект не связан ни с одним проектом на GitHub. Вы можете создать для него проект на GitHub:",
|
||||
"project_not_linked_to_github": "Этот проект не связан ни с одним проектом на __provider__. Вы можете создать для него проект на __provider__:",
|
||||
"project_too_large": "Проект слишком большой",
|
||||
"project_too_large_please_reduce": "В этом проекте слишком много текста. Пожалуйста, попробуйте уменьшить количество.",
|
||||
"project_url": "URL проекта",
|
||||
"projects": "Проекты",
|
||||
"projects_list": "Список проектов",
|
||||
"provider_rate_limited": "Поставщик библиографических данных временно ограничил число запросов. Пожалуйста, повторите попытку позже.",
|
||||
"pt": "Португальский",
|
||||
"public": "Открытый",
|
||||
"publish": "Опубликовать",
|
||||
@@ -589,7 +635,7 @@
|
||||
"reactivate_subscription_valid_address_required": "Пожалуйста, <0>добавьте в аккаунт корректный платёжный адрес</0>, прежде чем восстанавливать подписку.",
|
||||
"reactivating": "Восстановление",
|
||||
"read_the_full_story": "Прочитать всю историю",
|
||||
"recent_commits_in_github": "Последние коммиты на GitHub",
|
||||
"recent_commits_in_github": "Последние коммиты на __provider__",
|
||||
"recompile": "Компилировать",
|
||||
"recompile_pdf": "Перекомпилировать PDF",
|
||||
"reconnecting": "Пересоединение",
|
||||
@@ -597,8 +643,12 @@
|
||||
"recurly_email_update_needed": "Сейчас Ваш адрес электронной почты для счетов — <0>__recurlyEmail__</0>. При необходимости Вы можете изменить его на <1>__userEmail__</1>.",
|
||||
"recurly_email_updated": "Адрес электронной почты для счетов успешно обновлён",
|
||||
"reduce_costs_group_licenses": "С нашими групповыми лицензиями со скидкой Вы сократите бумажную работу и расходы.",
|
||||
"reference": "Ссылка",
|
||||
"reference_picker_hide_hint": "Больше не показывать",
|
||||
"reference_picker_shortcut_hint": "Нажмите __shortcut__, чтобы найти ссылки на источники.",
|
||||
"reference_search": "Расширенный поиск по источникам",
|
||||
"reference_sync": "Синхронизация с менеджером библиографии",
|
||||
"references": "Ссылки",
|
||||
"refresh_page_after_starting_free_trial": "Пожалуйста, обновите страницу после начала бесплатного пробного периода.",
|
||||
"regards": "С уважением",
|
||||
"register": "Регистрация",
|
||||
@@ -620,6 +670,8 @@
|
||||
"republish": "Переопубликовать",
|
||||
"request_sent_thank_you": "Спасибо, Ваш запрос отправлен!",
|
||||
"required": "обязательно",
|
||||
"required_field_missing": "Отсутствует обязательное поле",
|
||||
"required_fields_missing": "Отсутствуют обязательные поля",
|
||||
"resend": "Отправить повторно",
|
||||
"resend_activation_email": "Повторно отправить письмо активации",
|
||||
"reset_password": "Сбросить пароль",
|
||||
@@ -637,6 +689,7 @@
|
||||
"revoke_invite": "Отозвать приглашение",
|
||||
"role": "Роль",
|
||||
"ru": "Русский",
|
||||
"save": "Сохранить",
|
||||
"save_x_or_more": "Экономьте __percentage__ или больше",
|
||||
"saving": "Сохранение",
|
||||
"saving_notification_with_seconds": "Сохранение __docname__... (__seconds__ секунд с последнего сохранения)",
|
||||
@@ -647,12 +700,17 @@
|
||||
"search_replace": "Заменить",
|
||||
"security": "Безопасность",
|
||||
"see_all_plans": "Показать все тарифы",
|
||||
"select": "Выбрать",
|
||||
"select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя",
|
||||
"select_all": "Выбрать всё",
|
||||
"select_all_entries": "Выбрать все записи",
|
||||
"select_all_projects": "Выбрать все проекты",
|
||||
"select_all_users": "Выбрать всех пользователей",
|
||||
"select_github_repository": "Выберите проект на GitHub для импорта в __appName__",
|
||||
"select_entry": "Выбрать запись",
|
||||
"select_github_repository": "Выберите проект на __provider__ для импорта в __appName__",
|
||||
"select_project": "Выбрать __project__",
|
||||
"select_projects": "Выбрать проекты",
|
||||
"select_reference": "Выберите хотя бы одну ссылку",
|
||||
"select_user": "Выбрать пользователя",
|
||||
"select_users": "Выбрать пользователей",
|
||||
"selected_text_word_count": "Количество слов в выбранном тексте",
|
||||
@@ -710,9 +768,10 @@
|
||||
"sv": "Шведский",
|
||||
"sync": "Синхронизация",
|
||||
"sync_dropbox_github": "Синхронизация с Dropbox и GitHub",
|
||||
"sync_project_to_github_explanation": "Все изменения, сделанные Вами в __appName__ будут интегрированы (commit и merge) со всеми обновлениями на GitHub.",
|
||||
"sync_project_to_github_explanation": "Все изменения, сделанные Вами в __appName__ будут интегрированы (commit и merge) со всеми обновлениями на __provider__.",
|
||||
"sync_to_dropbox": "Синхронизация с Dropbox",
|
||||
"sync_with_github": "Синхронизация с GitHub",
|
||||
"sync_with_a_github_repository": "Синхронизировать с репозиторием __provider__.",
|
||||
"sync_with_github": "Синхронизация с __provider__",
|
||||
"syntax_validation": "Проверка кода",
|
||||
"take_me_home": "Вернуться в начало",
|
||||
"talk_to_sales": "Связаться с отделом продаж",
|
||||
@@ -746,6 +805,8 @@
|
||||
"too_many_files_uploaded_throttled_short_period": "Слишком много файлов загружено за раз - на короткое время загрузка была приостановлена.",
|
||||
"too_many_login_requests_2_mins": "Было предпринято слишком много попыток входа. Пожалуйста, подождите 2 минуты, прежде чем пробовать снова",
|
||||
"too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.",
|
||||
"tooltip_hide_panel": "Нажмите, чтобы скрыть панель",
|
||||
"tooltip_show_panel": "Нажмите, чтобы показать панель",
|
||||
"total_with_subtotal_and_tax": "Итого: <0>__total__</0> (__subtotal__ + налог __tax__) в год",
|
||||
"total_words": "Количество слов",
|
||||
"tr": "Турецкий",
|
||||
@@ -764,10 +825,11 @@
|
||||
"unlimited_collabs": "Неограниченное число соавторов",
|
||||
"unlimited_projects": "Неограниченное число проектов",
|
||||
"unlink": "Отсоединить",
|
||||
"unlink": "Отсоединить",
|
||||
"unlink_github_repository": "Отсоединить Git-репозиторий",
|
||||
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?",
|
||||
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с __provider__, будут отсоединены и больше не будут синхронизироваться с __provider__. Вы уверены, что хотите отсоединить Ваш __provider__ аккаунт?",
|
||||
"unlink_provider_account_title": "Отключить аккаунт __provider__",
|
||||
"unlink_reference": "Отсоединить провайдер ссылок __provider__",
|
||||
"unlink_the_project_from_the_current_github_repo": "Отключите проект от текущего репозитория __provider__ и подключите его к репозиторию, который принадлежит вам.",
|
||||
"unlink_warning_reference": "Если вы отсоедините аккаунт, у Вас не будет возможности импортировать ссылки из __provider__ в Ваши проекты.",
|
||||
"unpause_subscription": "Возобновить подписку",
|
||||
"unpausing": "Возобновление",
|
||||
@@ -875,11 +937,107 @@
|
||||
"youve_paused_your_subscription": "Подписка <0>__planName__</0> приостановлена до <0>__reactivationDate__</0>, затем она возобновится автоматически. Вы можете возобновить её раньше в любой момент.",
|
||||
"zh-CN": "Китайский",
|
||||
"zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.",
|
||||
"zotero_could_not_load_collections": "Не удалось загрузить коллекции Zotero",
|
||||
"zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка",
|
||||
"zotero_groups_relink": "При загрузке данных из Zotero произошла ошибка, по-видимости вызванная отсутствием разрешения на доступ. Пожалуйста, подключите аккаунт заново и попробуйте ещё раз.",
|
||||
"zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero",
|
||||
"zotero_reference_loading_error_expired": "Срок действия токена Zotero истёк, пожалуйста, повторно подключите аккаунт",
|
||||
"zotero_reference_loading_error_forbidden": "Не удалось загрузить ссылки из Zotero, пожалуйста, повторно подключите аккаунт и попробуйте снова",
|
||||
"zotero_reference_loading_error_not_linked": "Не удалось обновить файл: учётная запись, использованная для импорта файла, больше не связана с Zotero или была удалена.",
|
||||
"zotero_sync_description": "Используя интеграцию с Zotero вы можете импортировать свои ссылки из Zotero в проекты __appName__."
|
||||
"zotero_sync_description": "Используя интеграцию с Zotero вы можете импортировать свои ссылки из Zotero в проекты __appName__.",
|
||||
"add_field": "Добавить поле",
|
||||
"add_name": "Добавить __name__",
|
||||
"add_optional_field": "Добавить необязательное поле",
|
||||
"author_name": "Имя автора",
|
||||
"badly_formatted_field": "Неправильный формат поле",
|
||||
"badly_formatted_fields": "Неправильный формат полей",
|
||||
"bib_field_group_books_and_volumes": "Книги и тома",
|
||||
"bib_field_group_contributors": "Авторы и соавторы",
|
||||
"bib_field_group_digital_and_online": "Электронные и сетевые ресурсы",
|
||||
"bib_field_group_events_and_conferences": "Мероприятия и конференции",
|
||||
"bib_field_group_language_and_origin": "Языки и источники",
|
||||
"bib_field_group_periodicals_and_journals": "Периодические издания и журналы",
|
||||
"bib_field_group_publication_details": "Сведения о публикации",
|
||||
"bib_field_helper_doi_identifier": "Только идентификатор, без полного URL, например 10.1000/xyz123",
|
||||
"bib_field_helper_page_range": "Диапазон страниц",
|
||||
"bib_field_helper_preprint_archive_identifier": "Идентификатор архива препринтов, например math/0307200v3",
|
||||
"bib_syntax_duplicate_field": "повторяющееся поле '__field__'",
|
||||
"bib_syntax_expected_comma_after_citation_key": "после ключа цитирования ожидалась запятая ',', обнаружено поле '__field__'",
|
||||
"bib_syntax_expected_comma_before_field": "перед полем '__field__' ожидалась запятая ','",
|
||||
"bib_syntax_expected_equals_after_field_name": "после имени поля '__field__' ожидался знак '='",
|
||||
"bib_syntax_invalid_entry_header": "Неверный или неполный заголовок записи BibTeX",
|
||||
"bib_syntax_invalid_field": "неверный синтаксис поля BibTeX",
|
||||
"bib_syntax_line": "Строка __line__: __problem__",
|
||||
"bib_syntax_missing_closing_brace_opened_on_line": "отсутствует закрывающая скобка '}' для '{', открытой в строке __line__",
|
||||
"bib_syntax_missing_closing_quote": "отсутствует закрывающая кавычка \" (открыта в строке __line__)",
|
||||
"bib_syntax_unexpected_comma": "Некорректно поставленная запятая в записи BibTeX",
|
||||
"bibliography_editor_load_error": "Не удалось загрузить редактор библиографии.",
|
||||
"bibtex_doi_description": "BibTeX, DOI",
|
||||
"citation_key": "Ключ цитирования",
|
||||
"citation_key_contains_invalid_characters": "Ключ цитирования содержит недопустимые символы",
|
||||
"collapse_optional": "Свернуть необязательные поля",
|
||||
"confirm_delete_entry": "Вы уверены, что хотите удалить эту запись?",
|
||||
"confirm_delete_references_count": "Удалить ссылки (__count__)?",
|
||||
"correct_syntax_error_in_code_mode": "Исправьте эту ошибку в режиме кода. Визуальное редактирование этой записи отключено.",
|
||||
"correct_syntax_errors_in_code_mode": "Исправьте эти ошибки в режиме кода. Визуальное редактирование этой записи отключено.",
|
||||
"could_not_import_this_reference": "Не удалось импортировать эту ссылку",
|
||||
"could_not_parse_this_reference": "Не удалось обработать эту ссылку.",
|
||||
"could_not_repair_this_reference": "Не удалось исправить эту ссылку.",
|
||||
"could_not_retrieve_doi_metadata": "Не удалось получить метаданные для этого DOI. Проверьте DOI и попробуйте снова.",
|
||||
"could_not_save_bib_file": "Не удалось сохранить .bib файл. Что-то пошло не так.",
|
||||
"curly_braces_must_be_balanced": "Фигурные скобки должны быть парными",
|
||||
"date_format_hint": "Формат даты: ГГГГ, ГГГГ-ММ или ГГГГ-ММ-ДД; время и часовой пояс необязательны; допустимы интервалы (например, 2026, 2026-10, 2026-10-17, 2026-10-17T14:30Z, 2026-10-17/2026-10-20)",
|
||||
"delete_entries": "Удалить записи",
|
||||
"delete_entry": "Удалить запись",
|
||||
"doi_format_hint": "Формат DOI: 10.xxxx/xxxxx (например, 10.1234/example)",
|
||||
"either_a_or_b_is_required": "Требуется поле __a__ или __b__",
|
||||
"enter_field_name": "Введите имя поля",
|
||||
"enter_orcid_id": "Введите ORCID iD",
|
||||
"enter_type": "Введите тип",
|
||||
"entry_has_syntax_error": "В записи есть синтаксическая ошибка",
|
||||
"entry_has_syntax_errors": "В записи есть синтаксические ошибки",
|
||||
"entry_has_warnings": "В записи есть предупреждения",
|
||||
"entry_type": "Тип записи",
|
||||
"expand_optional": "Развернуть необязательные поля",
|
||||
"failed_to_fetch_doi": "Не удалось загрузить DOI",
|
||||
"failed_to_load_works": "Не удалось загрузить публикации",
|
||||
"field_is_required": "Поле __a__ обязательно",
|
||||
"generate": "Генерировать",
|
||||
"import_failed": "Ошибка импорта",
|
||||
"import_from_provider": "Импортировать из __provider__",
|
||||
"invalid_doi_format": "Неверный формат DOI (ожидается 10.xxxx/…)",
|
||||
"invalid_response_from_server": "Некорректный ответ сервера",
|
||||
"load_works": "Загрузить публикации",
|
||||
"missing_field_for_entry_type": "Отсутствует поле для типа __entryType__",
|
||||
"missing_fields_for_entry_type": "Отсутствуют поля для типа __entryType__",
|
||||
"move_down": "Переместить вниз",
|
||||
"move_up": "Переместить вверх",
|
||||
"no_authors_found": "Авторы не найдены",
|
||||
"no_bibliography_entries_yet_add_one": "В библиографии пока нет записей. Нажмите «+ Добавить», чтобы создать запись.",
|
||||
"no_entries_match_your_search": "Нет записей, соответствующих запросу.",
|
||||
"no_name": "(без имени)",
|
||||
"no_works_found_for_this_author": "Публикации этого автора не найдены",
|
||||
"orcid_crossref_rate_limited": "Достигнут лимит запросов. Повторите попытку позже.",
|
||||
"orcid_enriched_reference": "__count__ запись дополнена метаданными Crossref",
|
||||
"orcid_enriched_references": "Дополнено метаданными Crossref: __count__",
|
||||
"orcid_enrichment_failed_reference": "Не удалось дополнить __count__ запись через Crossref:",
|
||||
"orcid_enrichment_failed_references": "Не удалось дополнить записи через Crossref (__count__):",
|
||||
"orcid_id": "ORCID iD",
|
||||
"orcid_imported_reference": "Успешно импортирована __count__ ссылка",
|
||||
"orcid_imported_references": "Успешно импортировано ссылок: __count__",
|
||||
"remove_author": "Удалить автора",
|
||||
"remove_field": "Удалить поле __a__",
|
||||
"resolving": "Обработка…",
|
||||
"search_bib_file": "Поиск в __fileName__",
|
||||
"search_by_name": "Поиск по имени",
|
||||
"search_failed": "Ошибка поиска",
|
||||
"search_orcid_by_name_or_id": "Поиск ORCID по имени или iD",
|
||||
"select_a_type_to_see_the_required_fields": "Выберите тип, чтобы увидеть обязательные поля",
|
||||
"separate_multiple_names_with_and": "Разделяйте имена словом «and»",
|
||||
"some_works_could_not_be_fetched": "Не удалось загрузить некоторые публикации",
|
||||
"some_works_could_not_be_imported": "Некоторые выбранные публикации не удалось импортировать",
|
||||
"untitled": "Без названия",
|
||||
"url_format_hint": "Формат URL: example.com или https://example.com",
|
||||
"works_by_author": "Публикации автора __author__",
|
||||
"year_format_hint": "Формат года: ГГГГ (например, 2026)"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Visual editor provider for the bib-editor module.
|
||||
* Registered via overleafModuleImports.visualEditorProviders in settings.
|
||||
*
|
||||
* This enables the Code/Visual toggle for .bib files so that clicking
|
||||
* "Visual" opens the bibliography editor inside the main editor window
|
||||
* instead of a separate sidebar rail panel.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { getFileExtension } from '@/features/source-editor/utils/file'
|
||||
|
||||
/**
|
||||
* Stable lazy-loaded reference to the visual editor component.
|
||||
* Must be created at module level so the reference is stable across renders.
|
||||
*/
|
||||
const BibEditorVisual = React.lazy(() => import('./components/bib-editor-panel'))
|
||||
|
||||
/** Unique id used for the per-file editor mode storage key. */
|
||||
export const id = 'bib-editor'
|
||||
|
||||
export function isVisualEditorAvailable(filename: string): boolean {
|
||||
return getFileExtension(filename) === 'bib'
|
||||
}
|
||||
|
||||
export function getVisualEditorComponent(
|
||||
filename: string
|
||||
): typeof BibEditorVisual | null {
|
||||
return isVisualEditorAvailable(filename) ? BibEditorVisual : null
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Toolbar for selecting and acting on the entries currently visible in the list.
|
||||
* Selection itself is owned by BibEditorPanel; this component only presents the
|
||||
* state and sends user actions back to the panel.
|
||||
*/
|
||||
import React, { useId } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import { plural } from '../utils/plural'
|
||||
import '../../stylesheets/bib-editor-panel.css'
|
||||
|
||||
type Props = {
|
||||
filteredLength: number
|
||||
allSelected: boolean
|
||||
selectedIds: string[]
|
||||
onToggleSelectAll?: (kind: 'all' | 'none') => void
|
||||
onBulkDelete?: () => void
|
||||
onBulkDownload?: () => void
|
||||
}
|
||||
|
||||
function BibBulkActionsBar({
|
||||
filteredLength,
|
||||
allSelected,
|
||||
selectedIds,
|
||||
onToggleSelectAll,
|
||||
onBulkDelete,
|
||||
onBulkDownload,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const selectAllId = useId()
|
||||
const count = selectedIds.length || filteredLength
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames('bibtex-bulk-actions-bar', {
|
||||
'bibtex-bulk-actions-bar-selected': selectedIds.length > 0,
|
||||
})}
|
||||
>
|
||||
<div className="wide-target-checkbox bibtex-bulk-actions-select-all">
|
||||
<span className="wide-target-checkbox-box">
|
||||
<OLFormCheckbox
|
||||
id={selectAllId}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e =>
|
||||
onToggleSelectAll?.(e.target.checked ? 'all' : 'none')
|
||||
}
|
||||
checked={allSelected}
|
||||
autoComplete="off"
|
||||
aria-label={t('select_all_entries')}
|
||||
/>
|
||||
<label
|
||||
className="wide-target-checkbox-target"
|
||||
htmlFor={selectAllId}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bibtex-bulk-actions-count">
|
||||
{`${count} ${plural(t, count, 'reference', 'references')}`}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && onBulkDelete ? (
|
||||
<OLButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leadingIcon={<MaterialIcon type="delete" unfilled />}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onBulkDelete()
|
||||
}}
|
||||
aria-label={t('delete')}
|
||||
>
|
||||
<span className="bibtex-bulk-actions-btn-label">
|
||||
{t('delete')}
|
||||
</span>
|
||||
</OLButton>
|
||||
) : null}
|
||||
|
||||
{selectedIds.length > 0 && onBulkDownload ? (
|
||||
<OLButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leadingIcon="download"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onBulkDownload()
|
||||
}}
|
||||
aria-label={t('download')}
|
||||
>
|
||||
<span className="bibtex-bulk-actions-btn-label">
|
||||
{t('download')}
|
||||
</span>
|
||||
</OLButton>
|
||||
) : null}
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<OLIconButton
|
||||
className="bibtex-bulk-actions-close"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="close"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onToggleSelectAll?.('none')
|
||||
}}
|
||||
aria-label={t('close')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BibBulkActionsBar)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import {
|
||||
OLModal, OLModalBody, OLModalFooter, OLModalHeader, OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
title: string
|
||||
message: string
|
||||
onHide: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export default function BibDeleteModal({ show, title, message, onHide, onConfirm }: Props) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<OLModal data-bib-editor-dialog backdrop="static" show={show} onHide={onHide} themed>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{title}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>{message}</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={onHide}>{t('cancel')}</OLButton>
|
||||
<OLButton variant="danger" onClick={onConfirm}>{t('delete')}</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* One bibliography row in the entry list. The row shows compact metadata and
|
||||
* delegates selection/detail editing to the surrounding list/panel.
|
||||
* Each source occurrence has its own id, including duplicate citation keys.
|
||||
*/
|
||||
import React, { useCallback, useMemo, useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { plural } from '../utils/plural'
|
||||
import type { PositionedBibtexEntry } from '../utils/bib-types'
|
||||
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
|
||||
import type { BibSourceDiagnostic } from '../utils/bib-source-diagnostics'
|
||||
import {
|
||||
getBibEntryDiagnostics,
|
||||
translateBibSourceDiagnostic,
|
||||
} from '../utils/bib-entry-diagnostics'
|
||||
import { missingRequiredLabels, splitHighlighted } from '../utils/preview-model'
|
||||
import { formatWarningLabels } from '../utils/bib-validate'
|
||||
import { citationAuthorSurnames, humanizeCitationHeading } from '../utils/overleaf-type-map.ts'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import BibEntryWarningSummary from './bib-entry-warning-summary'
|
||||
|
||||
function Highlighted({ text, search }: { text: string; search: string }) {
|
||||
const segments = splitHighlighted(text, search)
|
||||
if (segments.length <= 1 && !segments[0]?.match) {
|
||||
return <>{text}</>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{segments.map((s, i) =>
|
||||
s.match ? (
|
||||
<mark key={i}>{s.text}</mark>
|
||||
) : (
|
||||
<React.Fragment key={i}>{s.text}</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type EntryStatusTooltipProps = {
|
||||
id: string
|
||||
description: React.ReactNode
|
||||
ariaLabel: string
|
||||
icon: 'error' | 'warning'
|
||||
className?: string
|
||||
}
|
||||
|
||||
function EntryStatusTooltip({
|
||||
id,
|
||||
description,
|
||||
ariaLabel,
|
||||
icon,
|
||||
className = 'bibtex-entry-error-icon',
|
||||
}: EntryStatusTooltipProps) {
|
||||
return (
|
||||
<OLTooltip
|
||||
id={id}
|
||||
description={description}
|
||||
tooltipProps={{ className: 'bibtex-tooltip-errors' }}
|
||||
overlayProps={{ placement: 'right', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<button type="button" className={className} aria-label={ariaLabel}>
|
||||
<span className="material-symbols" aria-hidden="true" translate="no">
|
||||
{icon}
|
||||
</span>
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// The parsed entry object is unchanged by position-only CodeMirror edits.
|
||||
// Keep metadata, warning computation and tooltip rendering independent of the
|
||||
// outer row's current source coordinates and selection state.
|
||||
const BibEntryCardContent = React.memo(function BibEntryCardContent({
|
||||
bibEntry,
|
||||
displayKey,
|
||||
missingFields,
|
||||
diagnostics,
|
||||
search,
|
||||
rowId,
|
||||
duplicate,
|
||||
}: {
|
||||
bibEntry: BibtexEntry
|
||||
displayKey: string
|
||||
missingFields: string[]
|
||||
diagnostics: BibSourceDiagnostic[]
|
||||
search: string
|
||||
rowId: string
|
||||
duplicate?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const title = bibEntry.getField('title').toDisplayString()
|
||||
const badlyFormattedFields = formatWarningLabels(bibEntry)
|
||||
const hasErrors = missingFields.length > 0
|
||||
const hasWarnings = hasErrors || badlyFormattedFields.length > 0
|
||||
const warningDescription = (
|
||||
<BibEntryWarningSummary
|
||||
missingFields={missingFields}
|
||||
badlyFormattedFields={badlyFormattedFields}
|
||||
requiredFieldsTitle={plural(
|
||||
t,
|
||||
missingFields.length,
|
||||
'missing_field_for_entry_type',
|
||||
'missing_fields_for_entry_type',
|
||||
{ entryType: bibEntry.type.toLowerCase() }
|
||||
)}
|
||||
badlyFormattedFieldsTitle={plural(
|
||||
t,
|
||||
badlyFormattedFields.length,
|
||||
'badly_formatted_field',
|
||||
'badly_formatted_fields'
|
||||
)}
|
||||
listFields
|
||||
/>
|
||||
)
|
||||
const syntaxErrorMessages = diagnostics.map(diagnostic =>
|
||||
translateBibSourceDiagnostic(diagnostic, t)
|
||||
)
|
||||
const syntaxErrorDescription = (
|
||||
<>
|
||||
<strong>
|
||||
{plural(t, syntaxErrorMessages.length, 'entry_has_syntax_error', 'entry_has_syntax_errors')}:
|
||||
</strong>
|
||||
<ul>
|
||||
{syntaxErrorMessages.map((message, index) => (
|
||||
<li key={`${index}-${message}`}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
const duplicateKeyMessage = t('bibtex_duplicates_keys')
|
||||
|
||||
const citationHeading = useMemo(
|
||||
() => humanizeCitationHeading(bibEntry),
|
||||
[bibEntry]
|
||||
)
|
||||
const year = bibEntry.getYear().trim()
|
||||
const yearSuffix = year ? ` (${year})` : ''
|
||||
const citationAuthor =
|
||||
yearSuffix && citationHeading.endsWith(yearSuffix)
|
||||
? citationHeading.slice(0, -yearSuffix.length)
|
||||
: citationHeading
|
||||
const hasCitationPerson = citationAuthorSurnames(bibEntry).surnames.length > 0
|
||||
|
||||
return (
|
||||
<div className="bibtex-entry-card-content">
|
||||
<div className="bibtex-entry-card-header">
|
||||
<div className="bibtex-entry-card-key">
|
||||
<Highlighted text={displayKey} search={search} />
|
||||
</div>
|
||||
{syntaxErrorMessages.length > 0 && (
|
||||
<EntryStatusTooltip
|
||||
id={`tooltip-entry-syntax-errors-${rowId}`}
|
||||
description={syntaxErrorDescription}
|
||||
ariaLabel={syntaxErrorMessages.join(' + ')}
|
||||
icon="error"
|
||||
/>
|
||||
)}
|
||||
{duplicate && (
|
||||
<EntryStatusTooltip
|
||||
id={`tooltip-duplicate-key-${rowId}`}
|
||||
description={duplicateKeyMessage}
|
||||
ariaLabel={duplicateKeyMessage}
|
||||
icon="warning"
|
||||
/>
|
||||
)}
|
||||
{hasWarnings && (
|
||||
<EntryStatusTooltip
|
||||
id={`tooltip-missed-field-${rowId}`}
|
||||
description={warningDescription}
|
||||
ariaLabel={t('entry_has_warnings')}
|
||||
icon="warning"
|
||||
className="bibtex-entry-error-icon bibtex-entry-error-icon-warning"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="bibtex-entry-card-details">
|
||||
{title && (
|
||||
<div className="bibtex-entry-card-title">
|
||||
<span>
|
||||
<Highlighted text={title} search={search} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bibtex-entry-card-meta">
|
||||
{hasCitationPerson && (
|
||||
<div className="bibtex-entry-card-author">
|
||||
<span>
|
||||
<Highlighted text={citationAuthor} search={search} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{year && (
|
||||
<div className="bibtex-entry-card-year">
|
||||
<span>
|
||||
<Highlighted text={year} search={search} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function BibEntryCard({
|
||||
entry,
|
||||
displayKey,
|
||||
onSelect,
|
||||
previewing,
|
||||
checked,
|
||||
onToggleSelect,
|
||||
search,
|
||||
rowId,
|
||||
duplicate,
|
||||
}: {
|
||||
entry: PositionedBibtexEntry
|
||||
displayKey: string
|
||||
onSelect: (entry: PositionedBibtexEntry) => void
|
||||
previewing: boolean
|
||||
checked: boolean
|
||||
onToggleSelect?: (id: string) => void
|
||||
/** Raw search text used to highlight matching pieces of the card. */
|
||||
search: string
|
||||
rowId: string
|
||||
/** True when another entry has the same citation key. */
|
||||
duplicate?: boolean
|
||||
}) {
|
||||
const t = useTranslation().t
|
||||
const bibEntry = entry.entry
|
||||
// Cache labels for both the row status and its memoized warning tooltip.
|
||||
const missingFields = useMemo(
|
||||
() => missingRequiredLabels(bibEntry, t('or')),
|
||||
[bibEntry, t]
|
||||
)
|
||||
|
||||
const handleCardKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const target = e.target as Element | null
|
||||
if (target?.closest('button, input, label')) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect(entry)
|
||||
}
|
||||
},
|
||||
[entry, onSelect]
|
||||
)
|
||||
|
||||
const toggleId = rowId
|
||||
const checkboxId = useId()
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`bibtex-entry-card-${toggleId}`}
|
||||
className={[
|
||||
'bibtex-entry-card',
|
||||
'bibtex-entry-card-compact',
|
||||
'bibtex-entry-card-clickable',
|
||||
checked ? 'bibtex-entry-card-selected' : '',
|
||||
missingFields.length > 0 ? 'bibtex-entry-card-errors' : '',
|
||||
previewing ? 'bibtex-entry-card-previewing' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={e => {
|
||||
const target = e.target as Element | null
|
||||
if (target?.closest('input, label')) return
|
||||
onSelect(entry)
|
||||
}}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
>
|
||||
<div className="wide-target-checkbox bibtex-entry-card-checkbox">
|
||||
<span className="wide-target-checkbox-box">
|
||||
<OLFormCheckbox
|
||||
id={checkboxId}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={() => onToggleSelect?.(toggleId)}
|
||||
checked={checked}
|
||||
autoComplete="off"
|
||||
aria-label={t('select_entry')}
|
||||
/>
|
||||
<label
|
||||
className="wide-target-checkbox-target"
|
||||
htmlFor={checkboxId}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<BibEntryCardContent
|
||||
bibEntry={bibEntry}
|
||||
displayKey={displayKey}
|
||||
missingFields={missingFields}
|
||||
diagnostics={getBibEntryDiagnostics(entry)}
|
||||
search={search}
|
||||
rowId={rowId}
|
||||
duplicate={duplicate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BibEntryCard)
|
||||
@@ -0,0 +1,35 @@
|
||||
type BibEntryFeedbackProps = {
|
||||
message: string
|
||||
variant?: 'error' | 'warning'
|
||||
}
|
||||
|
||||
const variantOptions = {
|
||||
error: {
|
||||
className: 'text-danger',
|
||||
icon: 'error',
|
||||
},
|
||||
warning: {
|
||||
className: 'text-warning',
|
||||
icon: 'warning',
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function BibEntryFeedback({
|
||||
message,
|
||||
variant = 'error',
|
||||
}: BibEntryFeedbackProps) {
|
||||
const { className, icon } = variantOptions[variant]
|
||||
|
||||
return (
|
||||
<div className="invalid-feedback">
|
||||
<small className={`${className} form-text`}>
|
||||
<span className="form-text-inner">
|
||||
<span className="material-symbols" aria-hidden="true" translate="no">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{message}</span>
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/** Entry-type and optional-field pickers, independent of draft and save state. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BIBTEX_IDENTIFIER_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
|
||||
import { getAllEntryTypes } from '@shared/bibtex/entry-types.mts'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLAutocomplete from '@/shared/components/ol/ol-autocomplete'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { NON_BIBLIOGRAPHY_ENTRY_TYPES } from '../utils/bib-constants'
|
||||
import { OPTIONAL_FIELD_TAXONOMY } from '../utils/overleaf-type-map.ts'
|
||||
import { fieldGroupLabel } from '../utils/bib-field-labels'
|
||||
|
||||
export const displayCustomType = (type: string) =>
|
||||
type.charAt(0).toUpperCase() + type.slice(1)
|
||||
|
||||
/** Keep a fixed-position combobox aligned with its trigger while open. */
|
||||
function useTrackMenuPosition(
|
||||
open: boolean,
|
||||
updatePosition: () => void,
|
||||
menuRef?: RefObject<HTMLDivElement | null>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onScroll = (event: Event) => {
|
||||
// Scrolling the dropdown itself should not reposition its trigger.
|
||||
if (event.target instanceof Node && menuRef?.current?.contains(event.target)) return
|
||||
updatePosition()
|
||||
}
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
}
|
||||
}, [open, updatePosition, menuRef])
|
||||
}
|
||||
|
||||
/** Searchable entry-type picker using the same autocomplete as Add field. */
|
||||
export function BibEntryTypeCombobox({
|
||||
id,
|
||||
selected,
|
||||
currentType,
|
||||
entryTypes,
|
||||
onSelect,
|
||||
}: {
|
||||
id: string
|
||||
selected: string
|
||||
currentType: string
|
||||
entryTypes: ReturnType<typeof getAllEntryTypes>
|
||||
onSelect: (type: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [position, setPosition] = useState<{
|
||||
left: number
|
||||
width: number
|
||||
top?: number
|
||||
bottom?: number
|
||||
} | null>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const expandUp = spaceBelow < 360 && rect.top > spaceBelow
|
||||
setPosition({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
...(expandUp
|
||||
? { bottom: window.innerHeight - rect.top + 4 }
|
||||
: { top: rect.bottom + 4 }),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setPosition(null)
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open && position) inputRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
useTrackMenuPosition(open, updatePosition, menuRef)
|
||||
|
||||
const select = useCallback((name: string) => {
|
||||
const type = name.trim().toLowerCase()
|
||||
if (!BIBTEX_IDENTIFIER_REGEX.test(type)) return
|
||||
if (NON_BIBLIOGRAPHY_ENTRY_TYPES.has(type)) return
|
||||
onSelect(type)
|
||||
close()
|
||||
}, [close, onSelect])
|
||||
|
||||
const canCreate = useCallback((name: string) => {
|
||||
const type = name.trim().toLowerCase()
|
||||
return BIBTEX_IDENTIFIER_REGEX.test(type) &&
|
||||
!NON_BIBLIOGRAPHY_ENTRY_TYPES.has(type) &&
|
||||
type !== currentType &&
|
||||
!entryTypes.some(entryType => entryType.key === type)
|
||||
}, [currentType, entryTypes])
|
||||
|
||||
return (
|
||||
<div
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
close()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
id={id}
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="form-control text-start d-flex justify-content-between align-items-center w-100 entry-type-selector-btn"
|
||||
onClick={() => {
|
||||
if (open) close()
|
||||
else {
|
||||
updatePosition()
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{selected || t('select')}</span>
|
||||
<MaterialIcon type="keyboard_arrow_down" />
|
||||
</button>
|
||||
{open && position && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`button-autocomplete-container position-fixed rounded-1 shadow p-2${position.bottom !== undefined ? ' expand-up' : ''}`}
|
||||
style={position}
|
||||
>
|
||||
<OLAutocomplete
|
||||
items={[
|
||||
...entryTypes.map(entryType => ({
|
||||
value: entryType.key,
|
||||
label: entryType.label,
|
||||
})),
|
||||
...(currentType && !entryTypes.some(entryType => entryType.key === currentType)
|
||||
? [{ value: currentType, label: displayCustomType(currentType) }]
|
||||
: []),
|
||||
]}
|
||||
onChange={select}
|
||||
placeholder={t('enter_type')}
|
||||
label={t('entry_type')}
|
||||
showLabel={false}
|
||||
isOpen={open}
|
||||
allowCreate={canCreate}
|
||||
createOptionPrefix="+ Create"
|
||||
expandUp={position.bottom !== undefined}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upward-opening field picker. OLAutocomplete owns filtering, keyboard navigation,
|
||||
* highlighted-item state and create-item behavior; this wrapper owns the trigger,
|
||||
* fixed viewport positioning and which fields are offered for the current entry.
|
||||
*/
|
||||
export function BibAddFieldCombobox({
|
||||
placeholder,
|
||||
offered,
|
||||
standardFields,
|
||||
existingFields,
|
||||
onSelect,
|
||||
}: {
|
||||
placeholder: string
|
||||
offered: string[]
|
||||
standardFields: string[]
|
||||
existingFields: string[]
|
||||
onSelect: (field: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
left: number
|
||||
bottom: number
|
||||
} | null>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const items = useMemo(() => {
|
||||
const byField = new Set(offered)
|
||||
return OPTIONAL_FIELD_TAXONOMY.flatMap(group =>
|
||||
group.fields
|
||||
.filter(field => byField.has(field.field))
|
||||
.map(field => ({
|
||||
value: field.field,
|
||||
label: field.label,
|
||||
group: fieldGroupLabel(group.label, key => t(key)),
|
||||
}))
|
||||
)
|
||||
}, [offered, t])
|
||||
|
||||
const standardFieldSet = useMemo(
|
||||
() => new Set(standardFields.map(field => field.toLowerCase())),
|
||||
[standardFields]
|
||||
)
|
||||
const existingFieldSet = useMemo(
|
||||
() => new Set(existingFields.map(field => field.toLowerCase())),
|
||||
[existingFields]
|
||||
)
|
||||
|
||||
const canCreate = useCallback(
|
||||
(input: string) => {
|
||||
const name = input.trim()
|
||||
if (!BIBTEX_IDENTIFIER_REGEX.test(name)) return false
|
||||
const normalized = name.toLowerCase()
|
||||
return !standardFieldSet.has(normalized) && !existingFieldSet.has(normalized)
|
||||
},
|
||||
[existingFieldSet, standardFieldSet]
|
||||
)
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) return
|
||||
|
||||
const rect = button.getBoundingClientRect()
|
||||
setMenuPosition({
|
||||
left: rect.left,
|
||||
bottom: Math.max(8, window.innerHeight - rect.top + 4),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setMenuPosition(null)
|
||||
}, [])
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
updateMenuPosition()
|
||||
setOpen(true)
|
||||
}, [updateMenuPosition])
|
||||
|
||||
useTrackMenuPosition(open, updateMenuPosition)
|
||||
|
||||
const handleSelect = useCallback((field: string) => {
|
||||
if (!field) {
|
||||
filterInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(field)
|
||||
close()
|
||||
}, [close, onSelect])
|
||||
|
||||
return (
|
||||
<div
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
close()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OLButton
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leadingIcon="add"
|
||||
className="bibtex-add-field-button"
|
||||
onClick={() => (open ? close() : openMenu())}
|
||||
>
|
||||
{t('add_field')}
|
||||
</OLButton>
|
||||
|
||||
{open && menuPosition !== null && (
|
||||
<div
|
||||
className="button-autocomplete-container position-fixed rounded-1 shadow p-2 expand-up"
|
||||
style={{
|
||||
top: 'auto',
|
||||
bottom: menuPosition.bottom,
|
||||
left: menuPosition.left,
|
||||
}}
|
||||
>
|
||||
<OLAutocomplete
|
||||
key="optional-field-autocomplete"
|
||||
items={items}
|
||||
onChange={handleSelect}
|
||||
placeholder={placeholder}
|
||||
label={t('add_optional_field')}
|
||||
showLabel={false}
|
||||
allowCreate={canCreate}
|
||||
createOptionPrefix="+ Create"
|
||||
expandUp
|
||||
isOpen={open}
|
||||
ref={filterInputRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
/**
|
||||
* Shared form for editing a parsed entry or creating a new one. It emits draft
|
||||
* changes (including temporarily invalid input); the host decides when and
|
||||
* whether to commit them to CodeMirror. Abstract uses the same field model.
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||
import { BIBTEX_IDENTIFIER_REGEX } from '@shared/bibtex/bibtex-identifier.mts'
|
||||
import { getAllEntryTypes, getEntryType } from '@shared/bibtex/entry-types.mts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormText from '@/shared/components/ol/ol-form-text'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import BibEntryFeedback from './bib-entry-feedback'
|
||||
import {
|
||||
formRowsFor,
|
||||
getFieldType,
|
||||
isNameField,
|
||||
NAME_FIELDS,
|
||||
missingRequiredFieldGroups,
|
||||
optionalVisibleFor,
|
||||
} from '../utils/bib-types'
|
||||
import {
|
||||
OPTIONAL_FIELD_NAMES,
|
||||
offeredOptionalFields,
|
||||
requiredFieldNames,
|
||||
} from '../utils/overleaf-type-map.ts'
|
||||
import {
|
||||
hasBalancedCurlyBraces,
|
||||
hasBlockingValidationErrors,
|
||||
validateEntry,
|
||||
hasUnbalancedCurlyBraces,
|
||||
isFormatWarning,
|
||||
isWritableCitationKey,
|
||||
} from '../utils/bib-validate'
|
||||
import { helperKeyForField } from '../utils/bib-form-helper'
|
||||
import { fieldLabel } from '../utils/bib-field-labels'
|
||||
import { fromBibFormString, sameBibFormValue, toBibFormString } from '../utils/bib-field-edit'
|
||||
import {
|
||||
createAuthorDraft,
|
||||
isAuthorDraftValid,
|
||||
} from '../utils/bib-author-draft'
|
||||
import type { BibAuthorDraft, BibNameDrafts } from '../utils/bib-author-draft'
|
||||
import { generateAvailableCitationKey } from '../utils/bib-key-generation'
|
||||
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
|
||||
import type {
|
||||
BibFieldWriteTarget,
|
||||
BibFormChangeHandler,
|
||||
BibHistoryUserEvent,
|
||||
} from '../context/bib-editor-context'
|
||||
|
||||
type Props = {
|
||||
/** The entry values the form starts from, including any unsaved local draft. */
|
||||
entry: BibtexEntry
|
||||
/** Invalid, source-preserving draft for each name-list field. */
|
||||
initialNameDrafts?: BibNameDrafts
|
||||
/** Authoritative parsed source value; differs from `entry` while a draft is invalid. */
|
||||
sourceEntry?: BibtexEntry
|
||||
/** Original editable spelling for multiline fields in the current source. */
|
||||
textareaSourceValues?: Record<string, string>
|
||||
kind: 'existing' | 'new'
|
||||
/** IDs of all existing entries (citation-key collision hints) */
|
||||
existingIds?: string[]
|
||||
/** Receives every draft edit, including locally retained invalid values. */
|
||||
onFormChange: BibFormChangeHandler
|
||||
/** Called when the host's primary action is pressed after validation. */
|
||||
onChecked?: (
|
||||
entry: BibtexEntry,
|
||||
kind: 'existing' | 'new'
|
||||
) => void | Promise<boolean>
|
||||
/** In previews, render Abstract in its own tab using the same field editor. */
|
||||
previewTab?: 'details' | 'abstract'
|
||||
/** Switch the preview host to its dedicated Abstract tab. */
|
||||
onSelectAbstract?: () => void
|
||||
/** Prefix for DOM ids when more than one form can be mounted. */
|
||||
idPrefix?: string
|
||||
/** Optional section shared across entries while the current BibTeX file is open. */
|
||||
optionalExpanded?: boolean
|
||||
onOptionalExpandedChange?: (expanded: boolean) => void
|
||||
/** Incremented when undo/redo requires discarding this entry's local draft. */
|
||||
draftReset?: { version: number }
|
||||
/** Disable the primary action while the host is saving. */
|
||||
submitDisabled?: boolean
|
||||
/** Supplies the modal host with the current submit action and disabled state. */
|
||||
onFooterStateChange?: (state: { disabled: boolean; submit: () => void }) => void
|
||||
}
|
||||
|
||||
import { BibEntryTypeCombobox, BibAddFieldCombobox, displayCustomType } from './bib-entry-form-comboboxes'
|
||||
import BibNameField from './bib-name-field'
|
||||
|
||||
const ENTRY_TYPES = getAllEntryTypes()
|
||||
|
||||
type SubmissionBlockers = {
|
||||
kind: Props['kind']
|
||||
submitDisabled: boolean
|
||||
unbalancedCurlyBraces: boolean
|
||||
hasPendingStructuralErrors: boolean
|
||||
validationResult: ReturnType<typeof validateEntry>
|
||||
}
|
||||
|
||||
function shouldBlockSubmission({
|
||||
kind,
|
||||
submitDisabled,
|
||||
unbalancedCurlyBraces,
|
||||
hasPendingStructuralErrors,
|
||||
validationResult,
|
||||
}: SubmissionBlockers): boolean {
|
||||
return (
|
||||
submitDisabled ||
|
||||
unbalancedCurlyBraces ||
|
||||
(kind === 'new' && (
|
||||
hasPendingStructuralErrors ||
|
||||
hasBlockingValidationErrors(validationResult)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
export default function BibEntryForm({
|
||||
entry,
|
||||
initialNameDrafts,
|
||||
sourceEntry = entry,
|
||||
textareaSourceValues,
|
||||
kind,
|
||||
existingIds = [],
|
||||
onFormChange,
|
||||
onChecked,
|
||||
submitDisabled = false,
|
||||
onFooterStateChange,
|
||||
onSelectAbstract,
|
||||
previewTab,
|
||||
idPrefix = '',
|
||||
draftReset,
|
||||
optionalExpanded: sharedOptionalExpanded,
|
||||
onOptionalExpandedChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [draggingName, setDraggingName] = useState(false)
|
||||
// This set initializes a ref; changing props must not reset validation of
|
||||
// the user's in-progress name rows. Compute it only on the first render.
|
||||
const [initiallyInvalidNameFields] = useState(() => NAME_FIELDS.filter(field =>
|
||||
!isAuthorDraftValid(
|
||||
initialNameDrafts?.[field] ??
|
||||
createAuthorDraft(toBibFormString(entry.getField(field), field))
|
||||
)
|
||||
))
|
||||
const [draft, setDraft] = useState(entry)
|
||||
const draftRef = useRef(entry)
|
||||
// BibtexFieldValue.toEditableString() uses canonical escaping (e.g. # -> ##).
|
||||
// Do not replace the user's input mid-edit: an incomplete #name can become a
|
||||
// named string once its closing # is typed. Keep raw text while the parsed
|
||||
// field value still matches what this input last produced.
|
||||
const fieldInputRef = useRef(
|
||||
new Map<string, { text: string; canonical: string }>()
|
||||
)
|
||||
const [entryTypeSelected, setEntryTypeSelected] = useState(kind !== 'new')
|
||||
const type = draft.type || 'article'
|
||||
const id = draft.key || ''
|
||||
// Previews share this state per open file; standalone forms own it locally.
|
||||
const [localOptionalExpanded, setLocalOptionalExpanded] = useState(false)
|
||||
const optionalExpanded = sharedOptionalExpanded ?? localOptionalExpanded
|
||||
const setOptionalExpanded = onOptionalExpandedChange ?? setLocalOptionalExpanded
|
||||
const [checked, setChecked] = useState(false)
|
||||
// Optional controls remain mounted after their value is cleared. They disappear
|
||||
// only when this form instance is recreated (for example, after reopening).
|
||||
const [visibleOptionals, setVisibleOptionals] = useState<string[]>(() =>
|
||||
optionalVisibleFor(type, entry)
|
||||
)
|
||||
const [removedOptionals, setRemovedOptionals] = useState<string[]>([])
|
||||
// Each name-list row is validated separately: concatenating two malformed
|
||||
// rows could otherwise accidentally produce balanced braces.
|
||||
const pendingInvalidFieldsRef = useRef(new Set<string>(initiallyInvalidNameFields))
|
||||
const nameDraftValid = (field: string) =>
|
||||
!pendingInvalidFieldsRef.current.has(field)
|
||||
|
||||
// `draft` is the form's editable projection, not a second document. CodeMirror
|
||||
// remains authoritative. A local draft can temporarily
|
||||
// contain an invalid key or unmatched braces while the user is typing, but such
|
||||
// a draft must not be serialized over the original source because doing so would
|
||||
// discard text the visual parser could not represent.
|
||||
const reportDraft = useCallback(
|
||||
(
|
||||
next: BibtexEntry,
|
||||
historyUserEvent: BibHistoryUserEvent = 'input.type',
|
||||
fieldName?: string,
|
||||
fieldIsValid = true,
|
||||
fieldTarget?: BibFieldWriteTarget,
|
||||
nameDraft?: BibAuthorDraft
|
||||
) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
|
||||
// Validate the edited target separately from the whole draft: a field edit
|
||||
// is allowed while the citation key or another field is temporarily invalid.
|
||||
const citationKeyIsWritable = isWritableCitationKey(next.key)
|
||||
const draftHasUnbalancedBraces = hasUnbalancedCurlyBraces(next)
|
||||
|
||||
// Keep invalid state keyed by a stable field name so it can be merged back
|
||||
// into the parsed source entry when CodeMirror updates behind this form.
|
||||
let fieldKey = fieldName ?? 'entry'
|
||||
let targetValueIsValid: boolean
|
||||
switch (fieldTarget?.kind) {
|
||||
case 'field':
|
||||
fieldKey = fieldTarget.name
|
||||
targetValueIsValid = hasBalancedCurlyBraces(
|
||||
next.getField(fieldTarget.name).toEditableString()
|
||||
)
|
||||
break
|
||||
case 'citation-key':
|
||||
fieldKey = 'id'
|
||||
targetValueIsValid = citationKeyIsWritable
|
||||
break
|
||||
case 'entry-type':
|
||||
fieldKey = 'entry-type'
|
||||
targetValueIsValid = true
|
||||
break
|
||||
default:
|
||||
targetValueIsValid = citationKeyIsWritable && !draftHasUnbalancedBraces
|
||||
}
|
||||
const targetIsValid = fieldIsValid && targetValueIsValid
|
||||
|
||||
if (targetIsValid) pendingInvalidFieldsRef.current.delete(fieldKey)
|
||||
else pendingInvalidFieldsRef.current.add(fieldKey)
|
||||
|
||||
// The draft is safe to materialize when its key is either empty or valid,
|
||||
// braces are balanced, and no structurally invalid field edits remain.
|
||||
const draftIsValid =
|
||||
citationKeyIsWritable &&
|
||||
!draftHasUnbalancedBraces &&
|
||||
pendingInvalidFieldsRef.current.size === 0
|
||||
|
||||
// Keep the entire draft in the parent even when it cannot yet be written
|
||||
// to CodeMirror. The parent uses the target validity to gate this source
|
||||
// mutation while retaining the full draft across remounts.
|
||||
onFormChange(
|
||||
next,
|
||||
historyUserEvent,
|
||||
targetIsValid,
|
||||
fieldTarget,
|
||||
draftIsValid,
|
||||
nameDraft
|
||||
)
|
||||
},
|
||||
[onFormChange]
|
||||
)
|
||||
|
||||
// The preview remounts per entry identity, so selecting another entry
|
||||
// creates a fresh form instance. Within one occurrence, the
|
||||
// parsed entry remains the source of truth: if the document changes behind
|
||||
// the form and the local draft no longer matches it, restore the parsed entry.
|
||||
const parsedSignatureRef = useRef(entry.toString())
|
||||
const draftResetVersionRef = useRef(draftReset?.version ?? 0)
|
||||
useEffect(() => {
|
||||
const nextSignature = entry.toString()
|
||||
const parsedChanged = nextSignature !== parsedSignatureRef.current
|
||||
const resetDraft =
|
||||
draftReset != null && draftReset.version !== draftResetVersionRef.current
|
||||
parsedSignatureRef.current = nextSignature
|
||||
if (draftReset) draftResetVersionRef.current = draftReset.version
|
||||
if (!parsedChanged && !resetDraft) return
|
||||
|
||||
// Invalid local values were never written to CodeMirror, so they cannot be
|
||||
// represented in its undo history. On undo/redo, reset this form to the
|
||||
// authoritative source entry instead of letting those values mask history.
|
||||
if (resetDraft) {
|
||||
pendingInvalidFieldsRef.current.clear()
|
||||
fieldInputRef.current.clear()
|
||||
draftRef.current = entry
|
||||
setDraft(entry)
|
||||
setChecked(false)
|
||||
setRemovedOptionals([])
|
||||
return
|
||||
}
|
||||
|
||||
// A source edit from outside this form takes precedence over locally
|
||||
// preserved input spelling. Local writes retain the same canonical value.
|
||||
for (const [field, input] of fieldInputRef.current) {
|
||||
if (!sameBibFormValue(input.canonical, toBibFormString(entry.getField(field), field))) {
|
||||
fieldInputRef.current.delete(field)
|
||||
}
|
||||
}
|
||||
|
||||
if (draftRef.current.toString() === nextSignature) {
|
||||
return
|
||||
}
|
||||
|
||||
let merged = entry
|
||||
const localDraft = draftRef.current
|
||||
for (const field of pendingInvalidFieldsRef.current) {
|
||||
if (field === 'id') {
|
||||
merged = merged.setKey(localDraft.key)
|
||||
} else if (field === 'entry-type') {
|
||||
merged = merged.setType(localDraft.type)
|
||||
} else if (field !== 'entry' && localDraft.hasField(field)) {
|
||||
merged = merged.setField(field, localDraft.getField(field))
|
||||
} else if (field !== 'entry') {
|
||||
merged = merged.deleteField(field)
|
||||
}
|
||||
}
|
||||
draftRef.current = merged
|
||||
setDraft(merged)
|
||||
setChecked(false)
|
||||
// Intentionally exclude `draft`: it is local form state and must not make
|
||||
// this synchronization effect run after every keystroke.
|
||||
}, [entry, sourceEntry, kind, draftReset])
|
||||
|
||||
const entryTypeDef = getEntryType(type.toLowerCase())
|
||||
|
||||
// Pure validation feeds live format warnings in the preview and submit-time
|
||||
// errors in the manual-entry modal. Required-field stars are independent.
|
||||
const starMembers = new Set(missingRequiredFieldGroups(draft).flat())
|
||||
|
||||
const validationResult = useMemo(() => validateEntry(draft), [draft])
|
||||
const sourceValidationResult = useMemo(() => validateEntry(sourceEntry), [sourceEntry])
|
||||
const checkResult = checked ? validationResult : null
|
||||
|
||||
const unbalancedCurlyBraces =
|
||||
hasUnbalancedCurlyBraces(draft) ||
|
||||
NAME_FIELDS.some(field => !nameDraftValid(field))
|
||||
|
||||
// Preserve the order defined by the selected shared entry type.
|
||||
const fieldRows = useMemo(() => formRowsFor(type), [type])
|
||||
|
||||
// Source updates may add fields while the form stays mounted. Merge those
|
||||
// into the openable set without closing optionals the user previously added.
|
||||
useEffect(() => {
|
||||
const currentlyPopulated = optionalVisibleFor(type, entry).filter(
|
||||
field => !removedOptionals.includes(field)
|
||||
)
|
||||
if (currentlyPopulated.length === 0) return
|
||||
setVisibleOptionals(previous => {
|
||||
const next = [...previous]
|
||||
for (const field of currentlyPopulated) {
|
||||
if (!next.includes(field)) next.push(field)
|
||||
}
|
||||
return next.length === previous.length ? previous : next
|
||||
})
|
||||
}, [type, entry, removedOptionals])
|
||||
|
||||
const optionalRows = useMemo(
|
||||
() => optionalVisibleFor(type, draft, visibleOptionals),
|
||||
[type, draft, visibleOptionals]
|
||||
)
|
||||
const pendingOptionalFocusRef = useRef<string | null>(null)
|
||||
|
||||
// The input for an added optional field exists only after React renders it.
|
||||
useEffect(() => {
|
||||
const field = pendingOptionalFocusRef.current
|
||||
if (!field || !optionalRows.includes(field)) return
|
||||
|
||||
pendingOptionalFocusRef.current = null
|
||||
const raf = requestAnimationFrame(() => {
|
||||
document.getElementById(`${idPrefix}${field}`)?.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [optionalRows])
|
||||
|
||||
// Expand Optional to expose structural errors or format warnings. A syntax
|
||||
// error takes precedence over a format warning in the same field.
|
||||
const hasInvalidOptionalField = optionalRows.some(fieldName => {
|
||||
if (!hasBalancedCurlyBraces(toBibFormString(draft.getField(fieldName), fieldName))) {
|
||||
return true
|
||||
}
|
||||
const sourceMessage = !draft.hasField(fieldName)
|
||||
? sourceValidationResult.byField[fieldName]
|
||||
: undefined
|
||||
return isFormatWarning(validationResult.byField[fieldName] ?? sourceMessage)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInvalidOptionalField) setOptionalExpanded(true)
|
||||
}, [hasInvalidOptionalField])
|
||||
|
||||
const offeredFields = useMemo(
|
||||
() =>
|
||||
offeredOptionalFields(entryTypeDef).filter(
|
||||
field =>
|
||||
!optionalRows.includes(field) &&
|
||||
!fieldRows.includes(field)
|
||||
),
|
||||
[entryTypeDef, optionalRows, fieldRows]
|
||||
)
|
||||
|
||||
const standardFieldNames = useMemo(() => {
|
||||
const names = new Set<string>([
|
||||
...fieldRows,
|
||||
...OPTIONAL_FIELD_NAMES,
|
||||
...requiredFieldNames(entryTypeDef),
|
||||
])
|
||||
return Array.from(names)
|
||||
}, [fieldRows, entryTypeDef.requiredFields])
|
||||
|
||||
const existingFieldNames = useMemo(
|
||||
() => draft.getFieldsArray().map(({ key }) => key),
|
||||
[draft]
|
||||
)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(
|
||||
name: string,
|
||||
value: string,
|
||||
historyUserEvent: BibHistoryUserEvent = 'input.type',
|
||||
fieldIsValid = true,
|
||||
nameDraft?: BibAuthorDraft
|
||||
) => {
|
||||
const current = draftRef.current
|
||||
const next = (getFieldType(name) === 'large' ? value !== '' : value.trim() !== '')
|
||||
? current.setField(
|
||||
name,
|
||||
fromBibFormString(value, name, current.getField(name))
|
||||
)
|
||||
: current.deleteField(name)
|
||||
// Validate the exact input text before conversion to BibtexFieldValue.
|
||||
// The editable and serialized representations can differ, so checking
|
||||
// only the converted value can miss malformed braces.
|
||||
// Name-list controls validate individual rows themselves.
|
||||
const isName = isNameField(name)
|
||||
const rawValueIsValid =
|
||||
fieldIsValid && (isName || hasBalancedCurlyBraces(value))
|
||||
if (!isName) {
|
||||
const canonical = toBibFormString(next.getField(name), name)
|
||||
if (canonical === value) fieldInputRef.current.delete(name)
|
||||
else fieldInputRef.current.set(name, { text: value, canonical })
|
||||
}
|
||||
reportDraft(
|
||||
next, historyUserEvent, name, rawValueIsValid,
|
||||
{ kind: 'field', name }, nameDraft
|
||||
)
|
||||
},
|
||||
[reportDraft]
|
||||
)
|
||||
|
||||
const handleGenerateKey = useCallback((focusAfter: boolean) => {
|
||||
const current = draftRef.current
|
||||
// Existing entries may reuse their own source key; new entries may not.
|
||||
const key = generateAvailableCitationKey(
|
||||
current, existingIds, kind === 'new' ? undefined : sourceEntry.key
|
||||
)
|
||||
reportDraft(current.setKey(key), 'input.complete', 'id', true, { kind: 'citation-key' })
|
||||
if (focusAfter) {
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(`${idPrefix}ref-key`)?.focus()
|
||||
})
|
||||
}
|
||||
}, [existingIds, idPrefix, kind, reportDraft, sourceEntry.key])
|
||||
|
||||
const hasPendingStructuralErrors = pendingInvalidFieldsRef.current.size > 0
|
||||
const submissionBlocked =
|
||||
(kind === 'new' && !entryTypeSelected) ||
|
||||
shouldBlockSubmission({
|
||||
kind,
|
||||
submitDisabled,
|
||||
unbalancedCurlyBraces,
|
||||
hasPendingStructuralErrors,
|
||||
validationResult,
|
||||
})
|
||||
|
||||
const handleCheck = useCallback(() => {
|
||||
const current = draftRef.current
|
||||
setChecked(true)
|
||||
|
||||
// Use the same blockers as the disabled Save button. Format warnings do
|
||||
// not block submission; the host generates a key if the new entry has none.
|
||||
const blocked =
|
||||
(kind === 'new' && !entryTypeSelected) ||
|
||||
shouldBlockSubmission({
|
||||
kind,
|
||||
submitDisabled,
|
||||
unbalancedCurlyBraces:
|
||||
hasUnbalancedCurlyBraces(current) ||
|
||||
NAME_FIELDS.some(field => pendingInvalidFieldsRef.current.has(field)),
|
||||
hasPendingStructuralErrors: pendingInvalidFieldsRef.current.size > 0,
|
||||
validationResult: validateEntry(current),
|
||||
})
|
||||
if (blocked) return Promise.resolve(false)
|
||||
|
||||
return Promise.resolve(onChecked?.(current, kind)).then(result =>
|
||||
result === undefined ? true : result
|
||||
)
|
||||
}, [entryTypeSelected, kind, onChecked, submitDisabled])
|
||||
|
||||
// The modal footer is outside this form. Publish the current submit action
|
||||
// only after validation or the save-disabled state changes.
|
||||
useEffect(() => {
|
||||
onFooterStateChange?.({
|
||||
disabled: submissionBlocked,
|
||||
submit: () => { void handleCheck() },
|
||||
})
|
||||
}, [handleCheck, onFooterStateChange, submissionBlocked])
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(name: string) => {
|
||||
const nextEntry = draftRef.current.setType(name)
|
||||
reportDraft(nextEntry, 'input.complete', 'type', true, { kind: 'entry-type' })
|
||||
setEntryTypeSelected(true)
|
||||
setChecked(false)
|
||||
setVisibleOptionals([])
|
||||
},
|
||||
[reportDraft]
|
||||
)
|
||||
|
||||
const handleAddOptional = useCallback((field: string) => {
|
||||
const normalized = field.trim().toLowerCase()
|
||||
if (!BIBTEX_IDENTIFIER_REGEX.test(normalized)) return
|
||||
|
||||
setRemovedOptionals(previous => previous.filter(name => name !== normalized))
|
||||
pendingOptionalFocusRef.current = normalized
|
||||
setVisibleOptionals(prev =>
|
||||
prev.includes(normalized) ? prev : [...prev, normalized]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleRemoveOptional = useCallback((field: string) => {
|
||||
fieldInputRef.current.delete(field)
|
||||
setRemovedOptionals(previous =>
|
||||
previous.includes(field) ? previous : [...previous, field]
|
||||
)
|
||||
setVisibleOptionals(previous => previous.filter(name => name !== field))
|
||||
reportDraft(
|
||||
draftRef.current.deleteField(field),
|
||||
'delete',
|
||||
field,
|
||||
true,
|
||||
{ kind: 'field', name: field }
|
||||
)
|
||||
}, [reportDraft])
|
||||
|
||||
const selectedType = ENTRY_TYPES.find(et => et.key === type)
|
||||
|
||||
const validationMessageText = (
|
||||
fieldName: string,
|
||||
msg: ReturnType<typeof validateEntry>['byField'][string]
|
||||
): string | null => {
|
||||
switch (msg.kind) {
|
||||
case 'required-missing': {
|
||||
if (msg.group && msg.labelFields.length > 1) {
|
||||
const labels = msg.labelFields.map(field => fieldLabel(field))
|
||||
return t('either_a_or_b_is_required', {
|
||||
a: labels.slice(0, -1).join(', ') + (labels.length > 2 ? ',' : ''),
|
||||
b: labels[labels.length - 1],
|
||||
})
|
||||
}
|
||||
return t('field_is_required', { a: fieldLabel(fieldName) })
|
||||
}
|
||||
case 'id-invalid':
|
||||
return t('citation_key_contains_invalid_characters')
|
||||
case 'year-format':
|
||||
return t('year_format_hint')
|
||||
case 'date-format':
|
||||
return t('date_format_hint')
|
||||
case 'doi-format':
|
||||
return t('doi_format_hint')
|
||||
case 'url-invalid':
|
||||
return t('url_format_hint')
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid citation-key characters are an immediate field error, like
|
||||
// unbalanced braces.
|
||||
const invalidCitationKeyMessage =
|
||||
validationResult.byField.id?.kind === 'id-invalid'
|
||||
? validationResult.byField.id
|
||||
: undefined
|
||||
const keyMessage =
|
||||
invalidCitationKeyMessage ??
|
||||
(kind === 'new' ? checkResult?.byField.id : undefined)
|
||||
const citationKeyMessage = keyMessage
|
||||
? validationMessageText('id', keyMessage)
|
||||
: null
|
||||
|
||||
// Format warnings are live in both hosts. The modal reveals missing-field
|
||||
// messages after a submit attempt, while invalid citation-key characters are
|
||||
// reported immediately.
|
||||
// standalone missing → "<Label> is required"
|
||||
// OR-group missing → "Either A or B is required" (one per empty member)
|
||||
const messageFor = (fieldName: string): string | null => {
|
||||
// Syntax errors take priority only within their own field. Citation keys
|
||||
// are validated as identifiers and must never receive brace/backslash errors.
|
||||
const fieldHasSyntaxError =
|
||||
isNameField(fieldName)
|
||||
? !nameDraftValid(fieldName)
|
||||
: !hasBalancedCurlyBraces(
|
||||
draft.getField(fieldName).toEditableString()
|
||||
)
|
||||
if (fieldHasSyntaxError) return null
|
||||
|
||||
let msg: ReturnType<typeof validateEntry>['byField'][string] | undefined
|
||||
if (kind === 'existing') {
|
||||
msg = validationResult.byField[fieldName]
|
||||
// Keep a source warning visible if deleting a field was rejected.
|
||||
if (!msg && !draft.hasField(fieldName)) {
|
||||
msg = sourceValidationResult.byField[fieldName]
|
||||
}
|
||||
if (msg?.kind !== 'required-missing' && !isFormatWarning(msg)) return null
|
||||
} else {
|
||||
msg = validationResult.byField[fieldName]
|
||||
if (!checkResult && !isFormatWarning(msg)) return null
|
||||
}
|
||||
if (!msg) {
|
||||
return null
|
||||
}
|
||||
return validationMessageText(fieldName, msg)
|
||||
}
|
||||
|
||||
// Helper text is deliberately centralized in bib-form-helper.ts. Some fields
|
||||
// have no helper at all; in particular, Author/Editor stay visually quiet.
|
||||
const rowHelper = (fieldName: string): string | null => {
|
||||
const key = helperKeyForField(fieldName)
|
||||
return key ? t(key) : null
|
||||
}
|
||||
|
||||
// Focus structural field errors before citation-key problems, then other
|
||||
// validation warnings. Clean existing entries must not steal focus.
|
||||
const focusOnceRef = useRef(false)
|
||||
useLayoutEffect(() => {
|
||||
if (focusOnceRef.current || (kind === 'new' && !entryTypeSelected)) return
|
||||
|
||||
const fields = [...fieldRows, ...optionalRows]
|
||||
const firstStructuralField = fields.find(fieldName =>
|
||||
isNameField(fieldName)
|
||||
? !nameDraftValid(fieldName)
|
||||
: !hasBalancedCurlyBraces(
|
||||
toBibFormString(draft.getField(fieldName), fieldName)
|
||||
)
|
||||
)
|
||||
const key = sourceEntry.key.trim()
|
||||
const duplicateKey =
|
||||
kind === 'existing' &&
|
||||
key !== '' &&
|
||||
existingIds.filter(existingKey => existingKey.trim() === key).length > 1
|
||||
const keyNeedsFocus = duplicateKey || validationResult.byField.id != null
|
||||
const firstWrongField =
|
||||
firstStructuralField ??
|
||||
(keyNeedsFocus
|
||||
? undefined
|
||||
: fields.find(fieldName =>
|
||||
validationResult.byField[fieldName] != null ||
|
||||
(!draft.hasField(fieldName) &&
|
||||
sourceValidationResult.byField[fieldName] != null)
|
||||
))
|
||||
|
||||
if (firstWrongField && optionalRows.includes(firstWrongField) && !optionalExpanded) {
|
||||
setOptionalExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
const targetId = firstWrongField
|
||||
? `${idPrefix}${firstWrongField}`
|
||||
: keyNeedsFocus || kind === 'new'
|
||||
? `${idPrefix}ref-key`
|
||||
: null
|
||||
|
||||
// Focus is decided only once per form instance. Later edits must not
|
||||
// move focus away from the field the user is editing.
|
||||
focusOnceRef.current = true
|
||||
if (targetId) document.getElementById(targetId)?.focus()
|
||||
}, [
|
||||
draft,
|
||||
entryTypeSelected,
|
||||
fieldRows,
|
||||
idPrefix,
|
||||
existingIds,
|
||||
optionalExpanded,
|
||||
optionalRows,
|
||||
sourceEntry.key,
|
||||
sourceValidationResult,
|
||||
validationResult,
|
||||
kind,
|
||||
])
|
||||
|
||||
// Ordinary fields show their own brace warning. Author and Editor validate
|
||||
// their name inputs individually, so they render feedback inside the control.
|
||||
const renderField = (fieldName: string) => {
|
||||
const isAbstractPreview = previewTab !== undefined && fieldName === 'abstract'
|
||||
const isStarred = starMembers.has(fieldName)
|
||||
const isOptional = optionalRows.includes(fieldName)
|
||||
const message = messageFor(fieldName)
|
||||
const helper = rowHelper(fieldName)
|
||||
const canonical = toBibFormString(draft.getField(fieldName), fieldName)
|
||||
const typeOfField = getFieldType(fieldName)
|
||||
const input = fieldInputRef.current.get(fieldName)
|
||||
const value = input && sameBibFormValue(input.canonical, canonical)
|
||||
? input.text
|
||||
: typeOfField === 'large' &&
|
||||
sourceEntry.getField(fieldName).toEditableString() === canonical
|
||||
? textareaSourceValues?.[fieldName] ?? canonical
|
||||
: canonical
|
||||
const unbalanced = !draggingName && !hasBalancedCurlyBraces(value)
|
||||
const braceFeedback = typeOfField !== 'name' && unbalanced && (
|
||||
<BibEntryFeedback message={t('curly_braces_must_be_balanced')} />
|
||||
)
|
||||
const messageFeedback = message && (
|
||||
<BibEntryFeedback message={message} variant="warning" />
|
||||
)
|
||||
const fieldControl = typeOfField === 'name' ? (
|
||||
<BibNameField
|
||||
key={draftReset?.version ?? 0}
|
||||
fieldName={fieldName}
|
||||
value={value}
|
||||
initialDraft={initialNameDrafts?.[fieldName]}
|
||||
onChange={(val, historyUserEvent, fieldIsValid, nameDraft) =>
|
||||
handleFieldChange(fieldName, val, historyUserEvent, fieldIsValid, nameDraft)
|
||||
}
|
||||
error={!!message}
|
||||
hideBraceErrors={draggingName}
|
||||
onDraggingChange={setDraggingName}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
) : typeOfField === 'large' ? (
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
id={`${idPrefix}${fieldName}`}
|
||||
className={`${isAbstractPreview ? '' : 'bibtex-auto-textarea '}${isOptional ? 'bibtex-form-input ' : ''}${
|
||||
fieldName === 'abstract' ? 'bibtex-abstract-textarea' : ''
|
||||
} ${message || unbalanced ? 'is-invalid' : ''}`}
|
||||
maxLength={fieldName === 'abstract' ? '8192' : '4096'}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={e => handleFieldChange(fieldName, e.target.value)}
|
||||
rows={isAbstractPreview ? 18 : 1}
|
||||
/>
|
||||
) : (
|
||||
<OLFormControl
|
||||
id={`${idPrefix}${fieldName}`}
|
||||
className={`${isOptional ? 'bibtex-form-input ' : ''}${
|
||||
message || unbalanced ? 'is-invalid' : ''
|
||||
}`}
|
||||
maxLength="512"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => handleFieldChange(fieldName, e.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isAbstractPreview ? 'form-group bibtex-abstract-form-group' : 'form-group'}
|
||||
key={fieldName}
|
||||
>
|
||||
<OLFormLabel
|
||||
className={isAbstractPreview ? 'visually-hidden' : undefined}
|
||||
htmlFor={`${idPrefix}${fieldName}`}
|
||||
>
|
||||
{fieldLabel(fieldName)}
|
||||
{isStarred && (
|
||||
<span className="bibtex-form-required"> *</span>
|
||||
)}
|
||||
</OLFormLabel>
|
||||
{isOptional ? (
|
||||
<div className="bibtex-field-with-actions bibtex-optional-field-row">
|
||||
{fieldControl}
|
||||
{braceFeedback}
|
||||
{messageFeedback}
|
||||
<div className="bibtex-form-actions">
|
||||
<OLIconButton
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
unfilled
|
||||
accessibilityLabel={t('remove_field', { a: fieldLabel(fieldName) })}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => handleRemoveOptional(fieldName)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{fieldControl}
|
||||
{braceFeedback}
|
||||
</>
|
||||
)}
|
||||
{helper && <OLFormText>{helper}</OLFormText>}
|
||||
{!isOptional && messageFeedback}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const detailsContent = (
|
||||
<>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor={`${idPrefix}ref-type`}>
|
||||
{t('entry_type')}
|
||||
</OLFormLabel>
|
||||
<BibEntryTypeCombobox
|
||||
id={`${idPrefix}ref-type`}
|
||||
selected={
|
||||
entryTypeSelected ? selectedType?.label ?? displayCustomType(type) : ''
|
||||
}
|
||||
currentType={entryTypeSelected ? type : ''}
|
||||
entryTypes={ENTRY_TYPES}
|
||||
onSelect={handleTypeChange}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
{kind === 'new' && !entryTypeSelected ? (
|
||||
<OLFormText>{t('select_a_type_to_see_the_required_fields')}</OLFormText>
|
||||
) : (
|
||||
<div>
|
||||
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor={`${idPrefix}ref-key`}>
|
||||
{t('citation_key')}
|
||||
</OLFormLabel>
|
||||
<div className="bibtex-field-with-actions bibtex-citation-key">
|
||||
<OLFormControl
|
||||
className={`bibtex-form-input ${
|
||||
citationKeyMessage ? 'is-invalid' : ''
|
||||
}`}
|
||||
maxLength="128"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
id={`${idPrefix}ref-key`}
|
||||
value={id}
|
||||
onChange={e => {
|
||||
// Existing Visual entries keep an empty source key until the
|
||||
// preview closes. The displayed suggestion is only a placeholder.
|
||||
const key = kind === 'new' && !e.target.value.trim()
|
||||
? generateAvailableCitationKey(draftRef.current, existingIds)
|
||||
: e.target.value
|
||||
reportDraft(
|
||||
draftRef.current.setKey(key),
|
||||
'input.type',
|
||||
'id',
|
||||
true,
|
||||
{ kind: 'citation-key' }
|
||||
)
|
||||
}}
|
||||
placeholder={id ? 'e.g. Doe0000' : generateAvailableCitationKey(
|
||||
draft, existingIds, kind === 'new' ? undefined : sourceEntry.key
|
||||
)}
|
||||
append={
|
||||
<OLTooltip id={`${idPrefix}generate-citation-key`} description={t('generate')}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}>
|
||||
<OLIconButton
|
||||
type="button"
|
||||
icon="autorenew"
|
||||
className="bibtex-generate-key"
|
||||
accessibilityLabel={t('generate')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => handleGenerateKey(true)}
|
||||
/>
|
||||
</OLTooltip>
|
||||
}
|
||||
/>
|
||||
{citationKeyMessage && (
|
||||
<BibEntryFeedback message={citationKeyMessage} />
|
||||
)}
|
||||
</div>
|
||||
<OLFormText>{t('citation_key_autogenerate_helper')}</OLFormText>
|
||||
</OLFormGroup>
|
||||
|
||||
{/* Keep the entry type's defined field order; it is not alphabetical. */}
|
||||
{fieldRows.filter(field => previewTab === undefined || field !== 'abstract').map(renderField)}
|
||||
|
||||
{/* Optional fields are hidden until requested, except validation can force the
|
||||
section open when a hidden field contains unbalanced braces. */}
|
||||
<div className="ms-2">
|
||||
<button
|
||||
type="button"
|
||||
className="bibtex-collapsible-heading"
|
||||
aria-expanded={optionalExpanded}
|
||||
aria-label={
|
||||
optionalExpanded ? t('collapse_optional') : t('expand_optional')
|
||||
}
|
||||
onClick={() => setOptionalExpanded(!optionalExpanded)}
|
||||
>
|
||||
<span className="mb-0 form-label">{t('optional')}</span>
|
||||
<span className="material-symbols bibtex-collapsible-heading-icon" aria-hidden="true" translate="no">
|
||||
{optionalExpanded ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
|
||||
</span>
|
||||
</button>
|
||||
{optionalExpanded && (
|
||||
<div className="position-relative">
|
||||
{optionalRows.filter(field => previewTab === undefined || field !== 'abstract').map(renderField)}
|
||||
<BibAddFieldCombobox
|
||||
placeholder={t('enter_field_name')}
|
||||
offered={offeredFields}
|
||||
standardFields={standardFieldNames}
|
||||
existingFields={existingFieldNames}
|
||||
onSelect={field => {
|
||||
if (field === 'abstract' && onSelectAbstract) {
|
||||
onSelectAbstract()
|
||||
return
|
||||
}
|
||||
handleAddOptional(field)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (!previewTab) return detailsContent
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
hidden={previewTab !== 'details'}
|
||||
role="tabpanel"
|
||||
id={`${idPrefix}panel-details`}
|
||||
aria-labelledby={`${idPrefix}tab-details`}
|
||||
>
|
||||
{detailsContent}
|
||||
</div>
|
||||
<div
|
||||
hidden={previewTab !== 'abstract'}
|
||||
role="tabpanel"
|
||||
id={`${idPrefix}panel-abstract`}
|
||||
aria-labelledby={`${idPrefix}tab-abstract`}
|
||||
>
|
||||
{renderField('abstract')}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Left-hand list controls: search and the Add menu. The actual entry rows are
|
||||
* rendered by BibEntryList, so this component stays independent of entry data.
|
||||
*/
|
||||
import '../../stylesheets/bib-editor-panel.css'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
OLDropdown,
|
||||
OLDropdownItem,
|
||||
OLDropdownMenu,
|
||||
OLDropdownToggle,
|
||||
} from '@/shared/components/ol/ol-dropdown-menu'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
type Props = {
|
||||
readOnly?: boolean
|
||||
search: string
|
||||
onSearchChange: (search: string) => void
|
||||
openDocName: string | null
|
||||
zoteroAvailable: boolean
|
||||
zoteroLinked: boolean
|
||||
onAddFromZotero: () => void
|
||||
onAddPaste: () => void
|
||||
onAddManual: () => void
|
||||
onAddFromOrcid: () => void
|
||||
}
|
||||
|
||||
function BibEntryListPanel({
|
||||
readOnly = false,
|
||||
search,
|
||||
onSearchChange,
|
||||
openDocName,
|
||||
onAddPaste,
|
||||
onAddManual,
|
||||
onAddFromOrcid,
|
||||
zoteroAvailable,
|
||||
zoteroLinked,
|
||||
onAddFromZotero,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="bibtex-entry-list-panel">
|
||||
<div className="bibtex-search">
|
||||
<OLFormControl
|
||||
id="bibtex-list-search"
|
||||
type="text"
|
||||
size="sm"
|
||||
value={search}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape') onSearchChange('')
|
||||
}}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder={t('search_bib_file', { fileName: openDocName || '.bib' })}
|
||||
aria-label={t('search')}
|
||||
prepend={<MaterialIcon type="search" />}
|
||||
append={
|
||||
search.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange('')}
|
||||
className="form-control-search-clear-btn"
|
||||
aria-label={t('clear_search')}
|
||||
>
|
||||
<MaterialIcon type="clear" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OLDropdown className="bibtex-add-button">
|
||||
<OLDropdownToggle
|
||||
disabled={readOnly}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="d-inline-grid custom-toggle"
|
||||
>
|
||||
<span className="button-content" aria-hidden="false">
|
||||
<span
|
||||
className="material-symbols"
|
||||
aria-hidden="true"
|
||||
translate="no"
|
||||
>
|
||||
add
|
||||
</span>
|
||||
{t('add')}
|
||||
</span>
|
||||
</OLDropdownToggle>
|
||||
|
||||
<OLDropdownMenu>
|
||||
<OLDropdownItem
|
||||
description={t('bibtex_doi_description')}
|
||||
onClick={onAddPaste}
|
||||
>
|
||||
{t('paste_references')}
|
||||
</OLDropdownItem>
|
||||
|
||||
<OLDropdownItem onClick={onAddManual}>
|
||||
{t('enter_manually')}
|
||||
</OLDropdownItem>
|
||||
|
||||
<OLDropdownItem
|
||||
description={t('search_orcid_by_name_or_id')}
|
||||
onClick={onAddFromOrcid}
|
||||
>
|
||||
{t('import_from_provider', { provider: 'ORCID.org' })}
|
||||
</OLDropdownItem>
|
||||
|
||||
{zoteroAvailable && (
|
||||
<OLDropdownItem disabled={!zoteroLinked} onClick={onAddFromZotero}>
|
||||
{t('import_from_provider', { provider: 'Zotero' })}
|
||||
</OLDropdownItem>
|
||||
)}
|
||||
|
||||
</OLDropdownMenu>
|
||||
</OLDropdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BibEntryListPanel)
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Filtered entry-list view. It derives the visible rows from the parsed source
|
||||
* list and passes each row to BibEntryCard; it does not own bibliography state.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PositionedBibtexEntry } from '../utils/bib-types'
|
||||
import BibEntryCard from './bib-entry-card'
|
||||
import { displayCitationKey } from '../utils/bib-key-generation'
|
||||
import { captureBibListPosition, restoreBibListPosition } from '../utils/bib-list-position'
|
||||
import type { BibListPosition } from '../utils/bib-list-position'
|
||||
|
||||
type Props = {
|
||||
entries: PositionedBibtexEntry[]
|
||||
existingIds: string[]
|
||||
onSelect: (entry: PositionedBibtexEntry) => void
|
||||
previewId?: string | null
|
||||
restoreKey?: string | null
|
||||
getRestorePosition?: () => BibListPosition | null
|
||||
onPositionChange?: (position: BibListPosition) => void
|
||||
selectedIds?: string[]
|
||||
onToggleSelect?: (id: string) => void
|
||||
search: string
|
||||
totalEntries?: number
|
||||
duplicateIds?: Set<string>
|
||||
}
|
||||
|
||||
function BibEntryList({
|
||||
entries,
|
||||
existingIds,
|
||||
onSelect,
|
||||
previewId = null,
|
||||
restoreKey = null,
|
||||
getRestorePosition,
|
||||
onPositionChange,
|
||||
selectedIds = [],
|
||||
onToggleSelect,
|
||||
search,
|
||||
totalEntries = entries.length,
|
||||
duplicateIds,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const restoredKeyRef = useRef<string | null>(null)
|
||||
const visibleRef = useRef(false)
|
||||
const restoreRef = useRef<() => void>(() => {})
|
||||
|
||||
// The editor can remain mounted while a binary-file tab covers it. Restore
|
||||
// only when the list is actually visible, not merely when its session loads.
|
||||
const restore = useCallback(() => {
|
||||
const list = listRef.current
|
||||
if (!restoreKey || !list || restoredKeyRef.current === restoreKey) return
|
||||
const rect = list.getBoundingClientRect()
|
||||
if (!visibleRef.current || rect.width === 0 || rect.height === 0) return
|
||||
const restorePosition = getRestorePosition?.()
|
||||
if (restorePosition && !restoreBibListPosition(list, restorePosition)) return
|
||||
restoredKeyRef.current = restoreKey
|
||||
}, [restoreKey, getRestorePosition])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoreRef.current = restore
|
||||
if (!restoreKey) restoredKeyRef.current = null
|
||||
restore()
|
||||
}, [restore, restoreKey, entries])
|
||||
|
||||
useEffect(() => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
const visible = entry.isIntersecting
|
||||
if (visible !== visibleRef.current) {
|
||||
visibleRef.current = visible
|
||||
// A tab may hide and re-show the same document without changing its
|
||||
// session key or remounting the list. It needs a fresh restoration.
|
||||
restoredKeyRef.current = null
|
||||
}
|
||||
if (visible) restoreRef.current()
|
||||
})
|
||||
observer.observe(list)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="bibtex-entry-list"
|
||||
onScroll={event => {
|
||||
if (!visibleRef.current) return
|
||||
const list = event.currentTarget
|
||||
// Capture geometry now. Approximating from a previous scrollTop gives
|
||||
// a wrong anchor when the browser adjusts scroll for resized rows.
|
||||
const position = captureBibListPosition(list)
|
||||
if (position) onPositionChange?.(position)
|
||||
}}
|
||||
>
|
||||
{entries.length === 0 && (
|
||||
<div className="bibtex-list-empty">
|
||||
{totalEntries === 0
|
||||
? t('no_bibliography_entries_yet_add_one')
|
||||
: t('no_entries_match_your_search')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bibtex-entry-list-body" role="list">
|
||||
{entries.map((entry, i) => {
|
||||
const rowId = entry.entry.id
|
||||
return (
|
||||
<div
|
||||
key={rowId}
|
||||
data-index={i}
|
||||
data-entry-id={rowId}
|
||||
className="bibtex-entry-card-row"
|
||||
role="listitem"
|
||||
>
|
||||
<BibEntryCard
|
||||
entry={entry}
|
||||
displayKey={displayCitationKey(entry.entry, existingIds)}
|
||||
onSelect={onSelect}
|
||||
previewing={previewId === rowId}
|
||||
checked={selectedIdSet.has(rowId)}
|
||||
onToggleSelect={onToggleSelect}
|
||||
search={search}
|
||||
rowId={rowId}
|
||||
duplicate={duplicateIds?.has(rowId) ?? false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BibEntryList)
|
||||
@@ -0,0 +1,337 @@
|
||||
import React, { useEffect, useId, useMemo, useState } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { plural } from '../utils/plural'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import {
|
||||
OLDropdown,
|
||||
OLDropdownMenu,
|
||||
OLDropdownItem,
|
||||
OLDropdownToggle,
|
||||
} from '@/shared/components/ol/ol-dropdown-menu'
|
||||
import BibEntryForm from './bib-entry-form'
|
||||
import type { BibProjectedEntry } from '../utils/bib-entry-position'
|
||||
import { textareaValueFromSource, toBibFormString } from '../utils/bib-field-edit'
|
||||
import { getFieldType } from '../utils/bib-types'
|
||||
import type { PositionedBibtexEntry } from '../utils/bib-types'
|
||||
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
|
||||
import {
|
||||
getBibEntryDiagnostics,
|
||||
translateBibSourceDiagnostic,
|
||||
} from '../utils/bib-entry-diagnostics'
|
||||
import type { BibFormChangeHandler } from '../context/bib-editor-context'
|
||||
import { missingRequiredLabels } from '../utils/preview-model.ts'
|
||||
import { humanizeCitationHeading } from '../utils/overleaf-type-map.ts'
|
||||
import { displayCitationKey } from '../utils/bib-key-generation'
|
||||
import type { BibNameDrafts } from '../utils/bib-author-draft'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import BibEntryWarningSummary from './bib-entry-warning-summary'
|
||||
|
||||
type BibPreviewProps = {
|
||||
/** The current parsed entries (file order — the preview walks this) */
|
||||
entries: PositionedBibtexEntry[]
|
||||
/** The previewed entry (full parsed entry, from the current parse) */
|
||||
entry: BibProjectedEntry
|
||||
/** Exact source slice for this occurrence; upstream normalizes literal newlines. */
|
||||
sourceText?: string
|
||||
/** Index into `entries` (file order) */
|
||||
previewIndex: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
/** Close the preview; existing-entry edits have already been written. */
|
||||
onClose: () => void
|
||||
/** Download callback for the currently previewed entry. */
|
||||
onDownload: () => void
|
||||
/** Single-entry delete (confirm modal in the panel) */
|
||||
onDelete: () => void
|
||||
/** Receives the reusable form's current draft so the host can commit it. */
|
||||
onFormChange: BibFormChangeHandler
|
||||
existingIds: string[]
|
||||
canDelete: boolean
|
||||
readOnly?: boolean
|
||||
draftEntry?: BibtexEntry
|
||||
pendingNameDrafts?: BibNameDrafts
|
||||
/** Incremented when undo/redo requires discarding this entry's local draft. */
|
||||
draftReset?: { version: number; entry: BibtexEntry }
|
||||
optionalExpanded?: boolean
|
||||
onOptionalExpandedChange?: (expanded: boolean) => void
|
||||
}
|
||||
|
||||
function BibEntryPreview({
|
||||
entries,
|
||||
entry,
|
||||
sourceText,
|
||||
previewIndex,
|
||||
onPrev,
|
||||
onNext,
|
||||
onClose,
|
||||
onDownload,
|
||||
onDelete,
|
||||
onFormChange,
|
||||
existingIds,
|
||||
canDelete,
|
||||
readOnly = false,
|
||||
draftEntry,
|
||||
pendingNameDrafts,
|
||||
draftReset,
|
||||
optionalExpanded,
|
||||
onOptionalExpandedChange,
|
||||
}: BibPreviewProps) {
|
||||
const { t } = useTranslation()
|
||||
const bibEntry = entry.entry
|
||||
const syntaxErrors = getBibEntryDiagnostics(entry).map(diagnostic =>
|
||||
translateBibSourceDiagnostic(diagnostic, t)
|
||||
)
|
||||
const hasSyntaxErrors = syntaxErrors.length > 0
|
||||
const idPrefix = `bib-${useId()}-`
|
||||
const abstractId = `${idPrefix}abstract`
|
||||
const textareaSourceValues = useMemo(() => {
|
||||
const result: Record<string, string> = {}
|
||||
if (sourceText === undefined) return result
|
||||
// Trusted source-relative ranges come from the existing Lezer projection.
|
||||
// Malformed entries have no trusted ranges and are not editable.
|
||||
const ranges = entry.sourceFieldRanges ?? []
|
||||
for (const range of ranges) {
|
||||
if (getFieldType(range.name) !== 'large') continue
|
||||
const text = textareaValueFromSource(
|
||||
sourceText.slice(range.valueFrom, range.valueTo),
|
||||
toBibFormString(bibEntry.getField(range.name), range.name)
|
||||
)
|
||||
if (text !== null) result[range.name] = text
|
||||
}
|
||||
return result
|
||||
}, [sourceText, entry])
|
||||
const [tab, setTab] = useState<'details' | 'abstract'>('details')
|
||||
const selectAbstract = React.useCallback(() => setTab('abstract'), [])
|
||||
|
||||
// Keep a single form mounted across tabs. Abstract uses the same change,
|
||||
// validation, draft and source-write path as every other field.
|
||||
useEffect(() => {
|
||||
if (tab !== 'abstract') return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
document.getElementById(abstractId)?.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [tab, abstractId])
|
||||
|
||||
// Short labels are derived directly from the current parsed entry.
|
||||
const summaryTitle = bibEntry.getField('title').toDisplayString().trim()
|
||||
const summaryHeading = humanizeCitationHeading(bibEntry)
|
||||
|
||||
const missing = useMemo(
|
||||
() => missingRequiredLabels(bibEntry, t('or')),
|
||||
[entry, t]
|
||||
)
|
||||
|
||||
// Handle Escape at the preview container so either tab returns to the list.
|
||||
const handleBodyKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== 'Escape') return
|
||||
const el = e.target
|
||||
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'details', label: t('details') },
|
||||
{ id: 'abstract', label: t('abstract') },
|
||||
] as const
|
||||
|
||||
// The form retains its local draft while the two tabs toggle visibility.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="bibtex-entry-preview-panel bibtex-entry-preview-panel-open"
|
||||
role="region"
|
||||
aria-label={t('edit_reference')}
|
||||
onKeyDown={handleBodyKeyDown}
|
||||
>
|
||||
<div className="bibtex-entry-preview-header">
|
||||
<div className="bibtex-entry-preview-header-nav">
|
||||
<OLIconButton
|
||||
icon="chevron_left"
|
||||
variant="ghost"
|
||||
unfilled
|
||||
aria-label={t('previous_reference')}
|
||||
disabled={previewIndex <= 0}
|
||||
onClick={onPrev}
|
||||
/>
|
||||
<OLIconButton
|
||||
icon="chevron_right"
|
||||
variant="ghost"
|
||||
unfilled
|
||||
aria-label={t('next_reference')}
|
||||
disabled={previewIndex >= entries.length - 1}
|
||||
onClick={onNext}
|
||||
/>
|
||||
{/* List collapse is controlled by the list pane, not this preview header. */}
|
||||
</div>
|
||||
<OLIconButton
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
unfilled
|
||||
aria-label={t('close')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bibtex-entry-preview-content">
|
||||
{hasSyntaxErrors && (
|
||||
<div className="bibtex-entry-preview-summary">
|
||||
<Notification
|
||||
type="error"
|
||||
customIcon={(<MaterialIcon unfilled type="error" />)}
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
<b>{plural(t, syntaxErrors.length, 'entry_has_syntax_error', 'entry_has_syntax_errors')}:</b>
|
||||
</p>
|
||||
<ul>{syntaxErrors.map((message, index) => <li key={`${index}-${message}`}>{message}</li>)}</ul>
|
||||
<p>
|
||||
{plural(t, syntaxErrors.length, 'correct_syntax_error_in_code_mode', 'correct_syntax_errors_in_code_mode')}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact summary shown above the detailed fields. */}
|
||||
<div className="bibtex-entry-preview-summary">
|
||||
<div className="bibtex-entry-preview-summary-content">
|
||||
<div className="bibtex-entry-preview-summary-key-row">
|
||||
<div className="bibtex-entry-preview-summary-key">
|
||||
{displayCitationKey(bibEntry, existingIds)}
|
||||
</div>
|
||||
</div>
|
||||
{summaryTitle && (
|
||||
<div className="bibtex-entry-preview-summary-title">
|
||||
{summaryTitle}
|
||||
</div>
|
||||
)}
|
||||
{summaryHeading && summaryHeading !== bibEntry.key.trim() && (
|
||||
<div className="bibtex-entry-preview-summary-meta">
|
||||
<span>{summaryHeading}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions for the currently previewed entry. */}
|
||||
<OLDropdown className="bibtex-entry-preview-summary-actions">
|
||||
<OLDropdownToggle
|
||||
className="d-inline-grid custom-toggle"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('actions')}
|
||||
>
|
||||
<span className="button-content" aria-hidden="false">
|
||||
<span className="material-symbols unfilled" aria-hidden="true">
|
||||
more_vert
|
||||
</span>
|
||||
</span>
|
||||
</OLDropdownToggle>
|
||||
<OLDropdownMenu>
|
||||
<OLDropdownItem leadingIcon="download" onClick={onDownload}>
|
||||
{t('download')}
|
||||
</OLDropdownItem>
|
||||
<OLDropdownItem
|
||||
leadingIcon="delete"
|
||||
onClick={onDelete}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{t('delete')}
|
||||
</OLDropdownItem>
|
||||
</OLDropdownMenu>
|
||||
</OLDropdown>
|
||||
</div>
|
||||
|
||||
{missing.length > 0 && !hasSyntaxErrors && (
|
||||
<div className="bibtex-entry-preview-summary">
|
||||
<Notification
|
||||
type="warning"
|
||||
className="unfilled"
|
||||
customIcon={(<MaterialIcon unfilled type="warning" />)}
|
||||
content={
|
||||
<BibEntryWarningSummary
|
||||
missingFields={missing}
|
||||
badlyFormattedFields={[]}
|
||||
requiredFieldsTitle={plural(
|
||||
t,
|
||||
missing.length,
|
||||
'required_field_missing',
|
||||
'required_fields_missing'
|
||||
)}
|
||||
badlyFormattedFieldsTitle={t('badly_formatted_fields')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classnames('bibtex-entry-preview-body', {
|
||||
'bibtex-entry-preview-body-abstract': tab === 'abstract',
|
||||
})}
|
||||
>
|
||||
<div className="bibtex-entry-preview-tabs" role="tablist" tabIndex={-1}>
|
||||
{tabs.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${idPrefix}tab-${id}`}
|
||||
aria-controls={`${idPrefix}panel-${id}`}
|
||||
aria-selected={tab === id}
|
||||
tabIndex={tab === id ? 0 : -1}
|
||||
className={classnames('bibtex-entry-preview-tab', {
|
||||
'bibtex-entry-preview-tab-active': tab === id,
|
||||
})}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<OLForm
|
||||
id="bibtex-entry-form"
|
||||
className={classnames('bibtex-entry-form', {
|
||||
'bibtex-entry-syntax-disabled': hasSyntaxErrors && !readOnly,
|
||||
'bibtex-entry-read-only': readOnly,
|
||||
})}
|
||||
>
|
||||
<fieldset
|
||||
className="bibtex-entry-preview-fields"
|
||||
disabled={hasSyntaxErrors || readOnly}
|
||||
>
|
||||
<BibEntryForm
|
||||
key={readOnly ? 'read-only' : 'editable'}
|
||||
entry={readOnly ? bibEntry : draftEntry ?? bibEntry}
|
||||
initialNameDrafts={pendingNameDrafts}
|
||||
sourceEntry={bibEntry}
|
||||
textareaSourceValues={textareaSourceValues}
|
||||
draftReset={draftReset}
|
||||
optionalExpanded={readOnly || optionalExpanded}
|
||||
onOptionalExpandedChange={onOptionalExpandedChange}
|
||||
kind="existing"
|
||||
previewTab={tab}
|
||||
existingIds={existingIds}
|
||||
onFormChange={onFormChange}
|
||||
onSelectAbstract={selectAbstract}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
</fieldset>
|
||||
</OLForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BibEntryPreview)
|
||||
@@ -0,0 +1,50 @@
|
||||
type BibEntryWarningSummaryProps = {
|
||||
missingFields: string[]
|
||||
badlyFormattedFields: string[]
|
||||
requiredFieldsTitle: string
|
||||
badlyFormattedFieldsTitle: string
|
||||
listFields?: boolean
|
||||
}
|
||||
|
||||
export default function BibEntryWarningSummary({
|
||||
missingFields,
|
||||
badlyFormattedFields,
|
||||
requiredFieldsTitle,
|
||||
badlyFormattedFieldsTitle,
|
||||
listFields = false,
|
||||
}: BibEntryWarningSummaryProps) {
|
||||
const renderFields = (fields: string[]) =>
|
||||
listFields ? (
|
||||
<ul>
|
||||
{fields.map(field => <li key={field}>{field}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
{fields.join(', ')}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{missingFields.length > 0 && (
|
||||
<>
|
||||
<strong>{requiredFieldsTitle}</strong>
|
||||
{renderFields(missingFields)}
|
||||
</>
|
||||
)}
|
||||
{!listFields && missingFields.length > 0 && badlyFormattedFields.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{badlyFormattedFields.length > 0 && (
|
||||
<>
|
||||
<strong>{badlyFormattedFieldsTitle}</strong>
|
||||
{renderFields(badlyFormattedFields)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Two-stage BibTeX/DOI import. Pasted entries appear immediately; DOI lookups
|
||||
* fill their rows concurrently without changing paste order. Closing or
|
||||
* restarting the preview invalidates outstanding results through a generation
|
||||
* counter. Import commits the selected valid entries in one guarded write.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { plural } from '../utils/plural'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import { fetchEntryFromDoi } from '../utils/doi-fetcher'
|
||||
import { generateCitationKey } from '../utils/bib-key-generation'
|
||||
import { translateBibSourceDiagnostic } from '../utils/bib-entry-diagnostics'
|
||||
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
|
||||
import {
|
||||
splitImportText,
|
||||
buildImportRow,
|
||||
buildImportRows,
|
||||
type BibImportRow,
|
||||
} from '../utils/bib-import.ts'
|
||||
|
||||
import ZoteroImportSource from '../zotero/zotero-import-source'
|
||||
import type { ZoteroGroup } from '../zotero/use-zotero-libraries'
|
||||
|
||||
type Props = {
|
||||
zoteroGroups?: ZoteroGroup[]
|
||||
show: boolean
|
||||
/** Fired for the Import button; false means the guarded write was rejected. */
|
||||
onImport: (entries: BibtexEntry[]) => boolean | Promise<boolean>
|
||||
onHidden: () => void
|
||||
/** Library (SaaS): pre-fill the paste step (Upload .bib file → same
|
||||
* pipeline). Default empty string. */
|
||||
initialText?: string
|
||||
}
|
||||
|
||||
export default function BibImportModal({
|
||||
show,
|
||||
onImport,
|
||||
onHidden,
|
||||
initialText = '',
|
||||
zoteroGroups,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<'paste' | 'preview'>('paste')
|
||||
const [text, setText] = useState('')
|
||||
const [rows, setRows] = useState<BibImportRow[]>([])
|
||||
const [checkedRowIds, setCheckedRowIds] = useState<string[]>([])
|
||||
const [previewDone, setPreviewDone] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importFailed, setImportFailed] = useState(false)
|
||||
const previewGenerationRef = useRef(0)
|
||||
|
||||
// Reset to a fresh "Add reference" → paste step whenever the modal opens.
|
||||
// `show` is the only remount signal (the component stays mounted to
|
||||
// follow the OLModal show/hide contract).
|
||||
useEffect(() => {
|
||||
// Invalidate queued DOI work whenever the modal becomes hidden. Requests
|
||||
// already in flight may finish, but their results will no longer update UI.
|
||||
previewGenerationRef.current++
|
||||
if (!show) return
|
||||
|
||||
setMode('paste')
|
||||
setText(initialText)
|
||||
setRows([])
|
||||
setCheckedRowIds([])
|
||||
setPreviewDone(false)
|
||||
setImporting(false)
|
||||
setImportFailed(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show])
|
||||
|
||||
const handleHidden = useCallback(() => {
|
||||
previewGenerationRef.current++
|
||||
onHidden()
|
||||
}, [onHidden])
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!text.trim()) return
|
||||
|
||||
const generation = ++previewGenerationRef.current
|
||||
setMode('preview')
|
||||
setImportFailed(false)
|
||||
setPreviewDone(false)
|
||||
setCheckedRowIds([])
|
||||
setRows([])
|
||||
|
||||
const items = splitImportText(text)
|
||||
type DoiItem = Extract<(typeof items)[number], { kind: 'doi' }>
|
||||
const doiItems: Array<{ item: DoiItem; rowIndex: number }> = []
|
||||
items.forEach((item, rowIndex) => {
|
||||
if (item.kind === 'doi') {
|
||||
doiItems.push({ item, rowIndex })
|
||||
}
|
||||
})
|
||||
const doiResults: (string | BibtexEntry | undefined)[] = doiItems.map(() => undefined)
|
||||
|
||||
// Prepare ordinary BibTeX rows once. DOI completions only rebuild their own
|
||||
// row instead of reparsing every pasted BibTeX entry on each network result.
|
||||
const previewRows = buildImportRows(items, doiResults)
|
||||
setRows([...previewRows])
|
||||
// Select available BibTeX rows by default, once. Subsequent DOI results
|
||||
// must never replace choices the user has already made.
|
||||
setCheckedRowIds(previewRows
|
||||
.filter(row => row.status === 'ok' || row.status === 'doi-ok')
|
||||
.map(row => row.rowId))
|
||||
|
||||
// DOI lookups are independent. Run a small bounded worker pool rather than
|
||||
// serial requests, while writing results at their original DOI index so
|
||||
// preview rows remain in paste order.
|
||||
const DOI_FETCH_CONCURRENCY = 4
|
||||
let nextDoiIndex = 0
|
||||
const fetchNextDoi = async () => {
|
||||
while (generation === previewGenerationRef.current) {
|
||||
const workIndex = nextDoiIndex++
|
||||
if (workIndex >= doiItems.length) return
|
||||
const { item, rowIndex } = doiItems[workIndex]
|
||||
|
||||
let result: string | BibtexEntry
|
||||
try {
|
||||
const fetched = await fetchEntryFromDoi(item.raw)
|
||||
const fetchedEntry = fetched.entry
|
||||
result = fetchedEntry.key
|
||||
? fetchedEntry
|
||||
: fetchedEntry.setKey(generateCitationKey(fetchedEntry))
|
||||
} catch (err) {
|
||||
result = err instanceof Error ? err.message : 'failed_to_fetch_doi'
|
||||
}
|
||||
if (generation !== previewGenerationRef.current) return
|
||||
const row = buildImportRow(item, rowIndex, result)
|
||||
previewRows[rowIndex] = row
|
||||
setRows([...previewRows])
|
||||
if (row.status === 'doi-ok') {
|
||||
setCheckedRowIds(previous => previous.includes(row.rowId)
|
||||
? previous
|
||||
: [...previous, row.rowId])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(DOI_FETCH_CONCURRENCY, doiItems.length) },
|
||||
() => fetchNextDoi()
|
||||
)
|
||||
)
|
||||
|
||||
if (generation !== previewGenerationRef.current) return
|
||||
setPreviewDone(true)
|
||||
}, [text])
|
||||
|
||||
const importable = useMemo(
|
||||
() => rows.filter(r => r.status === 'ok' || r.status === 'doi-ok'),
|
||||
[rows]
|
||||
)
|
||||
const checkedRowIdsSet = useMemo(() => new Set(checkedRowIds), [checkedRowIds])
|
||||
const checkedCount = checkedRowIds.length
|
||||
|
||||
const toggleRow = useCallback((rowId: string) => {
|
||||
setCheckedRowIds(prev =>
|
||||
prev.includes(rowId) ? prev.filter(id => id !== rowId) : [...prev, rowId]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleToggleSelectAll = useCallback((checked: boolean) => {
|
||||
setCheckedRowIds(checked ? importable.map(r => r.rowId) : [])
|
||||
}, [importable])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (importing) return
|
||||
const entries = importable
|
||||
.filter(r => checkedRowIdsSet.has(r.rowId))
|
||||
.map(r => r.entry as BibtexEntry)
|
||||
if (entries.length === 0) return
|
||||
|
||||
setImporting(true)
|
||||
setImportFailed(false)
|
||||
try {
|
||||
const saved = await onImport(entries)
|
||||
if (saved) handleHidden()
|
||||
else setImportFailed(true)
|
||||
} catch (error) {
|
||||
console.error('BibTeX import failed', error)
|
||||
setImportFailed(true)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [checkedRowIdsSet, importable, importing, handleHidden, onImport])
|
||||
|
||||
// Navigation controls for the two import stages:
|
||||
// - empty state (`bibtex-import-preview-empty`) when the paste yields
|
||||
// nothing importable (previewDone + zero importable rows)
|
||||
// - footer-warning when nothing is checked (Import shows why it's idle)
|
||||
const previewEmpty = previewDone && rows.length === 0
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
backdrop="static"
|
||||
data-bib-editor-dialog
|
||||
show={show}
|
||||
onHide={handleHidden}
|
||||
themed
|
||||
scrollable
|
||||
>
|
||||
<OLModalHeader>
|
||||
{mode === 'preview' ? (
|
||||
<>
|
||||
{/* Keep the stage heading available to screen readers. */}
|
||||
<OLButton
|
||||
className="ps-1"
|
||||
variant="ghost"
|
||||
leadingIcon="arrow_back"
|
||||
aria-label={t('back')}
|
||||
onClick={() => {
|
||||
previewGenerationRef.current++
|
||||
setMode('paste')
|
||||
}}
|
||||
>
|
||||
{t('back')}
|
||||
</OLButton>
|
||||
<OLModalTitle className="visually-hidden">{t('preview')}</OLModalTitle>
|
||||
</>
|
||||
) : (
|
||||
<OLModalTitle>
|
||||
{zoteroGroups ? t('import_from_provider', { provider: 'Zotero' }) : t('add_reference')}
|
||||
</OLModalTitle>
|
||||
)}
|
||||
</OLModalHeader>
|
||||
|
||||
{zoteroGroups && (
|
||||
<ZoteroImportSource
|
||||
groups={zoteroGroups}
|
||||
visible={mode === 'paste'}
|
||||
onCancel={handleHidden}
|
||||
onPreview={bibliography => {
|
||||
const items = splitImportText(bibliography)
|
||||
const previewRows = items.map((item, index) =>
|
||||
buildImportRow(item, index, 'could_not_parse_this_reference')
|
||||
)
|
||||
setRows(previewRows)
|
||||
setCheckedRowIds(previewRows.filter(row => row.status === 'ok').map(row => row.rowId))
|
||||
setImportFailed(false)
|
||||
setPreviewDone(true)
|
||||
setMode('preview')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mode === 'paste' && !zoteroGroups && (
|
||||
<>
|
||||
<OLModalBody className="bibtex-import-form">
|
||||
<div className="form-group">
|
||||
<OLFormLabel htmlFor="bib-import-textarea">
|
||||
{t('reference')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
id="bib-import-textarea"
|
||||
className="bibtex-import-textarea form-control"
|
||||
rows={6}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
aria-describedby="bib-import-help"
|
||||
/>
|
||||
<div className="form-text" id="bib-import-help">
|
||||
<span className="form-text-inner">
|
||||
<span>
|
||||
{t('paste_bibtex_or_dois_here')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleHidden}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={text.trim() === ''}
|
||||
onClick={() => void handlePreview()}
|
||||
type="button"
|
||||
>
|
||||
{t('preview')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)}
|
||||
{mode === 'preview' && (
|
||||
<>
|
||||
<OLModalBody>
|
||||
{previewEmpty ? (
|
||||
<div className="bibtex-import-preview-empty">
|
||||
{t('no_references_found')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bibtex-import-preview-header">
|
||||
<div
|
||||
className="wide-target-checkbox bibtex-import-preview-check-all"
|
||||
>
|
||||
<span className="wide-target-checkbox-box">
|
||||
<div className="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
aria-label={t('select_all')}
|
||||
checked={
|
||||
importable.length > 0 &&
|
||||
importable.every(r => checkedRowIdsSet.has(r.rowId))
|
||||
}
|
||||
disabled={importable.length === 0 || !previewDone}
|
||||
onChange={e => handleToggleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<label className="wide-target-checkbox-target" aria-hidden="true"></label>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bibtex-import-preview-count">
|
||||
{`${importable.length} ${plural(t, importable.length, 'reference', 'references')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bibtex-import-preview-list">
|
||||
{rows.map(row => (
|
||||
<BibImportCard
|
||||
key={row.rowId}
|
||||
row={row}
|
||||
checked={checkedRowIdsSet.has(row.rowId)}
|
||||
onToggle={toggleRow}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<div className="bibtex-import-preview-footer">
|
||||
{importFailed && (
|
||||
<div className="bibtex-import-preview-footer-warning" role="alert">
|
||||
<span aria-hidden="true" className="material-symbols unfilled">error</span>
|
||||
<span>{t('could_not_import_this_reference')}</span>
|
||||
</div>
|
||||
)}
|
||||
{previewDone && checkedCount === 0 && importable.length > 0 && !previewEmpty && (
|
||||
<div className="bibtex-import-preview-footer-warning" role="alert">
|
||||
<span aria-hidden="true" className="material-symbols unfilled">error</span>
|
||||
<span>{t('select_reference')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bibtex-import-preview-footer-actions">
|
||||
{!previewEmpty && (
|
||||
<div className="bibtex-import-preview-footer-count">
|
||||
{`${checkedCount} ${plural(t, checkedCount, 'reference', 'references')}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="bibtex-import-preview-footer-buttons">
|
||||
<OLButton variant="secondary" onClick={handleHidden}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={previewEmpty || !previewDone || checkedCount === 0 || importing}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{importing ? '…' : t(zoteroGroups ? 'import' : 'save')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)}
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
function BibImportCard({
|
||||
row,
|
||||
checked,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
row: BibImportRow
|
||||
checked: boolean
|
||||
onToggle: (rowId: string) => void
|
||||
t: TFunction
|
||||
}) {
|
||||
const selectable = row.status === 'ok' || row.status === 'doi-ok'
|
||||
|
||||
return (
|
||||
<div className="bibtex-import-preview-card">
|
||||
{selectable && (
|
||||
<div className="wide-target-checkbox bibtex-import-preview-card-check">
|
||||
<span className="wide-target-checkbox-box">
|
||||
<div className="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
autoComplete="off"
|
||||
className="form-check-input"
|
||||
aria-label={row.entry ? row.entry.key : t('untitled')}
|
||||
checked={checked}
|
||||
onChange={() => onToggle(row.rowId)}
|
||||
/>
|
||||
</div>
|
||||
<label className="wide-target-checkbox-target" aria-hidden="true"></label>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bibtex-import-preview-card-content">
|
||||
<div className="bibtex-import-preview-card-key">
|
||||
{row.status === 'empty'
|
||||
? t('resolving')
|
||||
: row.status === 'error' && !row.syntaxError
|
||||
? t('could_not_import_this_reference')
|
||||
: row.entry?.key || (row.syntaxError ? row.raw.split(/\r?\n/, 1)[0] : t('untitled'))}
|
||||
</div>
|
||||
<div className="bibtex-import-preview-card-details">
|
||||
{row.heading && (
|
||||
<div className="bibtex-import-preview-card-heading">
|
||||
{row.heading}
|
||||
</div>
|
||||
)}
|
||||
{row.title && (
|
||||
<div className="bibtex-import-preview-card-field">
|
||||
{row.title}
|
||||
</div>
|
||||
)}
|
||||
{row.status === 'error' && (
|
||||
<>
|
||||
<div className="bibtex-import-preview-card-error" role="alert">
|
||||
{row.syntaxError && row.errors?.length ? (
|
||||
<>
|
||||
<strong>
|
||||
{plural(t, row.errors.length, 'entry_has_syntax_error', 'entry_has_syntax_errors')}:
|
||||
</strong>
|
||||
<ul>
|
||||
{row.errors.map((diagnostic, index) => (
|
||||
<li key={index}>
|
||||
{translateBibSourceDiagnostic(diagnostic, t)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : row.error
|
||||
? t(row.error)
|
||||
: row.raw}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{row.status === 'empty' && (
|
||||
<div className="bibtex-import-preview-card-resolving">
|
||||
{row.raw}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Modal for creating a new bibliography entry. The same BibEntryForm is reused
|
||||
* here as in the inline preview, but the host controls when the new entry is
|
||||
* committed to the document.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import BibEntryForm from './bib-entry-form'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
|
||||
|
||||
const EMPTY_FOOTER_STATE = { disabled: true, submit: () => {} }
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
existingIds: string[]
|
||||
/** Save a new entry (the panel routes it through writeEntry(new)). */
|
||||
onSave: (entry: BibtexEntry) => Promise<boolean>
|
||||
onHide: () => void
|
||||
optionalExpanded?: boolean
|
||||
onOptionalExpandedChange?: (expanded: boolean) => void
|
||||
}
|
||||
|
||||
export default function BibManualModal({
|
||||
show,
|
||||
existingIds,
|
||||
onSave,
|
||||
onHide,
|
||||
optionalExpanded,
|
||||
onOptionalExpandedChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveFailed, setSaveFailed] = useState(false)
|
||||
const [footerState, setFooterState] = useState<{ disabled: boolean; submit: () => void }>(
|
||||
EMPTY_FOOTER_STATE
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setSaving(false)
|
||||
setSaveFailed(false)
|
||||
} else {
|
||||
// Discard the previous form's Save callback before the next opening.
|
||||
setFooterState(EMPTY_FOOTER_STATE)
|
||||
}
|
||||
}, [show])
|
||||
|
||||
const initialEntry = useMemo(
|
||||
() => new BibtexEntry({ type: 'article', key: '', fields: new Map() }),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
if (!saving) onHide()
|
||||
}, [onHide, saving])
|
||||
|
||||
const handleFormChecked = useCallback(async (entry: BibtexEntry, kind: 'existing' | 'new') => {
|
||||
if (kind !== 'new' || saving) return false
|
||||
setSaving(true)
|
||||
let saved = false
|
||||
try {
|
||||
saved = await onSave(entry)
|
||||
} catch (error) {
|
||||
console.error('BibTeX manual entry save failed', error)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
setSaveFailed(!saved)
|
||||
if (saved) handleHide()
|
||||
return saved
|
||||
}, [onSave, saving, handleHide])
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
backdrop="static"
|
||||
data-bib-editor-dialog
|
||||
show={show}
|
||||
onHide={handleHide}
|
||||
themed
|
||||
scrollable
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('add_reference')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
{show && (
|
||||
<OLModalBody>
|
||||
{saveFailed && (
|
||||
<div className="bibtex-write-failure-banner" role="alert">
|
||||
<span className="bibtex-write-failure-banner-text">
|
||||
{t('could_not_save_bib_file')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<OLForm
|
||||
id="bibtex-manual-entry-form"
|
||||
className="bibtex-entry-form"
|
||||
>
|
||||
<BibEntryForm
|
||||
key="open"
|
||||
entry={initialEntry}
|
||||
optionalExpanded={optionalExpanded}
|
||||
onOptionalExpandedChange={onOptionalExpandedChange}
|
||||
kind="new"
|
||||
existingIds={existingIds}
|
||||
idPrefix="bib-manual-"
|
||||
onFormChange={() => setSaveFailed(false)}
|
||||
onChecked={handleFormChecked}
|
||||
submitDisabled={saving}
|
||||
onFooterStateChange={setFooterState}
|
||||
/>
|
||||
</OLForm>
|
||||
</OLModalBody>
|
||||
)}
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleHide} disabled={saving}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={!show || footerState.disabled || saving}
|
||||
onClick={footerState.submit}
|
||||
type="button"
|
||||
>
|
||||
{saving ? '…' : t('save')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/** Name-list editor with local invalid drafts and stable focus behavior. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormText from '@/shared/components/ol/ol-form-text'
|
||||
import BibEntryFeedback from './bib-entry-feedback'
|
||||
import { fieldLabel } from '../utils/bib-field-labels'
|
||||
import { hasBalancedCurlyBraces } from '../utils/bib-validate'
|
||||
import { fromBibFormString, toBibFormString } from '../utils/bib-field-edit'
|
||||
import {
|
||||
authorDraftSavedValue,
|
||||
authorDraftValue,
|
||||
authorSourceValue,
|
||||
createAuthorDraft,
|
||||
isAuthorDraftValid,
|
||||
MAX_AUTHOR_ROWS,
|
||||
} from '../utils/bib-author-draft'
|
||||
import type { BibAuthorDraft } from '../utils/bib-author-draft'
|
||||
import type { BibHistoryUserEvent } from '../context/bib-editor-context'
|
||||
|
||||
/**
|
||||
* Name-list inputs and the raw text editor share one draft model. An invalid name
|
||||
* never blocks writes of valid neighboring rows: each row retains its own last
|
||||
* writable value. Switching to text mode preserves the last writable combined
|
||||
* value without trying to split malformed BibTeX back into rows.
|
||||
*/
|
||||
export default function BibNameField({
|
||||
fieldName,
|
||||
value,
|
||||
initialDraft,
|
||||
onChange,
|
||||
error,
|
||||
hideBraceErrors,
|
||||
onDraggingChange,
|
||||
idPrefix = '',
|
||||
}: {
|
||||
fieldName: string
|
||||
value: string
|
||||
initialDraft?: BibAuthorDraft
|
||||
onChange: (
|
||||
value: string,
|
||||
historyUserEvent?: BibHistoryUserEvent,
|
||||
fieldIsValid?: boolean,
|
||||
draft?: BibAuthorDraft
|
||||
) => void
|
||||
hideBraceErrors: boolean
|
||||
onDraggingChange: (dragging: boolean) => void
|
||||
error?: boolean
|
||||
idPrefix?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [nameDraft, setNameDraft] = useState<BibAuthorDraft>(
|
||||
() => initialDraft ?? createAuthorDraft(value)
|
||||
)
|
||||
const press = useRef<{ index: number; pointer: number; y: number; centerOffset: number; textOffset: number } | null>(null)
|
||||
const [drag, setDrag] = useState<{ from: number; to: number; x: number; y: number } | null>(null)
|
||||
const dragging = drag !== null
|
||||
useLayoutEffect(() => {
|
||||
if (!dragging) return
|
||||
onDraggingChange(true)
|
||||
return () => onDraggingChange(false)
|
||||
}, [dragging, onDraggingChange])
|
||||
|
||||
const nameDraftRef = useRef(nameDraft)
|
||||
// The parent supplies canonical BibTeX field text. Track the canonical value
|
||||
// of our last edit so its echo is not mistaken for an external source change.
|
||||
const expectedValueRef = useRef(value)
|
||||
const focusTextEndRef = useRef(false)
|
||||
const focusNameIndexRef = useRef<number | null>(null)
|
||||
// Keep DOM row identity with the person when names are reordered, rather
|
||||
// than with the array index. These IDs are local UI state, not BibTeX data.
|
||||
const rowIdsRef = useRef<number[]>(
|
||||
nameDraft.mode === 'rows' ? nameDraft.rows.map((_, index) => index) : []
|
||||
)
|
||||
const nextRowIdRef = useRef(rowIdsRef.current.length)
|
||||
// Match the label target in both row and textarea modes.
|
||||
const inputId = (index: number) => `${idPrefix}${fieldName}${index ? `-${index}` : ''}`
|
||||
|
||||
const handleTextareaMount = useCallback((textarea: HTMLInputElement | null) => {
|
||||
if (!textarea || !focusTextEndRef.current) return
|
||||
focusTextEndRef.current = false
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
}, [])
|
||||
|
||||
// Keep the user's exact spelling (including a single, unfinished '#') when
|
||||
// CodeMirror returns the canonical representation of our own edit. Only a
|
||||
// different source value replaces the local name draft.
|
||||
useEffect(() => {
|
||||
if (value === expectedValueRef.current) return
|
||||
press.current = null
|
||||
setDrag(null)
|
||||
const next = createAuthorDraft(value)
|
||||
if (next.mode === 'rows') {
|
||||
rowIdsRef.current = next.rows.map((_, index) =>
|
||||
rowIdsRef.current[index] ?? nextRowIdRef.current++
|
||||
)
|
||||
} else {
|
||||
rowIdsRef.current = []
|
||||
}
|
||||
expectedValueRef.current = value
|
||||
nameDraftRef.current = next
|
||||
setNameDraft(next)
|
||||
}, [value])
|
||||
|
||||
const update = (
|
||||
next: BibAuthorDraft,
|
||||
historyUserEvent: BibHistoryUserEvent = 'input.type'
|
||||
) => {
|
||||
nameDraftRef.current = next
|
||||
setNameDraft(next)
|
||||
const text = authorDraftValue(next)
|
||||
expectedValueRef.current = text.trim()
|
||||
? toBibFormString(fromBibFormString(text, fieldName), fieldName)
|
||||
: ''
|
||||
onChange(text, historyUserEvent, isAuthorDraftValid(next), next)
|
||||
}
|
||||
|
||||
const setNameAt = (index: number, value: string) => {
|
||||
const current = nameDraftRef.current
|
||||
if (current.mode !== 'rows') return
|
||||
update({
|
||||
mode: 'rows',
|
||||
rows: current.rows.map((row, i) =>
|
||||
i === index
|
||||
? {
|
||||
value,
|
||||
saved: hasBalancedCurlyBraces(value)
|
||||
? authorSourceValue(value, row.saved)
|
||||
: row.saved,
|
||||
}
|
||||
: row
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const addName = (after: number) => {
|
||||
const current = nameDraftRef.current
|
||||
if (current.mode !== 'rows') return
|
||||
if (current.rows.length >= MAX_AUTHOR_ROWS) {
|
||||
// Mode is a presentation choice, not a validation result. Retain the
|
||||
// writable projection while showing the entire (possibly invalid) text.
|
||||
focusNameIndexRef.current = null
|
||||
focusTextEndRef.current = true
|
||||
rowIdsRef.current = []
|
||||
update({
|
||||
mode: 'text',
|
||||
value: authorDraftValue(current),
|
||||
saved: authorDraftSavedValue(current),
|
||||
invalidRow: current.rows.find(
|
||||
row => !hasBalancedCurlyBraces(row.value)
|
||||
)?.value,
|
||||
}, 'input.complete')
|
||||
return
|
||||
}
|
||||
|
||||
const rows = current.rows.slice()
|
||||
const index = after + 1
|
||||
rows.splice(index, 0, { value: '', saved: '' })
|
||||
rowIdsRef.current.splice(index, 0, nextRowIdRef.current++)
|
||||
focusNameIndexRef.current = index
|
||||
update({ mode: 'rows', rows }, 'input.complete')
|
||||
}
|
||||
|
||||
const removeName = (index: number) => {
|
||||
const current = nameDraftRef.current
|
||||
if (current.mode !== 'rows') return
|
||||
rowIdsRef.current = current.rows.length > 1
|
||||
? rowIdsRef.current.filter((_, i) => i !== index)
|
||||
: [rowIdsRef.current[0]]
|
||||
update({
|
||||
mode: 'rows',
|
||||
rows: current.rows.length > 1
|
||||
? current.rows.filter((_, i) => i !== index)
|
||||
: [{ value: '', saved: '' }],
|
||||
}, 'input.complete')
|
||||
}
|
||||
|
||||
const moveName = (index: number, target: number) => {
|
||||
const current = nameDraftRef.current
|
||||
if (current.mode !== 'rows' || index < 0 || index === target ||
|
||||
target < 0 || target >= current.rows.length) return
|
||||
const rows = current.rows.slice()
|
||||
const [row] = rows.splice(index, 1)
|
||||
rows.splice(target, 0, row)
|
||||
const ids = rowIdsRef.current
|
||||
const [id] = ids.splice(index, 1)
|
||||
ids.splice(target, 0, id)
|
||||
update({ mode: 'rows', rows }, 'input.complete')
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
press.current = null
|
||||
setDrag(null)
|
||||
}
|
||||
|
||||
function targetIndex(handle: HTMLElement, centerY: number) {
|
||||
const list = handle.closest('.bibtex-name-rows')!
|
||||
let nearest = 0
|
||||
let distance = Infinity
|
||||
list.querySelectorAll('.bibtex-name-handle').forEach((slot, index) => {
|
||||
const rect = slot.getBoundingClientRect()
|
||||
const delta = Math.abs(centerY - (rect.top + rect.height / 2))
|
||||
if (delta < distance) {
|
||||
nearest = index
|
||||
distance = delta
|
||||
}
|
||||
})
|
||||
return nearest
|
||||
}
|
||||
|
||||
if (nameDraft.mode === 'text') {
|
||||
return (
|
||||
<>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
ref={handleTextareaMount}
|
||||
id={`${idPrefix}${fieldName}`}
|
||||
className={`bibtex-auto-textarea bibtex-name-textarea bibtex-form-input ${error || (!hideBraceErrors && !isAuthorDraftValid(nameDraft)) ? 'is-invalid' : ''}`}
|
||||
maxLength="4096"
|
||||
autoComplete="off"
|
||||
value={nameDraft.value}
|
||||
onChange={event => {
|
||||
const text = event.target.value
|
||||
update({
|
||||
mode: 'text',
|
||||
value: text,
|
||||
saved: hasBalancedCurlyBraces(text) ? text : nameDraft.saved,
|
||||
})
|
||||
}}
|
||||
placeholder="Doe, Jane and Mustermann, Max"
|
||||
rows={1}
|
||||
/>
|
||||
{!hideBraceErrors && !isAuthorDraftValid(nameDraft) && (
|
||||
<BibEntryFeedback
|
||||
message={t('curly_braces_must_be_balanced')}
|
||||
/>
|
||||
)}
|
||||
<OLFormText>{t('separate_multiple_names_with_and')}</OLFormText>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bibtex-form-names">
|
||||
<div className="bibtex-name-rows">
|
||||
{nameDraft.rows.map((name, index) => {
|
||||
// Preview the name order in the existing input slots.
|
||||
let displayed = name
|
||||
if (drag) {
|
||||
if (index === drag.to) displayed = { value: '', saved: '' }
|
||||
else if (index >= drag.from && index < drag.to) displayed = nameDraft.rows[index + 1]
|
||||
else if (index <= drag.from && index > drag.to) displayed = nameDraft.rows[index - 1]
|
||||
}
|
||||
return (
|
||||
<div key={rowIdsRef.current[index]} className="bibtex-field-with-actions">
|
||||
<OLFormControl
|
||||
ref={
|
||||
focusNameIndexRef.current === index
|
||||
? element => {
|
||||
if (!element) return
|
||||
focusNameIndexRef.current = null
|
||||
element.focus()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`bibtex-form-input ${error || (!hideBraceErrors && !hasBalancedCurlyBraces(displayed.value)) ? 'is-invalid' : ''}`}
|
||||
maxLength="128"
|
||||
type="text"
|
||||
id={inputId(index)}
|
||||
value={displayed.value}
|
||||
onChange={event => setNameAt(index, event.target.value)}
|
||||
placeholder={drag?.to === index ? '' : 'Doe, Jane'}
|
||||
prepend={
|
||||
<OLIconButton
|
||||
type="button"
|
||||
icon="drag_indicator"
|
||||
className="bibtex-name-handle"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
accessibilityLabel={`${t('move_up')} / ${t('move_down')}: ${name.value}`}
|
||||
disabled={nameDraft.rows.length < 2 || !name.value.trim()}
|
||||
onPointerDown={event => {
|
||||
if (!event.isPrimary || event.button !== 0 || event.currentTarget.matches(':disabled')) return
|
||||
const handle = event.currentTarget
|
||||
const rect = handle.getBoundingClientRect()
|
||||
const input = handle.closest('.bibtex-field-with-actions')!.querySelector('input')!
|
||||
const textLeft = input.getBoundingClientRect().left + input.clientLeft +
|
||||
parseFloat(getComputedStyle(input).paddingLeft)
|
||||
press.current = { index, pointer: event.pointerId, y: event.clientY,
|
||||
centerOffset: rect.top + rect.height / 2 - event.clientY,
|
||||
textOffset: textLeft - event.clientX }
|
||||
handle.setPointerCapture(event.pointerId)
|
||||
}}
|
||||
onPointerMove={event => {
|
||||
const start = press.current
|
||||
if (!start || start.index !== index || start.pointer !== event.pointerId) return
|
||||
if (!drag && Math.abs(event.clientY - start.y) < 3) return
|
||||
const y = event.clientY + start.centerOffset
|
||||
setDrag({ from: index, to: targetIndex(event.currentTarget, y),
|
||||
x: event.clientX + start.textOffset, y })
|
||||
}}
|
||||
onPointerUp={event => {
|
||||
const start = press.current
|
||||
if (!start || start.pointer !== event.pointerId) return
|
||||
const list = event.currentTarget.closest('.bibtex-name-rows')!
|
||||
if (drag && list.contains(document.elementFromPoint(event.clientX, event.clientY))) {
|
||||
moveName(index, targetIndex(event.currentTarget, event.clientY + start.centerOffset))
|
||||
}
|
||||
endDrag()
|
||||
}}
|
||||
onLostPointerCapture={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape') endDrag()
|
||||
if (press.current || !['ArrowUp', 'ArrowDown'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
moveName(index, index + (event.key === 'ArrowUp' ? -1 : 1))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
append={
|
||||
<OLIconButton
|
||||
type="button"
|
||||
icon="add"
|
||||
className="bibtex-name-add"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
accessibilityLabel={t('add_name', { name: fieldName })}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => addName(index)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{!hideBraceErrors && !hasBalancedCurlyBraces(displayed.value) && (
|
||||
<BibEntryFeedback message={t('curly_braces_must_be_balanced')} />
|
||||
)}
|
||||
|
||||
<div className="bibtex-form-actions">
|
||||
<OLIconButton
|
||||
icon="close"
|
||||
unfilled
|
||||
variant="ghost"
|
||||
accessibilityLabel={fieldName === 'author'
|
||||
? t('remove_author')
|
||||
: t('remove_field', { a: fieldLabel(fieldName) })}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => removeName(index)}
|
||||
disabled={nameDraft.rows.length === 1 && !name.value.trim()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{drag && createPortal(
|
||||
<span aria-hidden="true" className="bibtex-dragged-name"
|
||||
style={{ left: drag.x, top: drag.y }}>
|
||||
{nameDraft.rows[drag.from]?.value}
|
||||
</span>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/** ORCID search and import interface for the BibTeX editor. */
|
||||
import { useId } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { OLModal, OLModalHeader, OLModalTitle, OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { useOrcidPicker } from '../orcid/hooks/use-orcid-picker'
|
||||
import { isValidOrcid } from '../orcid/orcid-api'
|
||||
import { useBibOrcidImport } from '../orcid/hooks/use-bib-orcid-import'
|
||||
import { plural } from '../utils/plural'
|
||||
import '../../stylesheets/bib-orcid-import.css'
|
||||
|
||||
type Props = { show: boolean; onHide: () => void }
|
||||
|
||||
export default function BibOrcidImportModal({ show, onHide }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const searchModeId = useId()
|
||||
const orcidModeId = useId()
|
||||
const selectAllId = useId()
|
||||
const bridge = useBibOrcidImport(show)
|
||||
const {
|
||||
step, searchMode, nameQuery, orcidInput, searchLoading, searchError, authors,
|
||||
selectedAuthorName, works, worksLoading, worksError, selectedPutCodes,
|
||||
importing, importError, importSummary, crossrefFailures, unimportedTitles, allSelected,
|
||||
setOrcidInput, setSearchError, setWorksError, setImportError,
|
||||
handleClose, goBack, handleSearchModeChange, changeNameQuery,
|
||||
handleSearchByName, handleGoToWorksByOrcid, handlePickAuthor,
|
||||
toggleWork, toggleAll, handleImport,
|
||||
} = useOrcidPicker({ show, onHide, ...bridge, t })
|
||||
const isReport = step === 'works' && importSummary !== null
|
||||
|
||||
// Presentation-only handlers
|
||||
const handleNameKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
void handleSearchByName()
|
||||
}
|
||||
|
||||
const handleOrcidKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
void handleGoToWorksByOrcid()
|
||||
}
|
||||
|
||||
const importWarning = importError && (
|
||||
<Notification
|
||||
type="warning"
|
||||
content={importError}
|
||||
isDismissible
|
||||
onDismiss={() => setImportError(null)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
backdrop="static"
|
||||
data-bib-editor-dialog
|
||||
show={show}
|
||||
onHide={handleClose}
|
||||
data-testid="orcid-picker-modal"
|
||||
scrollable
|
||||
themed
|
||||
>
|
||||
<OLModalHeader closeButton>
|
||||
{step === 'works' && !isReport ? (
|
||||
<>
|
||||
<OLButton
|
||||
variant="ghost"
|
||||
className="ps-1"
|
||||
leadingIcon="arrow_back"
|
||||
disabled={importing}
|
||||
onClick={goBack}
|
||||
data-testid="orcid-back"
|
||||
>
|
||||
{t('back')}
|
||||
</OLButton>
|
||||
<OLModalTitle className="visually-hidden">{t('import_from_provider', { provider: 'ORCID.org' })}</OLModalTitle>
|
||||
</>
|
||||
) : (
|
||||
<OLModalTitle>{t('import_from_provider', { provider: 'ORCID.org' })}</OLModalTitle>
|
||||
)}
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody className={step === 'works' && !isReport ? 'orcid-picker-works-body' : undefined}>
|
||||
{step === 'search' && (
|
||||
<>
|
||||
{/* Reuse the upstream Code/Visual editor toggle styling. */}
|
||||
<div
|
||||
className="editor-toggle-switch mb-3"
|
||||
data-testid="orcid-mode-toggle"
|
||||
>
|
||||
<fieldset className="toggle-switch">
|
||||
<legend className="visually-hidden">
|
||||
{t('search_orcid_by_name_or_id')}
|
||||
</legend>
|
||||
<input
|
||||
type="radio"
|
||||
name={searchModeId}
|
||||
id={searchModeId}
|
||||
className="toggle-switch-input"
|
||||
value="name"
|
||||
checked={searchMode === 'name'}
|
||||
onChange={() => handleSearchModeChange('name')}
|
||||
/>
|
||||
<label htmlFor={searchModeId} className="toggle-switch-label">
|
||||
<span>{t('search_by_name')}</span>
|
||||
</label>
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
name={searchModeId}
|
||||
id={orcidModeId}
|
||||
className="toggle-switch-input"
|
||||
value="orcid"
|
||||
checked={searchMode === 'orcid'}
|
||||
onChange={() => handleSearchModeChange('orcid')}
|
||||
/>
|
||||
<label htmlFor={orcidModeId} className="toggle-switch-label">
|
||||
<span>{t('enter_orcid_id')}</span>
|
||||
</label>
|
||||
</span>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{searchError && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={searchError}
|
||||
isDismissible
|
||||
onDismiss={() => setSearchError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchMode === 'name' && (
|
||||
<>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor="orcid-name-input">
|
||||
{t('author_name')}
|
||||
</OLFormLabel>
|
||||
<div className="d-flex gap-2">
|
||||
<OLFormControl
|
||||
id="orcid-name-input"
|
||||
className="flex-grow-1"
|
||||
type="text"
|
||||
placeholder="Jane Doe"
|
||||
value={nameQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
changeNameQuery(e.target.value)
|
||||
}}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
disabled={searchLoading}
|
||||
/>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => void handleSearchByName()}
|
||||
disabled={searchLoading || !nameQuery.trim()}
|
||||
isLoading={searchLoading}
|
||||
>
|
||||
{t('search')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</OLFormGroup>
|
||||
|
||||
{authors.length > 0 && (
|
||||
<div
|
||||
className="orcid-picker-authors bibtex-import-preview-list"
|
||||
data-testid="orcid-authors"
|
||||
>
|
||||
{authors.map(a => {
|
||||
const display = [a.givenNames, a.familyNames]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return (
|
||||
<button
|
||||
key={a.orcid}
|
||||
type="button"
|
||||
className="bibtex-import-preview-card orcid-picker-author-card"
|
||||
onClick={() => void handlePickAuthor(a.orcid, display || a.orcid)}
|
||||
>
|
||||
<span className="bibtex-import-preview-card-content">
|
||||
<span className="bibtex-import-preview-card-heading">
|
||||
{display || t('no_name')}
|
||||
</span>
|
||||
<span className="bibtex-import-preview-card-details">
|
||||
<span className="font-monospace small">{a.orcid}</span>
|
||||
<span className="bibtex-import-preview-card-field">
|
||||
{a.institutionNames.join(', ') || '—'}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchMode === 'orcid' && (
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor="orcid-id-input">{t('orcid_id')}</OLFormLabel>
|
||||
<div className="d-flex gap-2">
|
||||
<OLFormControl
|
||||
id="orcid-id-input"
|
||||
className="flex-grow-1"
|
||||
type="text"
|
||||
placeholder="0000-0000-0000-0000"
|
||||
value={orcidInput}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setOrcidInput(e.target.value)
|
||||
}
|
||||
onKeyDown={handleOrcidKeyDown}
|
||||
disabled={worksLoading}
|
||||
/>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => void handleGoToWorksByOrcid()}
|
||||
disabled={worksLoading || !isValidOrcid(orcidInput.trim())}
|
||||
isLoading={worksLoading}
|
||||
>
|
||||
{t('load_works')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</OLFormGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'works' && !isReport && (
|
||||
<>
|
||||
<div className="orcid-picker-works-summary">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<h3 className="h5 mb-0">{t('works_by_author', { author: selectedAuthorName })}</h3>
|
||||
{worksLoading && <span className="spinner-border spinner-border-sm" role="status" aria-label={t('loading')} />}
|
||||
</div>
|
||||
{works.length > 0 && (
|
||||
<div className="small text-muted">
|
||||
{`${works.length} ${plural(t, works.length, 'reference', 'references')}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{importWarning}
|
||||
{unimportedTitles.length > 0 && (
|
||||
<ul className="orcid-picker-enrichment-titles">
|
||||
{unimportedTitles.map((title, index) => <li key={index}>{title}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="orcid-picker-works-scroll">
|
||||
{worksError && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={worksError}
|
||||
isDismissible
|
||||
onDismiss={() => setWorksError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{works.length > 0 && (
|
||||
<div className="bibtex-import-preview-header orcid-picker-select-all-row">
|
||||
<div className="wide-target-checkbox bibtex-import-preview-check-all">
|
||||
<span className="wide-target-checkbox-box">
|
||||
<div className="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
aria-label={t('select_all')}
|
||||
id={selectAllId}
|
||||
checked={allSelected}
|
||||
disabled={importing || worksLoading}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</div>
|
||||
<label className="wide-target-checkbox-target" htmlFor={selectAllId} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<label className="bibtex-import-preview-count" htmlFor={selectAllId}>
|
||||
{t('select_all')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{works.length > 0 && (
|
||||
<div className="orcid-picker-works bibtex-import-preview-list" data-testid="orcid-works">
|
||||
{works.map(w => (
|
||||
<div className="bibtex-import-preview-card" key={w.putCode}>
|
||||
<div className="wide-target-checkbox bibtex-import-preview-card-check">
|
||||
<span className="wide-target-checkbox-box">
|
||||
<div className="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
autoComplete="off"
|
||||
checked={selectedPutCodes.has(w.putCode)}
|
||||
disabled={importing}
|
||||
onChange={() => toggleWork(w.putCode)}
|
||||
aria-label={w.title || t('untitled')}
|
||||
/>
|
||||
</div>
|
||||
<label className="wide-target-checkbox-target" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="bibtex-import-preview-card-content">
|
||||
<div className="bibtex-import-preview-card-heading">
|
||||
{w.title || t('untitled')}
|
||||
</div>
|
||||
{w.year && <div className="bibtex-import-preview-card-field">{w.year}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isReport && importSummary && (
|
||||
<>
|
||||
<div className="orcid-picker-import-summary" role="status" data-testid="orcid-import-summary">
|
||||
<div>
|
||||
{plural(t, importSummary.imported, 'orcid_imported_reference', 'orcid_imported_references')}
|
||||
</div>
|
||||
{importSummary.enriched > 0 && (
|
||||
<div>{plural(t, importSummary.enriched, 'orcid_enriched_reference', 'orcid_enriched_references')}</div>
|
||||
)}
|
||||
{crossrefFailures.length > 0 && (
|
||||
<div className="orcid-picker-enrichment-warning" role="alert">
|
||||
<div className="orcid-picker-enrichment-danger">
|
||||
{plural(t, crossrefFailures.length, 'orcid_enrichment_failed_reference', 'orcid_enrichment_failed_references')}
|
||||
</div>
|
||||
<ul className="orcid-picker-enrichment-titles">
|
||||
{crossrefFailures.map((failure, index) => (
|
||||
<li key={index}>{failure.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
{crossrefFailures.some(failure => failure.reason === 'rate-limit') && (
|
||||
<div className="orcid-picker-enrichment-danger">{t('orcid_crossref_rate_limited')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{importWarning}
|
||||
{unimportedTitles.length > 0 && (
|
||||
<ul className="orcid-picker-enrichment-titles">
|
||||
{unimportedTitles.map((title, index) => <li key={index}>{title}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OLModalBody>
|
||||
|
||||
{step === 'works' && (works.length > 0 || isReport) && (
|
||||
<OLModalFooter>
|
||||
<div className="bibtex-import-preview-footer-actions">
|
||||
{!isReport && (
|
||||
<div className="bibtex-import-preview-footer-count">
|
||||
{`${selectedPutCodes.size} ${plural(t, selectedPutCodes.size, 'reference', 'references')}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="bibtex-import-preview-footer-buttons orcid-picker-footer-buttons">
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
>
|
||||
{t(isReport ? 'close' : 'cancel')}
|
||||
</OLButton>
|
||||
{!isReport && (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
className="orcid-picker-import-button"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={selectedPutCodes.size === 0 || importing}
|
||||
>
|
||||
<span className="orcid-picker-import-content">
|
||||
<span className={importing ? 'invisible' : undefined} aria-hidden={importing}>
|
||||
{t('import')}
|
||||
</span>
|
||||
{importing && (
|
||||
<span className="spinner-border spinner-border-sm orcid-picker-import-spinner"
|
||||
role="status" aria-label={t('importing')} />
|
||||
)}
|
||||
</span>
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OLModalFooter>
|
||||
)}
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
type VerticalTogglerProps = {
|
||||
id: string
|
||||
onClick: () => void
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
export function VerticalToggler({
|
||||
id,
|
||||
onClick,
|
||||
tooltip,
|
||||
}: VerticalTogglerProps) {
|
||||
return (
|
||||
<OLTooltip
|
||||
id={id}
|
||||
description={tooltip}
|
||||
overlayProps={{ placement: 'top' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="custom-toggler custom-toggler-vertical"
|
||||
aria-label={tooltip}
|
||||
title=""
|
||||
onClick={onClick}
|
||||
>
|
||||
<MaterialIcon type="keyboard_arrow_up" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { createContext, FC, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import type { BibFieldWriteTarget } from '../utils/bib-write'
|
||||
import type { BibAuthorDraft } from '../utils/bib-author-draft'
|
||||
import type { PositionedBibtexEntry } from '../utils/bib-types'
|
||||
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
|
||||
import {
|
||||
BIB_DELETE_EVENT,
|
||||
BIB_IMPORT_EVENT,
|
||||
BIB_WRITE_EVENT,
|
||||
dispatchBibMutation,
|
||||
} from '../utils/bib-editor-events'
|
||||
import type {
|
||||
BibEntryIdRemap,
|
||||
BibHistoryUserEvent,
|
||||
BibWriteRequest,
|
||||
BibDeleteRequest,
|
||||
BibImportRequest,
|
||||
} from '../utils/bib-editor-events'
|
||||
export type {
|
||||
BibEntryIdRemap,
|
||||
BibHistoryUserEvent,
|
||||
BibWriteRequest,
|
||||
BibDeleteRequest,
|
||||
BibImportRequest,
|
||||
} from '../utils/bib-editor-events'
|
||||
|
||||
export type { BibFieldWriteTarget } from '../utils/bib-write'
|
||||
|
||||
const EMPTY_ENTRY_ID_REMAPS: BibEntryIdRemap[] = []
|
||||
const EMPTY_REMOVED_ENTRY_IDS: string[] = []
|
||||
|
||||
export type BibSelection = null | {
|
||||
entryId: string
|
||||
from: number
|
||||
// Stable while the same preview is open, even if the citation key changes.
|
||||
previewId: number
|
||||
}
|
||||
|
||||
/** Shared contract for visual form edits across the details and Abstract controls. */
|
||||
export type BibFormChangeHandler = (
|
||||
entry: BibtexEntry,
|
||||
historyUserEvent?: BibHistoryUserEvent,
|
||||
fieldIsValid?: boolean,
|
||||
fieldTarget?: BibFieldWriteTarget,
|
||||
draftIsValid?: boolean,
|
||||
nameDraft?: BibAuthorDraft
|
||||
) => void
|
||||
|
||||
type BibEditorActions = {
|
||||
setEditorState: (
|
||||
entries: PositionedBibtexEntry[],
|
||||
revision: number,
|
||||
entryIdRemaps?: BibEntryIdRemap[],
|
||||
removedEntryIds?: string[],
|
||||
documentId?: string | null
|
||||
) => void
|
||||
selectEntry: (entry: PositionedBibtexEntry) => void
|
||||
bindEditorView: (view: EditorView | null, canWrite?: () => boolean) => void
|
||||
setRestoredSessionKey: (key: string | null) => void
|
||||
deselect: () => void
|
||||
writeEntry: (request: BibWriteRequest) => boolean
|
||||
importMany: (request: BibImportRequest) => boolean
|
||||
deleteEntry: (request: BibDeleteRequest) => void
|
||||
setWriteFailure: (reason: string | null) => void
|
||||
clearWriteFailure: () => void
|
||||
setHistoryState: (canUndo: boolean, canRedo: boolean) => void
|
||||
isCurrentEditorView: (view: EditorView | undefined) => boolean
|
||||
}
|
||||
|
||||
export type BibEditorState = {
|
||||
entries: PositionedBibtexEntry[]
|
||||
entriesDocumentId: string | null
|
||||
restoredSessionKey: string | null
|
||||
revision: number
|
||||
entryIdRemaps: BibEntryIdRemap[]
|
||||
removedEntryIds: string[]
|
||||
selection: BibSelection
|
||||
writeFailure: string | null
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
}
|
||||
|
||||
type BibEditorContextValue = BibEditorState & BibEditorActions
|
||||
|
||||
const BibEditorContext = createContext<BibEditorContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const BibEditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const [entries, setEntries] = useState<PositionedBibtexEntry[]>([])
|
||||
const [entriesDocumentId, setEntriesDocumentId] = useState<string | null>(null)
|
||||
// View and eligibility have one lifetime. Departure commits remain separate.
|
||||
const editorBindingRef = useRef<{ view: EditorView; canWrite: () => boolean } | null>(null)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [entryIdRemaps, setEntryIdRemaps] = useState(EMPTY_ENTRY_ID_REMAPS)
|
||||
const [removedEntryIds, setRemovedEntryIds] = useState(EMPTY_REMOVED_ENTRY_IDS)
|
||||
const [selection, setSelection] = useState<BibSelection>(null)
|
||||
// Session initialization survives Visual-panel unmounts, just like selection.
|
||||
const [restoredSessionKey, setRestoredSessionKey] = useState<string | null>(null)
|
||||
const projectedEntriesRef = useRef<PositionedBibtexEntry[]>([])
|
||||
const projectionDocumentIdRef = useRef<string | null>(null)
|
||||
const nextPreviewId = useRef(0)
|
||||
const [writeFailure, setWriteFailureState] = useState<string | null>(null)
|
||||
const [canUndo, setCanUndo] = useState(false)
|
||||
const [canRedo, setCanRedo] = useState(false)
|
||||
|
||||
// Entry ids are derived from citation key plus document-order occurrence index.
|
||||
// The extension reports both ID remaps and removed source occurrences.
|
||||
const setEditorState = useCallback(
|
||||
(
|
||||
newEntries: PositionedBibtexEntry[],
|
||||
newRevision: number,
|
||||
nextEntryIdRemaps?: BibEntryIdRemap[],
|
||||
nextRemovedEntryIds?: string[],
|
||||
documentId?: string | null
|
||||
) => {
|
||||
const nextDocumentId = documentId ?? null
|
||||
if (projectionDocumentIdRef.current !== nextDocumentId) {
|
||||
projectionDocumentIdRef.current = nextDocumentId
|
||||
setRestoredSessionKey(null)
|
||||
setSelection(null)
|
||||
}
|
||||
setEntriesDocumentId(nextDocumentId)
|
||||
setRevision(newRevision)
|
||||
setEntryIdRemaps(
|
||||
nextEntryIdRemaps?.length ? nextEntryIdRemaps : EMPTY_ENTRY_ID_REMAPS
|
||||
)
|
||||
setRemovedEntryIds(
|
||||
nextRemovedEntryIds?.length
|
||||
? nextRemovedEntryIds
|
||||
: EMPTY_REMOVED_ENTRY_IDS
|
||||
)
|
||||
const previousEntries = projectedEntriesRef.current
|
||||
projectedEntriesRef.current = newEntries
|
||||
setEntries(newEntries)
|
||||
setWriteFailureState(null)
|
||||
|
||||
setSelection(prev => {
|
||||
if (!prev) return prev
|
||||
|
||||
const remap = nextEntryIdRemaps?.find(
|
||||
item => item.previousEntryId === prev.entryId
|
||||
)
|
||||
const identityWasUpdated =
|
||||
nextEntryIdRemaps !== undefined || nextRemovedEntryIds !== undefined
|
||||
// Code-mode cut/paste can temporarily remove the current entry, and
|
||||
// whole-range swaps can report positional renames. Follow a unique key.
|
||||
const key = prev.entryId.slice(0, prev.entryId.lastIndexOf('#'))
|
||||
const followKey = !editorBindingRef.current && key &&
|
||||
previousEntries.filter(item => item.entry.key === key).length <= 1 &&
|
||||
newEntries.filter(item => item.entry.key === key).length <= 1
|
||||
const movedEntry = followKey
|
||||
? newEntries.find(item => item.entry.id === prev.entryId)
|
||||
: undefined
|
||||
const selectedEntry = movedEntry ?? (remap
|
||||
? newEntries.find(entry => entry.from === remap.from)
|
||||
: identityWasUpdated && nextRemovedEntryIds?.includes(prev.entryId)
|
||||
? undefined
|
||||
: newEntries.find(entry => entry.entry.id === prev.entryId))
|
||||
|
||||
if (!selectedEntry) return followKey ? prev : null
|
||||
if (
|
||||
selectedEntry.entry.id === prev.entryId &&
|
||||
selectedEntry.from === prev.from
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
entryId: selectedEntry.entry.id,
|
||||
from: selectedEntry.from,
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const bindEditorView = useCallback((view: EditorView | null, canWrite?: () => boolean) => {
|
||||
editorBindingRef.current = view && canWrite ? { view, canWrite } : null
|
||||
}, [])
|
||||
|
||||
const isCurrentEditorView = useCallback((view: EditorView | undefined) => {
|
||||
return !!view && editorBindingRef.current?.view === view
|
||||
}, [])
|
||||
|
||||
const setWriteFailure = useCallback((reason: string | null) => {
|
||||
setWriteFailureState(reason)
|
||||
}, [])
|
||||
|
||||
const clearWriteFailure = useCallback(() => {
|
||||
setWriteFailureState(null)
|
||||
}, [])
|
||||
|
||||
const setHistoryState = useCallback((nextUndo: boolean, nextRedo: boolean) => {
|
||||
setCanUndo(prev => (prev === nextUndo ? prev : nextUndo))
|
||||
setCanRedo(prev => (prev === nextRedo ? prev : nextRedo))
|
||||
}, [])
|
||||
|
||||
const selectEntry = useCallback((entry: PositionedBibtexEntry) => {
|
||||
setSelection({
|
||||
entryId: entry.entry.id,
|
||||
from: entry.from,
|
||||
previewId: ++nextPreviewId.current,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
setSelection(null)
|
||||
}, [])
|
||||
|
||||
// The extension owns CodeMirror writes. React requires its explicit
|
||||
// acknowledgement: a DOM event without a listener is not a successful write.
|
||||
const writeEntry = useCallback((request: BibWriteRequest) => {
|
||||
const binding = editorBindingRef.current
|
||||
if (!binding?.canWrite()) return false
|
||||
const { view } = binding
|
||||
return dispatchBibMutation(BIB_WRITE_EVENT, request, view)
|
||||
}, [])
|
||||
|
||||
const importMany = useCallback((request: BibImportRequest) => {
|
||||
const binding = editorBindingRef.current
|
||||
if (!binding?.canWrite()) return false
|
||||
const { view } = binding
|
||||
return dispatchBibMutation(BIB_IMPORT_EVENT, request, view)
|
||||
}, [])
|
||||
|
||||
const deleteEntry = useCallback((request: BibDeleteRequest) => {
|
||||
const binding = editorBindingRef.current
|
||||
if (!binding?.canWrite()) return
|
||||
const { view } = binding
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(BIB_DELETE_EVENT, { detail: { ...request, view } })
|
||||
)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<BibEditorContextValue>(
|
||||
() => ({
|
||||
entries,
|
||||
entriesDocumentId,
|
||||
restoredSessionKey,
|
||||
setRestoredSessionKey,
|
||||
revision,
|
||||
entryIdRemaps,
|
||||
removedEntryIds,
|
||||
selection,
|
||||
writeFailure,
|
||||
canUndo,
|
||||
canRedo,
|
||||
setEditorState,
|
||||
selectEntry,
|
||||
bindEditorView,
|
||||
deselect,
|
||||
writeEntry,
|
||||
importMany,
|
||||
deleteEntry,
|
||||
setWriteFailure,
|
||||
clearWriteFailure,
|
||||
setHistoryState,
|
||||
isCurrentEditorView,
|
||||
}),
|
||||
[
|
||||
entries,
|
||||
entriesDocumentId,
|
||||
restoredSessionKey,
|
||||
setRestoredSessionKey,
|
||||
revision,
|
||||
entryIdRemaps,
|
||||
removedEntryIds,
|
||||
selection,
|
||||
writeFailure,
|
||||
canUndo,
|
||||
canRedo,
|
||||
setEditorState,
|
||||
selectEntry,
|
||||
bindEditorView,
|
||||
deselect,
|
||||
writeEntry,
|
||||
importMany,
|
||||
deleteEntry,
|
||||
setWriteFailure,
|
||||
clearWriteFailure,
|
||||
setHistoryState,
|
||||
isCurrentEditorView,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<BibEditorContext.Provider value={value}>
|
||||
{children}
|
||||
</BibEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useBibEditorContext() {
|
||||
const context = useContext(BibEditorContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useBibEditorContext is only available inside BibEditorProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Bridge between the CodeMirror extension (DOM events) and the BibEditor
|
||||
* React context.
|
||||
*
|
||||
* The extension emits parsed entries, undo/redo state and
|
||||
* guarded-write failures. Projections carry both the CodeMirror view and the
|
||||
* owning OT document ID, since the same view is reused across file switches.
|
||||
*/
|
||||
import React, { useLayoutEffect, useRef } from 'react'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import type { BibEntriesDetail, BibHistoryStateDetail, BibWriteFailureDetail } from '../utils/bib-editor-events'
|
||||
import {
|
||||
BibEditorProvider,
|
||||
useBibEditorContext,
|
||||
} from './bib-editor-context'
|
||||
import {
|
||||
BIB_ENTRIES_EVENT,
|
||||
BIB_WRITE_FAILED_EVENT,
|
||||
BIB_HISTORY_STATE_EVENT,
|
||||
} from '../utils/bib-editor-events'
|
||||
|
||||
function BibEditorBridge({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
setEditorState,
|
||||
setWriteFailure,
|
||||
setHistoryState,
|
||||
isCurrentEditorView,
|
||||
} = useBibEditorContext()
|
||||
const { currentDocumentId, openDocName } = useEditorOpenDocContext()
|
||||
// A newly attached CodeMirror plugin may publish its initial projection
|
||||
// during the document-switch commit. Keep the listener installed while the
|
||||
// active document changes, and identify events by document ID, not by a
|
||||
// view that the Visual panel has not yet bound.
|
||||
const activeDocumentRef = useRef({ currentDocumentId, openDocName })
|
||||
activeDocumentRef.current = { currentDocumentId, openDocName }
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// The active document ID is authoritative even before the Visual panel binds a view.
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<BibEntriesDetail>).detail
|
||||
if (
|
||||
!detail ||
|
||||
detail.documentId !== activeDocumentRef.current.currentDocumentId ||
|
||||
!activeDocumentRef.current.openDocName?.toLowerCase().endsWith('.bib') ||
|
||||
!detail.entries ||
|
||||
typeof detail.revision !== 'number' ||
|
||||
detail.isBibFile !== true
|
||||
) return
|
||||
|
||||
setEditorState(
|
||||
detail.entries,
|
||||
detail.revision,
|
||||
detail.entryIdRemaps,
|
||||
detail.removedEntryIds,
|
||||
detail.documentId
|
||||
)
|
||||
}
|
||||
const historyHandler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<BibHistoryStateDetail>).detail
|
||||
if (detail && isCurrentEditorView(detail.view) && (typeof detail.canUndo === 'boolean' || typeof detail.canRedo === 'boolean')) {
|
||||
setHistoryState(!!detail.canUndo, !!detail.canRedo)
|
||||
}
|
||||
}
|
||||
|
||||
const failedHandler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<BibWriteFailureDetail>).detail
|
||||
// A failed guard is not a local form-validation error: the source changed
|
||||
// underneath the request, so the UI keeps the warning until a fresh parse
|
||||
// arrives or the user dismisses it.
|
||||
if (!isCurrentEditorView(detail?.view)) return
|
||||
setWriteFailure(detail?.reason ?? 'unknown')
|
||||
}
|
||||
|
||||
document.addEventListener(BIB_HISTORY_STATE_EVENT, historyHandler)
|
||||
document.addEventListener(BIB_ENTRIES_EVENT, handler)
|
||||
document.addEventListener(BIB_WRITE_FAILED_EVENT, failedHandler)
|
||||
return () => {
|
||||
document.removeEventListener(BIB_HISTORY_STATE_EVENT, historyHandler)
|
||||
document.removeEventListener(BIB_ENTRIES_EVENT, handler)
|
||||
document.removeEventListener(
|
||||
BIB_WRITE_FAILED_EVENT,
|
||||
failedHandler
|
||||
)
|
||||
}
|
||||
}, [setEditorState, setWriteFailure, setHistoryState, isCurrentEditorView])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite provider: BibEditorProvider + BibEditorBridge.
|
||||
* Registered in rootContextProviders (settings).
|
||||
*/
|
||||
export default function BibEditorContextProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<BibEditorProvider>
|
||||
<BibEditorBridge>{children}</BibEditorBridge>
|
||||
</BibEditorProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
/**
|
||||
* CodeMirror integration for the visual BibTeX editor.
|
||||
*
|
||||
* CodeMirror remains authoritative. Visual edits are applied as small changes
|
||||
* to the affected field/header, while this plugin owns the current source range
|
||||
* for every entry occurrence. Entry ids are derived from citation key and
|
||||
* occurrence index, with explicit remaps for targeted visual edits.
|
||||
*/
|
||||
import { Annotation, Extension, Facet, Transaction } from '@codemirror/state'
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||
import { undoDepth, redoDepth } from '@codemirror/commands'
|
||||
import { parseBibFile, parseBibEntryAt, parseBibEntryInRange } from '../utils/bib-entry-position'
|
||||
import { keyToCommitOnLeave } from '../utils/bib-key-generation'
|
||||
import type { BibProjectedEntry } from '../utils/bib-entry-position'
|
||||
import { validateBibSource } from '../utils/bib-source-diagnostics'
|
||||
import type { BibSourceDiagnostic } from '../utils/bib-source-diagnostics'
|
||||
import {
|
||||
getBibEntryDiagnostics,
|
||||
setBibEntryDiagnostics,
|
||||
} from '../utils/bib-entry-diagnostics'
|
||||
import { PositionedBibtexEntry } from '@/features/bibtex/positioned-bibtex-entry'
|
||||
import {
|
||||
planBibWrite,
|
||||
planBibFieldWrite,
|
||||
planBibBulkDelete,
|
||||
planBibImport,
|
||||
type BibFieldWriteTarget,
|
||||
} from '../utils/bib-write'
|
||||
import type { BibtexEntry } from '@shared/bibtex/bibtex-entry.mts'
|
||||
import { updateOccurrenceIndexes, nearestEntryAt, getEntryIdentityChanges } from '../utils/bib-editor-identity'
|
||||
import type { PreviousBibEntryIdentity, BibEntryIdentityChanges } from '../utils/bib-editor-identity'
|
||||
|
||||
import {
|
||||
BIB_ENTRIES_EVENT,
|
||||
BIB_WRITE_EVENT,
|
||||
BIB_DELETE_EVENT,
|
||||
BIB_IMPORT_EVENT,
|
||||
BIB_SCROLL_TO_EVENT,
|
||||
BIB_WRITE_FAILED_EVENT,
|
||||
BIB_HISTORY_STATE_EVENT,
|
||||
BIB_HISTORY_APPLIED_EVENT,
|
||||
BIB_REQUEST_STATE_EVENT,
|
||||
} from '../utils/bib-editor-events'
|
||||
import type { BibEntryIdRemap, BibHistoryUserEvent } from '../utils/bib-editor-events'
|
||||
|
||||
export {
|
||||
BIB_ENTRIES_EVENT,
|
||||
BIB_WRITE_EVENT,
|
||||
BIB_DELETE_EVENT,
|
||||
BIB_IMPORT_EVENT,
|
||||
BIB_SCROLL_TO_EVENT,
|
||||
BIB_WRITE_FAILED_EVENT,
|
||||
BIB_HISTORY_STATE_EVENT,
|
||||
BIB_HISTORY_APPLIED_EVENT,
|
||||
BIB_REQUEST_STATE_EVENT,
|
||||
} from '../utils/bib-editor-events'
|
||||
|
||||
type BibVisualTarget = {
|
||||
mode: 'existing' | 'new'
|
||||
entryId?: string
|
||||
sourceStart?: number
|
||||
/** Guarded field/header write; only this path can use the known entry range. */
|
||||
local?: boolean
|
||||
}
|
||||
|
||||
export const BIB_VISUAL_TARGET = Annotation.define<BibVisualTarget>()
|
||||
|
||||
// Bind each projection to the OT document that created this CodeMirror state.
|
||||
const bibDocumentId = Facet.define<string | null, string | null>({
|
||||
combine: values => values[0] ?? null,
|
||||
})
|
||||
|
||||
/**
|
||||
* Bridges one CodeMirror bibliography document and the React visual editor.
|
||||
*
|
||||
* The central invariant is that `this.view.state.doc` is authoritative. The
|
||||
* cached `entries` array is only a parsed projection for rendering and for
|
||||
* preserving entry identity across CodeMirror transactions.
|
||||
*/
|
||||
class BibEditorPlugin {
|
||||
private entries: PositionedBibtexEntry[] = []
|
||||
private dispatchHandler: ((ev: Event) => void) | null = null
|
||||
private scrollHandler: ((ev: Event) => void) | null = null
|
||||
private cancelScrollAlignment: (() => void) | null = null
|
||||
private requestStateHandler: ((ev: Event) => void) | null = null
|
||||
private destroyed = false
|
||||
private readonly documentId: string | null
|
||||
private revision = 0
|
||||
private diagnostics: BibSourceDiagnostic[] = []
|
||||
private validationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.documentId = view.state.facet(bibDocumentId)
|
||||
this.setupDispatchListener()
|
||||
this.setupScrollListener()
|
||||
const source = this.view.state.doc.toString()
|
||||
this.entries = this.parseEntries(source)
|
||||
updateOccurrenceIndexes(this.entries)
|
||||
this.diagnostics = validateBibSource(source)
|
||||
this.emitState()
|
||||
this.emitHistoryState()
|
||||
}
|
||||
|
||||
/** Write a departing preview's key against the current plugin projection.
|
||||
* Called only after the host verifies that this document still owns OT.
|
||||
* Never invoked by ordinary Code-mode edits or source parsing.
|
||||
*/
|
||||
commitKeyOnExit(entryId: string, draft?: BibtexEntry): void {
|
||||
if (this.destroyed || this.view.state.readOnly) return
|
||||
const current = this.entries.find(item => item.entry.id === entryId)
|
||||
if (!current) return
|
||||
const key = keyToCommitOnLeave(
|
||||
current,
|
||||
this.entries.map(item => item.entry.key),
|
||||
draft
|
||||
)
|
||||
if (!key) return
|
||||
const source = this.view.state.doc.toString()
|
||||
const guard = planBibFieldWrite(
|
||||
source.slice(current.from, current.to),
|
||||
current.entry.setKey(key),
|
||||
{ kind: 'citation-key' }
|
||||
)
|
||||
if (!guard.ok) return
|
||||
this.view.dispatch({
|
||||
changes: {
|
||||
from: current.from + guard.plan.from,
|
||||
to: current.from + guard.plan.to,
|
||||
insert: guard.plan.insert,
|
||||
},
|
||||
annotations: [
|
||||
Transaction.userEvent.of('input.complete'),
|
||||
BIB_VISUAL_TARGET.of({ mode: 'existing', entryId, local: true }),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reparse visual edits locally and try incremental projection for undo/redo.
|
||||
* Arbitrary structural changes fall back to a full parse. All three paths
|
||||
* reconcile occurrence identities and emit the resulting state once.
|
||||
*/
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged) return
|
||||
|
||||
this.revision++
|
||||
const oldEntries = this.entries
|
||||
// Save ids before recalculating duplicate-key occurrence indexes, since the
|
||||
// parsed entries can be reused across projections.
|
||||
const previousIdentities: PreviousBibEntryIdentity[] = oldEntries.map(entry => ({
|
||||
entryId: entry.entry.id,
|
||||
from: entry.from,
|
||||
to: entry.to,
|
||||
}))
|
||||
const visualTarget = update.transactions
|
||||
.map(transaction => transaction.annotation(BIB_VISUAL_TARGET))
|
||||
.find(target => target !== undefined)
|
||||
const visualUpdate = visualTarget
|
||||
? this.applyVisualChange(update, oldEntries, visualTarget)
|
||||
: null
|
||||
const historyEvent = update.transactions.find(transaction =>
|
||||
transaction.isUserEvent('undo') || transaction.isUserEvent('redo')
|
||||
)
|
||||
|
||||
if (visualUpdate) {
|
||||
this.entries = visualUpdate.entries
|
||||
} else {
|
||||
const incremental = historyEvent
|
||||
? this.applyIncrementalChange(update, oldEntries)
|
||||
: null
|
||||
this.entries = incremental ?? this.parseEntries(this.view.state.doc.toString())
|
||||
}
|
||||
|
||||
this.remapDiagnostics(update, oldEntries)
|
||||
updateOccurrenceIndexes(this.entries)
|
||||
if (visualUpdate) {
|
||||
const changedEntry = this.entries.find(
|
||||
entry => entry.from === visualUpdate.entryFrom
|
||||
)
|
||||
if (changedEntry) this.refreshEntryDiagnostics(changedEntry)
|
||||
}
|
||||
|
||||
const identityChanges = getEntryIdentityChanges(
|
||||
update.changes,
|
||||
previousIdentities,
|
||||
this.entries
|
||||
)
|
||||
this.emitState(identityChanges.entryIdRemaps, identityChanges.removedEntryIds)
|
||||
this.emitHistoryState()
|
||||
|
||||
if (!visualUpdate) {
|
||||
if (historyEvent) {
|
||||
this.emitHistoryApplied(update, historyEvent, previousIdentities, identityChanges)
|
||||
}
|
||||
this.scheduleFullValidation()
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
if (this.validationTimer !== null) clearTimeout(this.validationTimer)
|
||||
this.validationTimer = null
|
||||
if (this.dispatchHandler) {
|
||||
document.removeEventListener(BIB_WRITE_EVENT, this.dispatchHandler)
|
||||
document.removeEventListener(BIB_DELETE_EVENT, this.dispatchHandler)
|
||||
document.removeEventListener(BIB_IMPORT_EVENT, this.dispatchHandler)
|
||||
this.dispatchHandler = null
|
||||
}
|
||||
if (this.requestStateHandler) {
|
||||
document.removeEventListener(BIB_REQUEST_STATE_EVENT, this.requestStateHandler)
|
||||
this.requestStateHandler = null
|
||||
}
|
||||
if (this.scrollHandler) {
|
||||
document.removeEventListener(BIB_SCROLL_TO_EVENT, this.scrollHandler)
|
||||
this.scrollHandler = null
|
||||
}
|
||||
this.cancelScrollAlignment?.()
|
||||
this.cancelScrollAlignment = null
|
||||
}
|
||||
|
||||
private emitHistoryState() {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(BIB_HISTORY_STATE_EVENT, {
|
||||
detail: {
|
||||
canUndo: undoDepth(this.view.state) > 0,
|
||||
canRedo: redoDepth(this.view.state) > 0,
|
||||
view: this.view,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Emit the current parsed projection. Entry ids derive from citation keys and
|
||||
* their document-order occurrence indexes, so React receives explicit remaps
|
||||
* whenever an edit changes those ids.
|
||||
*/
|
||||
private emitState(
|
||||
entryIdRemaps?: BibEntryIdRemap[],
|
||||
removedEntryIds?: string[]
|
||||
) {
|
||||
const diagnosticByOccurrence = new Map<string, BibSourceDiagnostic[]>()
|
||||
const entryByStart = new Map(this.entries.map(entry => [entry.from, entry] as const))
|
||||
|
||||
// Keep all diagnostics for each entry, including independent syntax errors.
|
||||
for (const diagnostic of this.diagnostics) {
|
||||
const exact = diagnostic.entryFrom === undefined
|
||||
? undefined
|
||||
: entryByStart.get(diagnostic.entryFrom)
|
||||
const target = exact ?? nearestEntryAt(this.entries, diagnostic.from)
|
||||
if (!target) continue
|
||||
const messages = diagnosticByOccurrence.get(target.entry.id) ?? []
|
||||
messages.push(diagnostic)
|
||||
diagnosticByOccurrence.set(target.entry.id, messages)
|
||||
}
|
||||
|
||||
// Preserve unchanged positioned-entry objects. This still refreshes the
|
||||
// exported array, but avoids allocating a new class instance for every entry
|
||||
// when a local field edit changes diagnostics on only one occurrence.
|
||||
this.entries = this.entries.map(entry => {
|
||||
const nextDiagnostics = diagnosticByOccurrence.get(entry.entry.id) ?? []
|
||||
const oldDiagnostics = getBibEntryDiagnostics(entry)
|
||||
if (
|
||||
oldDiagnostics.length === nextDiagnostics.length &&
|
||||
oldDiagnostics.every((old, index) => {
|
||||
const next = nextDiagnostics[index]
|
||||
return old.from === next.from && old.line === next.line &&
|
||||
old.code === next.code && old.field === next.field &&
|
||||
old.relatedFrom === next.relatedFrom
|
||||
})
|
||||
) {
|
||||
return entry
|
||||
}
|
||||
const projected = this.makePositioned(
|
||||
entry.entry,
|
||||
entry.from,
|
||||
entry.to,
|
||||
nextDiagnostics,
|
||||
(entry as BibProjectedEntry).sourceFieldRanges
|
||||
) as BibProjectedEntry
|
||||
return projected
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(BIB_ENTRIES_EVENT, {
|
||||
detail: {
|
||||
isBibFile: true,
|
||||
documentId: this.documentId,
|
||||
entries: this.entries,
|
||||
revision: this.revision,
|
||||
...(entryIdRemaps !== undefined
|
||||
? { entryIdRemaps, removedEntryIds: removedEntryIds ?? [] }
|
||||
: {}),
|
||||
view: this.view,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve diagnostics for entries untouched by a transaction and map their
|
||||
* absolute coordinates through the same CodeMirror changes as entry ranges.
|
||||
* Diagnostics belonging to edited entries are recalculated separately or by
|
||||
* the debounced full-document validation.
|
||||
*/
|
||||
private remapDiagnostics(update: ViewUpdate, previousEntries: PositionedBibtexEntry[]) {
|
||||
const changed: Array<{ from: number; to: number }> = []
|
||||
update.changes.iterChangedRanges((fromA, toA) => changed.push({ from: fromA, to: toA }))
|
||||
|
||||
const previousEntryByStart = new Map(previousEntries.map(entry => [entry.from, entry] as const))
|
||||
this.diagnostics = this.diagnostics
|
||||
.filter(diagnostic => {
|
||||
const owner = diagnostic.entryFrom === undefined
|
||||
? undefined
|
||||
: previousEntryByStart.get(diagnostic.entryFrom)
|
||||
if (owner) {
|
||||
return !changed.some(range =>
|
||||
range.from < owner.to && range.to > owner.from ||
|
||||
range.from === range.to && range.from > owner.from && range.from < owner.to
|
||||
)
|
||||
}
|
||||
return !changed.some(range => diagnostic.from >= range.from && diagnostic.from <= range.to)
|
||||
})
|
||||
.map(diagnostic => {
|
||||
const from = update.changes.mapPos(diagnostic.from, 1)
|
||||
const entryFrom = diagnostic.entryFrom === undefined
|
||||
? undefined
|
||||
: update.changes.mapPos(diagnostic.entryFrom, 1)
|
||||
const relatedFrom = diagnostic.relatedFrom === undefined
|
||||
? undefined
|
||||
: update.changes.mapPos(diagnostic.relatedFrom, 1)
|
||||
return this.withCurrentLine({ ...diagnostic, from, entryFrom, relatedFrom })
|
||||
})
|
||||
.sort((a, b) => a.from - b.from)
|
||||
}
|
||||
|
||||
private withCurrentLine(diagnostic: BibSourceDiagnostic): BibSourceDiagnostic {
|
||||
const document = this.view.state.doc
|
||||
const lineAt = (from: number) =>
|
||||
document.lineAt(Math.max(0, Math.min(from, document.length))).number
|
||||
return {
|
||||
...diagnostic,
|
||||
line: lineAt(diagnostic.from),
|
||||
relatedLine: diagnostic.relatedFrom === undefined
|
||||
? undefined
|
||||
: lineAt(diagnostic.relatedFrom),
|
||||
}
|
||||
}
|
||||
|
||||
/** Revalidate the changed entry only; ordinary visual keystrokes avoid a full-file scan. */
|
||||
private refreshEntryDiagnostics(entry: PositionedBibtexEntry) {
|
||||
const raw = this.view.state.sliceDoc(entry.from, entry.to)
|
||||
const diagnostics = validateBibSource(raw).map(diagnostic => {
|
||||
const from = entry.from + diagnostic.from
|
||||
return this.withCurrentLine({
|
||||
...diagnostic,
|
||||
from,
|
||||
entryFrom: entry.from,
|
||||
relatedFrom: diagnostic.relatedFrom === undefined
|
||||
? undefined
|
||||
: entry.from + diagnostic.relatedFrom,
|
||||
})
|
||||
})
|
||||
this.diagnostics.push(...diagnostics)
|
||||
this.diagnostics.sort((a, b) => a.from - b.from)
|
||||
}
|
||||
|
||||
/** Code-mode edits can change structure anywhere; coalesce a full validation. */
|
||||
private scheduleFullValidation() {
|
||||
if (this.validationTimer !== null) clearTimeout(this.validationTimer)
|
||||
this.validationTimer = setTimeout(() => {
|
||||
this.validationTimer = null
|
||||
this.diagnostics = validateBibSource(this.view.state.doc.toString())
|
||||
this.emitState()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
private parseEntries(
|
||||
source: string,
|
||||
range?: { from: number; to: number },
|
||||
trustedWindow?: { from: number; to: number }
|
||||
): PositionedBibtexEntry[] {
|
||||
try {
|
||||
const parsed = parseBibFile(source, range, trustedWindow)
|
||||
const doc = this.view.state.doc
|
||||
return parsed.map(entry =>
|
||||
Object.assign(entry, {
|
||||
line: doc.lineAt(entry.from).number,
|
||||
toLine: doc.lineAt(entry.to).number,
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
// Malformed source may temporarily have no safe visual projection. The
|
||||
// original text remains untouched in CodeMirror and can still be edited
|
||||
// from the source editor.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private makePositioned(
|
||||
entry: BibtexEntry,
|
||||
from: number,
|
||||
to: number,
|
||||
diagnostics: BibSourceDiagnostic[] = [],
|
||||
sourceFieldRanges?: BibProjectedEntry['sourceFieldRanges']
|
||||
): PositionedBibtexEntry {
|
||||
const doc = this.view.state.doc
|
||||
const positioned = Object.assign(new PositionedBibtexEntry(), {
|
||||
entry,
|
||||
from,
|
||||
to,
|
||||
line: doc.lineAt(from).number,
|
||||
toLine: doc.lineAt(to).number,
|
||||
})
|
||||
if (sourceFieldRanges) {
|
||||
(positioned as BibProjectedEntry).sourceFieldRanges = sourceFieldRanges
|
||||
}
|
||||
setBibEntryDiagnostics(positioned, diagnostics)
|
||||
return positioned
|
||||
}
|
||||
|
||||
/** Remap an unchanged entry through a CodeMirror document change. */
|
||||
private mapEntry(update: ViewUpdate, entry: PositionedBibtexEntry) {
|
||||
return this.makePositioned(
|
||||
entry.entry,
|
||||
update.changes.mapPos(entry.from, 1),
|
||||
update.changes.mapPos(entry.to, -1),
|
||||
getBibEntryDiagnostics(entry),
|
||||
(entry as BibProjectedEntry).sourceFieldRanges
|
||||
)
|
||||
}
|
||||
|
||||
/** Reparse the visual edit's entry; fall back to a full parse if it is lost. */
|
||||
private applyVisualChange(
|
||||
update: ViewUpdate,
|
||||
oldEntries: PositionedBibtexEntry[],
|
||||
target: BibVisualTarget
|
||||
): { entries: PositionedBibtexEntry[]; entryFrom: number } | null {
|
||||
const source = this.view.state.doc.toString()
|
||||
const previous = target.mode === 'existing'
|
||||
? oldEntries.find(entry => entry.entry.id === target.entryId)
|
||||
: undefined
|
||||
if (target.mode === 'existing' && !previous) return null
|
||||
|
||||
const sourceStart = previous
|
||||
? update.changes.mapPos(previous.from, 1)
|
||||
: target.sourceStart
|
||||
if (sourceStart === undefined) return null
|
||||
|
||||
let parsed: BibProjectedEntry | null = null
|
||||
if (previous && target.local) {
|
||||
// The field-write planner has already checked a single source entry.
|
||||
// Map its old end through the same transaction instead of reparsing the
|
||||
// entire bibliography to rediscover a boundary we already own.
|
||||
const end = update.changes.mapPos(previous.to, -1)
|
||||
if (end > sourceStart) {
|
||||
parsed = parseBibEntryInRange(source, sourceStart, end)
|
||||
}
|
||||
}
|
||||
// Whole-entry replacements and edits that change an entry's structure
|
||||
// must still rediscover boundaries from the full document.
|
||||
if (!parsed) parsed = parseBibEntryAt(source, sourceStart)
|
||||
if (!parsed) return null
|
||||
|
||||
const entries = oldEntries.map(entry =>
|
||||
entry === previous
|
||||
? this.makePositioned(parsed.entry, parsed.from, parsed.to, [], parsed.sourceFieldRanges)
|
||||
: this.mapEntry(update, entry)
|
||||
)
|
||||
if (target.mode === 'new') {
|
||||
entries.push(this.makePositioned(parsed.entry, parsed.from, parsed.to, [], parsed.sourceFieldRanges))
|
||||
}
|
||||
entries.sort((a, b) => a.from - b.from)
|
||||
return { entries, entryFrom: parsed.from }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only entries touched by an undo/redo transaction. If a transaction
|
||||
* changes something that cannot be projected safely from the previous entry
|
||||
* ranges, return null and let the normal full parse handle it.
|
||||
*/
|
||||
private applyIncrementalChange(
|
||||
update: ViewUpdate,
|
||||
oldEntries: PositionedBibtexEntry[]
|
||||
): PositionedBibtexEntry[] | null {
|
||||
const changes: Array<{
|
||||
fromA: number
|
||||
toA: number
|
||||
fromB: number
|
||||
toB: number
|
||||
}> = []
|
||||
|
||||
update.changes.iterChanges((fromA, toA, fromB, toB) => {
|
||||
changes.push({ fromA, toA, fromB, toB })
|
||||
})
|
||||
|
||||
if (changes.length !== 1) return null
|
||||
|
||||
const change = changes[0]
|
||||
const source = this.view.state.doc.toString()
|
||||
const next: PositionedBibtexEntry[] = []
|
||||
|
||||
for (const entry of oldEntries) {
|
||||
const mappedFrom = update.changes.mapPos(entry.from, 1)
|
||||
const mappedTo = update.changes.mapPos(entry.to, -1)
|
||||
const affected = change.fromA <= entry.to && change.toA >= entry.from
|
||||
if (!affected) {
|
||||
// Entries before the edit retain both offsets and content. Reuse their
|
||||
// positioned model instead of allocating a wrapper on every undo/redo.
|
||||
next.push(
|
||||
mappedFrom === entry.from && mappedTo === entry.to
|
||||
? entry
|
||||
: this.mapEntry(update, entry)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (mappedTo <= mappedFrom) continue
|
||||
|
||||
const parsed = parseBibEntryAt(source, mappedFrom, mappedTo)
|
||||
if (!parsed) return null
|
||||
|
||||
next.push(this.makePositioned(parsed.entry, parsed.from, parsed.to, [], parsed.sourceFieldRanges))
|
||||
}
|
||||
|
||||
if (change.toB > change.fromB) {
|
||||
// Surviving, trusted entries delimit a self-contained local parse.
|
||||
// If a neighbor is malformed, use the full Lezer projection instead.
|
||||
const before = [...next].reverse().find(entry => entry.to <= change.fromB)
|
||||
const after = next.find(entry => entry.from >= change.toB)
|
||||
const trusted = (!before || (before as BibProjectedEntry).sourceFieldRanges) &&
|
||||
(!after || (after as BibProjectedEntry).sourceFieldRanges)
|
||||
const window = trusted ? {
|
||||
from: before?.to ?? 0,
|
||||
to: after?.from ?? source.length,
|
||||
} : undefined
|
||||
const inserted = this.parseEntries(source, {
|
||||
from: change.fromB,
|
||||
to: change.toB,
|
||||
}, window)
|
||||
const existingStarts = new Set(next.map(entry => entry.from))
|
||||
for (const entry of inserted) {
|
||||
if (
|
||||
existingStarts.has(entry.from) ||
|
||||
next.some(current => entry.from > current.from && entry.from < current.to)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
next.push(entry)
|
||||
existingStarts.add(entry.from)
|
||||
}
|
||||
}
|
||||
|
||||
next.sort((a, b) => a.from - b.from)
|
||||
const doc = this.view.state.doc
|
||||
return next.map(entry => {
|
||||
const line = doc.lineAt(entry.from).number
|
||||
const toLine = doc.lineAt(entry.to).number
|
||||
if (entry.line === line && entry.toLine === toLine) return entry
|
||||
return this.makePositioned(
|
||||
entry.entry,
|
||||
entry.from,
|
||||
entry.to,
|
||||
getBibEntryDiagnostics(entry),
|
||||
(entry as BibProjectedEntry).sourceFieldRanges
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private emitHistoryApplied(
|
||||
update: ViewUpdate,
|
||||
historyEvent: Transaction,
|
||||
oldEntries: PreviousBibEntryIdentity[],
|
||||
identityChanges: BibEntryIdentityChanges
|
||||
) {
|
||||
const changedRanges: Array<{ from: number; to: number }> = []
|
||||
update.changes.iterChangedRanges((from, to) => {
|
||||
changedRanges.push({ from, to })
|
||||
})
|
||||
|
||||
// Emit one reset for every existing entry touched by this history step.
|
||||
// A single undo transaction can change more than one range or entry.
|
||||
const kind = historyEvent.isUserEvent('undo') ? 'undo' : 'redo'
|
||||
const emitted = new Set<string>()
|
||||
for (const previous of oldEntries) {
|
||||
const wasChanged = changedRanges.some(({ from, to }) =>
|
||||
from === to
|
||||
? from >= previous.from && from < previous.to
|
||||
: from < previous.to && to > previous.from
|
||||
)
|
||||
if (!wasChanged || emitted.has(previous.entryId)) continue
|
||||
|
||||
const current = identityChanges.currentByPreviousId.get(previous.entryId)
|
||||
if (!current) continue
|
||||
|
||||
emitted.add(previous.entryId)
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(BIB_HISTORY_APPLIED_EVENT, {
|
||||
detail: {
|
||||
kind,
|
||||
// Key changes can remount the form before React sees this event.
|
||||
// Include both identities so listeners can invalidate the old draft.
|
||||
previousEntryId: previous.entryId,
|
||||
entryId: current.entry.id,
|
||||
entry: current.entry,
|
||||
view: this.view,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive write/import/delete requests from React. Live field edits deliberately
|
||||
* do not carry an old document snapshot: rapid typing can produce another
|
||||
* transaction before React renders the previous one. Other writes and imports
|
||||
* honor the supplied snapshot guards so they cannot apply stale data.
|
||||
*/
|
||||
private setupDispatchListener() {
|
||||
this.dispatchHandler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { view: EditorView; accepted?: boolean }
|
||||
| undefined
|
||||
if (!detail || detail.view !== this.view || this.view.state.readOnly) return
|
||||
|
||||
const {
|
||||
entry,
|
||||
entries,
|
||||
mode,
|
||||
entryId,
|
||||
expectedEntry,
|
||||
expectedRevision,
|
||||
expectedDocument,
|
||||
historyUserEvent,
|
||||
fieldTarget,
|
||||
} = detail as {
|
||||
entry?: BibtexEntry
|
||||
entries?: BibtexEntry[]
|
||||
mode: 'existing' | 'new'
|
||||
entryId?: string
|
||||
expectedEntry?: BibtexEntry
|
||||
expectedRevision?: number
|
||||
expectedDocument?: unknown
|
||||
historyUserEvent?: BibHistoryUserEvent
|
||||
fieldTarget?: BibFieldWriteTarget
|
||||
}
|
||||
|
||||
// Existing field writes target an entry id resolved against the current
|
||||
// CodeMirror projection. They intentionally tolerate a React
|
||||
// render lag while the user types. Revision/document guards are reserved
|
||||
// for structural operations that must not apply to a stale snapshot.
|
||||
const isLiveFieldWrite = ev.type === BIB_WRITE_EVENT &&
|
||||
mode === 'existing' && fieldTarget !== undefined &&
|
||||
entryId !== undefined
|
||||
if (
|
||||
!isLiveFieldWrite &&
|
||||
ev.type !== BIB_DELETE_EVENT &&
|
||||
((typeof expectedRevision === 'number' &&
|
||||
expectedRevision !== this.revision) ||
|
||||
(expectedDocument !== undefined &&
|
||||
expectedDocument !== this.view.state.doc))
|
||||
) {
|
||||
this.emitWriteFailed('doc-changed')
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (ev.type === BIB_WRITE_EVENT) {
|
||||
if (!entry) return
|
||||
|
||||
const source = this.view.state.doc.toString()
|
||||
const current = entryId === undefined
|
||||
? undefined
|
||||
: this.entries.find(candidate => candidate.entry.id === entryId)
|
||||
const isFieldWrite = mode === 'existing' && fieldTarget !== undefined &&
|
||||
entryId !== undefined
|
||||
if (isFieldWrite && (!current || (expectedEntry && current.entry !== expectedEntry))) {
|
||||
this.emitWriteFailed('entry-gone')
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Clearing a field that exists only in the form is a source no-op.
|
||||
if (
|
||||
isFieldWrite && current && fieldTarget.kind === 'field' &&
|
||||
!entry.hasField(fieldTarget.name) &&
|
||||
!current.entry.hasField(fieldTarget.name)
|
||||
) {
|
||||
detail.accepted = true
|
||||
return
|
||||
}
|
||||
|
||||
const offset = isFieldWrite && current ? current.from : 0
|
||||
const guard = isFieldWrite && current
|
||||
? planBibFieldWrite(
|
||||
source.slice(current.from, current.to),
|
||||
entry,
|
||||
fieldTarget,
|
||||
fieldTarget.kind === 'field' && current.entry.hasField(fieldTarget.name)
|
||||
)
|
||||
: planBibWrite(source, entry, mode, current?.from)
|
||||
|
||||
if (!guard.ok) {
|
||||
this.emitWriteFailed(guard.reason)
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
this.view.dispatch({
|
||||
changes: {
|
||||
from: offset + guard.plan.from,
|
||||
to: offset + guard.plan.to,
|
||||
insert: guard.plan.insert,
|
||||
},
|
||||
annotations: [
|
||||
Transaction.userEvent.of(
|
||||
historyUserEvent ?? (mode === 'new' ? 'input.complete' : 'input.type')
|
||||
),
|
||||
BIB_VISUAL_TARGET.of(mode === 'existing'
|
||||
? { mode, entryId, local: isFieldWrite }
|
||||
: { mode, sourceStart: guard.plan.sourceStart ?? guard.plan.from }),
|
||||
],
|
||||
})
|
||||
detail.accepted = true
|
||||
} else if (ev.type === BIB_IMPORT_EVENT) {
|
||||
const entriesArg = entries ?? []
|
||||
if (entriesArg.length === 0) return
|
||||
|
||||
const source = this.view.state.doc.toString()
|
||||
const guard = planBibImport(source, entriesArg)
|
||||
if (guard.ok === false) {
|
||||
this.emitWriteFailed(guard.reason)
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
this.view.dispatch({
|
||||
changes: guard.changes,
|
||||
annotations: Transaction.userEvent.of('input.complete'),
|
||||
})
|
||||
detail.accepted = true
|
||||
} else if (ev.type === BIB_DELETE_EVENT) {
|
||||
const { entryIds } = detail as { entryIds?: string[] }
|
||||
const ids = entryIds ?? (entryId ? [entryId] : [])
|
||||
if (!entryIds && ids.length === 0) {
|
||||
this.emitWriteFailed('entry-gone')
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const byId = new Map(this.entries.map(entry => [entry.entry.id, entry] as const))
|
||||
const starts = ids
|
||||
.map(id => byId.get(id)?.from)
|
||||
.filter((start): start is number => start !== undefined)
|
||||
if (starts.length !== ids.length) {
|
||||
this.emitWriteFailed('entry-gone')
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const guard = planBibBulkDelete(
|
||||
this.view.state.doc.toString(),
|
||||
starts
|
||||
)
|
||||
if (!guard.ok) {
|
||||
this.emitWriteFailed(guard.reason)
|
||||
return
|
||||
}
|
||||
if (guard.changes.length === 0) return
|
||||
|
||||
this.view.dispatch({
|
||||
changes: guard.changes,
|
||||
annotations: Transaction.userEvent.of('delete'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(BIB_WRITE_EVENT, this.dispatchHandler)
|
||||
document.addEventListener(BIB_DELETE_EVENT, this.dispatchHandler)
|
||||
document.addEventListener(BIB_IMPORT_EVENT, this.dispatchHandler)
|
||||
|
||||
this.requestStateHandler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { view?: EditorView }
|
||||
| undefined
|
||||
if (detail?.view !== this.view) return
|
||||
// Re-run structural diagnostics whenever the visual panel requests a
|
||||
// fresh projection. This covers returning from source mode even if its
|
||||
// debounced validation was cancelled by an editor/view lifecycle change.
|
||||
if (this.validationTimer !== null) clearTimeout(this.validationTimer)
|
||||
this.validationTimer = null
|
||||
this.diagnostics = validateBibSource(this.view.state.doc.toString())
|
||||
this.emitState()
|
||||
this.emitHistoryState()
|
||||
}
|
||||
document.addEventListener(BIB_REQUEST_STATE_EVENT, this.requestStateHandler)
|
||||
}
|
||||
|
||||
private emitWriteFailed(reason: string) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(BIB_WRITE_FAILED_EVENT, {
|
||||
detail: { reason, view: this.view },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private setupScrollListener() {
|
||||
this.scrollHandler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { view?: EditorView; position?: number; entryId?: string }
|
||||
| undefined
|
||||
if (!detail || detail.view !== this.view) return
|
||||
|
||||
// A new request supersedes any pending alignment for this view.
|
||||
this.cancelScrollAlignment?.()
|
||||
this.cancelScrollAlignment = null
|
||||
|
||||
const resolvePosition = () => {
|
||||
if (detail.entryId) {
|
||||
return this.entries.find(
|
||||
entry => entry.entry.id === detail.entryId
|
||||
)?.from
|
||||
}
|
||||
return typeof detail.position === 'number'
|
||||
? detail.position
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (resolvePosition() === undefined) return
|
||||
|
||||
// Do not issue the scroll effect until CodeMirror has a real layout. After
|
||||
// alignment, ResizeObserver keeps the target at the top while the revealed
|
||||
// editor is resizing, then releases the request once layout settles.
|
||||
let cancelled = false
|
||||
let frame = 0
|
||||
let scrollIssued = false
|
||||
let settledTimer: number | null = null
|
||||
const startedAt = performance.now()
|
||||
let observer: ResizeObserver | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
if (settledTimer !== null) window.clearTimeout(settledTimer)
|
||||
observer?.disconnect()
|
||||
if (this.cancelScrollAlignment === cleanup) {
|
||||
this.cancelScrollAlignment = null
|
||||
}
|
||||
}
|
||||
this.cancelScrollAlignment = cleanup
|
||||
|
||||
const releaseAfterLayoutSettles = () => {
|
||||
if (settledTimer !== null) window.clearTimeout(settledTimer)
|
||||
// A resize during this grace period cancels the timer and realigns again.
|
||||
settledTimer = window.setTimeout(cleanup, 250)
|
||||
}
|
||||
|
||||
const scheduleAlignment = () => {
|
||||
if (cancelled || frame) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
if (cancelled) return
|
||||
if (resolvePosition() === undefined) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
if (!this.view.dom.isConnected) {
|
||||
// The source editor may be detached briefly while React changes modes.
|
||||
if (performance.now() - startedAt >= 2000) cleanup()
|
||||
else scheduleAlignment()
|
||||
return
|
||||
}
|
||||
|
||||
const viewport = this.view.scrollDOM
|
||||
const editorRect = this.view.dom.getBoundingClientRect()
|
||||
if (viewport.clientHeight === 0 || editorRect.height === 0) {
|
||||
// Stay pending while the editor is hidden; ResizeObserver will also
|
||||
// wake this request as soon as the source editor becomes measurable.
|
||||
if (performance.now() - startedAt >= 2000) cleanup()
|
||||
else scheduleAlignment()
|
||||
return
|
||||
}
|
||||
|
||||
const livePosition = resolvePosition()
|
||||
if (livePosition === undefined) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
const pos = Math.min(
|
||||
Math.max(0, livePosition),
|
||||
this.view.state.doc.length
|
||||
)
|
||||
|
||||
if (!scrollIssued) {
|
||||
this.view.dispatch({
|
||||
selection: { anchor: pos },
|
||||
effects: EditorView.scrollIntoView(pos, {
|
||||
y: 'start',
|
||||
yMargin: 0,
|
||||
}),
|
||||
})
|
||||
this.view.focus()
|
||||
scrollIssued = true
|
||||
}
|
||||
|
||||
// CodeMirror batches this DOM read and the exact scroll correction in
|
||||
// its own measure cycle, avoiding a race with its built-in scroll effect.
|
||||
this.view.requestMeasure({
|
||||
read: () => {
|
||||
const currentPosition = resolvePosition()
|
||||
if (currentPosition === undefined) return null
|
||||
const currentPos = Math.min(
|
||||
Math.max(0, currentPosition),
|
||||
this.view.state.doc.length
|
||||
)
|
||||
const coords = this.view.coordsAtPos(currentPos)
|
||||
const scrollRect = viewport.getBoundingClientRect()
|
||||
const currentEditorRect = this.view.dom.getBoundingClientRect()
|
||||
return {
|
||||
delta: coords ? coords.top - scrollRect.top : null,
|
||||
usable:
|
||||
viewport.clientHeight > 0 && currentEditorRect.height > 0,
|
||||
}
|
||||
},
|
||||
write: measurement => {
|
||||
if (cancelled) return
|
||||
if (!measurement || !measurement.usable || measurement.delta === null) {
|
||||
scrollIssued = false
|
||||
if (performance.now() - startedAt >= 2000) cleanup()
|
||||
else scheduleAlignment()
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.abs(measurement.delta) > 1) {
|
||||
const before = viewport.scrollTop
|
||||
viewport.scrollTop = before + measurement.delta
|
||||
const after = viewport.scrollTop
|
||||
const maxScrollTop = viewport.scrollHeight - viewport.clientHeight
|
||||
const cannotScrollFurther =
|
||||
after === before &&
|
||||
((measurement.delta < 0 && before <= 0) ||
|
||||
(measurement.delta > 0 && before >= maxScrollTop - 1))
|
||||
|
||||
// At the end of a document there may not be enough content below
|
||||
// the target to put it at the viewport top. Do not retry forever.
|
||||
if (cannotScrollFurther) {
|
||||
releaseAfterLayoutSettles()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
releaseAfterLayoutSettles()
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(() => {
|
||||
if (settledTimer !== null) {
|
||||
window.clearTimeout(settledTimer)
|
||||
settledTimer = null
|
||||
}
|
||||
scheduleAlignment()
|
||||
})
|
||||
observer.observe(this.view.dom)
|
||||
observer.observe(this.view.scrollDOM)
|
||||
}
|
||||
scheduleAlignment()
|
||||
}
|
||||
document.addEventListener(BIB_SCROLL_TO_EVENT, this.scrollHandler)
|
||||
}
|
||||
}
|
||||
|
||||
const bibEditorPlugin = ViewPlugin.fromClass(BibEditorPlugin)
|
||||
|
||||
export function commitBibKeyOnExit(view: EditorView, entryId: string, draft?: BibtexEntry): void {
|
||||
view.plugin(bibEditorPlugin)?.commitKeyOnExit(entryId, draft)
|
||||
}
|
||||
|
||||
export const extension = (options?: {
|
||||
currentDoc?: { currentDoc?: { doc_id: string } }
|
||||
}): Extension => [
|
||||
bibEditorPlugin,
|
||||
bibDocumentId.of(options?.currentDoc?.currentDoc?.doc_id ?? null),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Bind generic ORCID results to the guarded atomic BibTeX editor import. */
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useBibEditorContext } from '../../context/bib-editor-context'
|
||||
import type { OrcidImportItem, ImportResult } from '../orcid-api'
|
||||
import { inspectOrcidImport, prepareOrcidImport } from '../../utils/bib-orcid-import'
|
||||
|
||||
type ImportGuard = {
|
||||
documentId: string
|
||||
revision: number
|
||||
doc: unknown
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
export function useBibOrcidImport(show: boolean) {
|
||||
const { entries, revision, importMany } = useBibEditorContext()
|
||||
const { currentDocumentId } = useEditorOpenDocContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const guardRef = useRef<ImportGuard | null>(null)
|
||||
const entriesRef = useRef(entries)
|
||||
entriesRef.current = entries
|
||||
|
||||
// Capture the document at opening, not at import time. An unrelated edit or
|
||||
// document switch must not redirect an in-flight ORCID import to another file.
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
guardRef.current = null
|
||||
} else if (!guardRef.current && currentDocumentId) {
|
||||
guardRef.current = {
|
||||
documentId: currentDocumentId,
|
||||
revision,
|
||||
doc: view.state.doc,
|
||||
keys: entriesRef.current.map(item => item.entry.key),
|
||||
}
|
||||
}
|
||||
}, [show, currentDocumentId, revision, view])
|
||||
|
||||
const handleInspect = useCallback((item: OrcidImportItem) => inspectOrcidImport(item), [])
|
||||
|
||||
const handleInsert = useCallback(
|
||||
(items: OrcidImportItem[]): ImportResult | false => {
|
||||
const guard = guardRef.current
|
||||
if (!guard || guard.documentId !== currentDocumentId ||
|
||||
guard.doc !== view.state.doc) return false
|
||||
const prepared = prepareOrcidImport(items, guard.keys)
|
||||
if (!prepared) return false
|
||||
const committed = importMany({
|
||||
entries: prepared.entries,
|
||||
expectedRevision: guard.revision,
|
||||
expectedDocument: guard.doc,
|
||||
})
|
||||
return committed ? {
|
||||
imported: prepared.entries.length,
|
||||
enriched: prepared.enriched,
|
||||
unusableIndices: prepared.unusableIndices,
|
||||
skippedIndices: prepared.skippedIndices,
|
||||
} : false
|
||||
},
|
||||
[importMany, currentDocumentId, view]
|
||||
)
|
||||
|
||||
return { onInspect: handleInspect, onInsert: handleInsert }
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/** ORCID search, selection and enrichment state for the BibTeX import modal. */
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { withWorkSummary, workMetadataScore, hasMissingWorkFields } from '../work-metadata'
|
||||
import { searchOrcidAuthors, fetchOrcidWorks, fetchOrcidWorkDetails, fetchDoiMetadataBatch, isValidOrcid, parseImportItem, workDoi, normalizedDoi, isRateLimit, errorMessage } from '../orcid-api'
|
||||
import type { AuthorResult, Work, WorkVariant, OrcidImportItem, ImportResult } from '../orcid-api'
|
||||
|
||||
type OrcidInspection = { needsMetadata: boolean }
|
||||
type OrcidPickerOptions = {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
onInspect: (item: OrcidImportItem) => OrcidInspection | null
|
||||
onInsert: (items: OrcidImportItem[]) => ImportResult | false | Promise<ImportResult | false>
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
type Step = 'search' | 'works'
|
||||
const ORCID_BATCH_SIZE = 100
|
||||
type CrossrefFailure = 'rate-limit' | 'unavailable'
|
||||
type FailedEnrichment = { title: string; reason: CrossrefFailure }
|
||||
type ImportSummary = { imported: number; enriched: number }
|
||||
|
||||
export function useOrcidPicker({ show, onHide, onInsert, onInspect, t }: OrcidPickerOptions) {
|
||||
// -- step state --
|
||||
const [step, setStep] = useState<Step>('search')
|
||||
|
||||
// -- search step --
|
||||
const [searchMode, setSearchMode] = useState<'name' | 'orcid'>('name')
|
||||
const [nameQuery, setNameQuery] = useState('')
|
||||
const [orcidInput, setOrcidInput] = useState('')
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const [authors, setAuthors] = useState<AuthorResult[]>([])
|
||||
|
||||
// -- works step --
|
||||
const [selectedOrcid, setSelectedOrcid] = useState<string | null>(null)
|
||||
const [selectedAuthorName, setSelectedAuthorName] = useState('')
|
||||
const [works, setWorks] = useState<Work[]>([])
|
||||
const [worksLoading, setWorksLoading] = useState(false)
|
||||
const [worksError, setWorksError] = useState<string | null>(null)
|
||||
// Keep successful details across failed imports, avoiding repeat ORCID calls.
|
||||
const workDetails = useRef(new Map<number, OrcidImportItem>())
|
||||
// Retry large failed ORCID batches in smaller groups on the next user click.
|
||||
// No automatic retry (especially after HTTP 429).
|
||||
const retryBatchSizes = useRef(new Map<number, number>())
|
||||
const [selectedPutCodes, setSelectedPutCodes] = useState<Set<number>>(
|
||||
new Set()
|
||||
)
|
||||
|
||||
// -- import state --
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importError, setImportError] = useState<string | null>(null)
|
||||
const [importSummary, setImportSummary] = useState<ImportSummary | null>(null)
|
||||
const [crossrefFailures, setCrossrefFailures] = useState<FailedEnrichment[]>([])
|
||||
const [unimportedTitles, setUnimportedTitles] = useState<string[]>([])
|
||||
const requests = useRef<Partial<Record<'search' | 'works' | 'import', AbortController>>>({})
|
||||
|
||||
const cancelRequest = (kind: 'search' | 'works' | 'import') => {
|
||||
requests.current[kind]?.abort()
|
||||
delete requests.current[kind]
|
||||
}
|
||||
|
||||
const cancelAll = useCallback(() => {
|
||||
for (const controller of Object.values(requests.current)) controller?.abort()
|
||||
requests.current = {}
|
||||
}, [])
|
||||
|
||||
const resetPicker = useCallback(() => {
|
||||
cancelAll()
|
||||
setStep('search')
|
||||
setSearchMode('name')
|
||||
setNameQuery('')
|
||||
setOrcidInput('')
|
||||
setSearchLoading(false)
|
||||
setSearchError(null)
|
||||
setAuthors([])
|
||||
setSelectedOrcid(null)
|
||||
setSelectedAuthorName('')
|
||||
setWorks([])
|
||||
setWorksLoading(false)
|
||||
setWorksError(null)
|
||||
workDetails.current.clear()
|
||||
retryBatchSizes.current.clear()
|
||||
setSelectedPutCodes(new Set())
|
||||
setImporting(false)
|
||||
setImportError(null)
|
||||
setImportSummary(null)
|
||||
setCrossrefFailures([])
|
||||
setUnimportedTitles([])
|
||||
}, [cancelAll])
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) resetPicker()
|
||||
return cancelAll
|
||||
}, [show, resetPicker, cancelAll])
|
||||
|
||||
const handleClose = () => {
|
||||
resetPicker()
|
||||
onHide()
|
||||
}
|
||||
|
||||
const handleSearchModeChange = (mode: 'name' | 'orcid') => {
|
||||
cancelRequest('search')
|
||||
setSearchLoading(false)
|
||||
setSearchMode(mode)
|
||||
setSearchError(null)
|
||||
setAuthors([])
|
||||
}
|
||||
|
||||
// Search by name
|
||||
const handleSearchByName = async () => {
|
||||
if (!nameQuery.trim() || searchLoading) return
|
||||
cancelRequest('search')
|
||||
const controller = new AbortController()
|
||||
requests.current.search = controller
|
||||
setSearchError(null)
|
||||
setAuthors([])
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
const data = await searchOrcidAuthors(nameQuery.trim(), controller.signal)
|
||||
if (requests.current.search !== controller) return
|
||||
setAuthors(data.results)
|
||||
if (data.results.length === 0) {
|
||||
setSearchError(t('no_authors_found'))
|
||||
}
|
||||
} catch (err) {
|
||||
if (requests.current.search === controller) {
|
||||
setSearchError(errorMessage(err, t('search_failed'), t('invalid_response_from_server'), t('provider_rate_limited')))
|
||||
}
|
||||
} finally {
|
||||
if (requests.current.search === controller) {
|
||||
delete requests.current.search
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the one ORCID works-summary request runs when opening the list.
|
||||
// Full details and DOI resolution are deferred to Import.
|
||||
const handlePickAuthor = async (orcid: string, displayName: string) => {
|
||||
cancelRequest('works')
|
||||
const controller = new AbortController()
|
||||
requests.current.works = controller
|
||||
setSelectedOrcid(orcid)
|
||||
setSelectedAuthorName(displayName)
|
||||
setStep('works')
|
||||
setWorksError(null)
|
||||
setImportError(null)
|
||||
setImportSummary(null)
|
||||
setCrossrefFailures([])
|
||||
setUnimportedTitles([])
|
||||
setWorks([])
|
||||
setSelectedPutCodes(new Set())
|
||||
workDetails.current.clear()
|
||||
retryBatchSizes.current.clear()
|
||||
setWorksLoading(true)
|
||||
try {
|
||||
const data = await fetchOrcidWorks(orcid, controller.signal)
|
||||
if (requests.current.works !== controller) return
|
||||
const returnedWorks = data.works
|
||||
setWorks(returnedWorks)
|
||||
if (!returnedWorks.length) setWorksError(t('no_works_found_for_this_author'))
|
||||
} catch (err) {
|
||||
if (requests.current.works === controller) {
|
||||
setWorksError(errorMessage(err, t('failed_to_load_works'), t('invalid_response_from_server'), t('provider_rate_limited')))
|
||||
}
|
||||
} finally {
|
||||
if (requests.current.works === controller) {
|
||||
delete requests.current.works
|
||||
setWorksLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Direct ORCID input
|
||||
const handleGoToWorksByOrcid = async () => {
|
||||
const orcid = orcidInput.trim()
|
||||
if (!isValidOrcid(orcid)) return
|
||||
setSearchError(null)
|
||||
await handlePickAuthor(orcid, orcid)
|
||||
}
|
||||
|
||||
// Toggle a work selection (by put-code)
|
||||
const toggleWork = (putCode: number) => {
|
||||
if (importing) return
|
||||
setSelectedPutCodes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(putCode)) {
|
||||
next.delete(putCode)
|
||||
} else {
|
||||
next.add(putCode)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Select / deselect all works; summaries need no detailed metadata.
|
||||
const allSelected = works.length > 0 && selectedPutCodes.size === works.length
|
||||
|
||||
const toggleAll = () => {
|
||||
if (worksLoading || importing) return
|
||||
setSelectedPutCodes(allSelected ? new Set() : new Set(works.map(work => work.putCode)))
|
||||
}
|
||||
|
||||
// Import one selection; a successful write displays a final report.
|
||||
const handleImport = async () => {
|
||||
if (importSummary || !selectedOrcid || selectedPutCodes.size === 0 || requests.current.import) return
|
||||
const controller = new AbortController()
|
||||
requests.current.import = controller
|
||||
const active = () => requests.current.import === controller && !controller.signal.aborted
|
||||
const selected = works.filter(work => selectedPutCodes.has(work.putCode))
|
||||
setImporting(true)
|
||||
setImportError(null)
|
||||
|
||||
// The host decides whether the item is usable or needs more metadata.
|
||||
// Cache that decision until the item changes through enrichment.
|
||||
const inspected = new WeakMap<OrcidImportItem, OrcidInspection | null>()
|
||||
const inspect = (item: OrcidImportItem): OrcidInspection | null => {
|
||||
if (inspected.has(item)) return inspected.get(item) ?? null
|
||||
let result: OrcidInspection | null = null
|
||||
try {
|
||||
result = onInspect(item)
|
||||
} catch {
|
||||
// Invalid optional source; the host may accept an alternate source.
|
||||
}
|
||||
inspected.set(item, result)
|
||||
return result
|
||||
}
|
||||
// Parsing and validation are owned by the BibTeX editor, never the server.
|
||||
const validCandidate = (item: OrcidImportItem): OrcidImportItem => {
|
||||
if (!('bibtex' in item) || inspect(item)) return item
|
||||
return item.work ? { work: item.work } : item
|
||||
}
|
||||
const needsMetadata = (item: OrcidImportItem) => {
|
||||
const result = inspect(item)
|
||||
return !result || result.needsMetadata
|
||||
}
|
||||
|
||||
let rateLimited = false
|
||||
const failedEnrichment = new Map<number, CrossrefFailure>()
|
||||
try {
|
||||
// Bulk ORCID detail retrieval is sequential, and limited to selection.
|
||||
// No requests at all for already cached details.
|
||||
const missing = selected.filter(work => !workDetails.current.has(work.putCode))
|
||||
for (let start = 0; start < missing.length;) {
|
||||
const size = retryBatchSizes.current.get(missing[start].putCode) ?? ORCID_BATCH_SIZE
|
||||
const batch: Work[] = []
|
||||
while (start < missing.length && batch.length < size &&
|
||||
(retryBatchSizes.current.get(missing[start].putCode) ?? ORCID_BATCH_SIZE) === size) {
|
||||
batch.push(missing[start++])
|
||||
}
|
||||
const smallerOnRetry = () => {
|
||||
const nextSize = Math.max(1, Math.ceil(batch.length / 4))
|
||||
for (const work of batch) retryBatchSizes.current.set(work.putCode, nextSize)
|
||||
}
|
||||
let result: unknown
|
||||
try {
|
||||
result = await fetchOrcidWorkDetails(selectedOrcid, batch.map(work => work.putCode), controller.signal)
|
||||
} catch (err) {
|
||||
if (!active()) return
|
||||
if (isRateLimit(err)) {
|
||||
rateLimited = true
|
||||
break // No further provider requests after a 429.
|
||||
}
|
||||
smallerOnRetry()
|
||||
continue
|
||||
}
|
||||
if (!active()) return
|
||||
if (!result || typeof result !== 'object' || !Array.isArray((result as { results?: unknown }).results)) {
|
||||
smallerOnRetry()
|
||||
continue
|
||||
}
|
||||
const returned = new Map<number, OrcidImportItem>()
|
||||
const allowed = new Set(batch.map(work => work.putCode))
|
||||
for (const row of (result as { results: unknown[] }).results) {
|
||||
if (!row || typeof row !== 'object') continue
|
||||
const { putCode, item, failure } = row as { putCode?: number; item?: unknown; failure?: string }
|
||||
if (typeof putCode !== 'number' || !allowed.has(putCode)) continue
|
||||
const parsed = parseImportItem(item)
|
||||
if (parsed) returned.set(putCode, parsed)
|
||||
if (failure === 'rate-limit') rateLimited = true
|
||||
}
|
||||
for (const work of batch) {
|
||||
const item = returned.get(work.putCode)
|
||||
if (item) {
|
||||
workDetails.current.set(work.putCode, withWorkSummary(item, work))
|
||||
retryBatchSizes.current.delete(work.putCode)
|
||||
} else {
|
||||
// A missing work is retried independently, not with an entire
|
||||
// previously successful batch.
|
||||
retryBatchSizes.current.set(work.putCode, 1)
|
||||
}
|
||||
}
|
||||
if (rateLimited) break
|
||||
}
|
||||
|
||||
const prepared = new Map<number, OrcidImportItem>()
|
||||
for (const work of selected) {
|
||||
const detail = workDetails.current.get(work.putCode)
|
||||
if (!detail) continue
|
||||
const item = validCandidate(withWorkSummary(detail, work))
|
||||
prepared.set(work.putCode, item)
|
||||
workDetails.current.set(work.putCode, item)
|
||||
}
|
||||
|
||||
// Inspect grouped alternatives only when a selected source lacks authors,
|
||||
// title or year. Preserve the original source when it is equally complete.
|
||||
const selectedByCode = new Map<number, WorkVariant>(selected.map(work => [work.putCode, work]))
|
||||
const needsAlternate = (work: Work) => {
|
||||
const item = prepared.get(work.putCode)
|
||||
const summary = selectedByCode.get(work.putCode)!
|
||||
return !!item && (needsMetadata(item) || hasMissingWorkFields(summary, item))
|
||||
}
|
||||
const alternatives = selected.filter(work => work.variants?.length && needsAlternate(work))
|
||||
const maxAlternatives = Math.max(0, ...alternatives.map(work => work.variants!.length))
|
||||
for (let index = 0; index < maxAlternatives && !rateLimited; index++) {
|
||||
const candidates = alternatives.filter(work => work.variants![index] && needsAlternate(work))
|
||||
const variants = new Map(candidates.map(work => [work.variants![index].putCode, work.variants![index]]))
|
||||
const codes = [...variants.keys()]
|
||||
for (let start = 0; start < codes.length && !rateLimited; start += ORCID_BATCH_SIZE) {
|
||||
let result: Awaited<ReturnType<typeof fetchOrcidWorkDetails>>
|
||||
try {
|
||||
result = await fetchOrcidWorkDetails(selectedOrcid, codes.slice(start, start + ORCID_BATCH_SIZE), controller.signal)
|
||||
} catch (err) {
|
||||
if (!active()) return
|
||||
if (isRateLimit(err)) rateLimited = true
|
||||
continue
|
||||
}
|
||||
if (!active()) return
|
||||
const found = new Map(result.results.flatMap(row => {
|
||||
const item = parseImportItem(row.item)
|
||||
return item ? [[row.putCode, item] as const] : []
|
||||
}))
|
||||
for (const work of candidates) {
|
||||
const variant = work.variants![index]
|
||||
if (!found.has(variant.putCode)) continue
|
||||
const current = prepared.get(work.putCode)!
|
||||
const source = selectedByCode.get(work.putCode)!
|
||||
let alternate = validCandidate(withWorkSummary(found.get(variant.putCode)!, variant))
|
||||
// Keep the original valid citation (and its supplied key), while
|
||||
// adding structured fields from a better alternate ORCID source.
|
||||
if ('bibtex' in current && inspect(current)) {
|
||||
alternate = { ...alternate, bibtex: current.bibtex }
|
||||
}
|
||||
alternate = withWorkSummary(alternate, source)
|
||||
const prior = inspect(current)
|
||||
const next = inspect(alternate)
|
||||
if (!next) continue
|
||||
const priorScore = workMetadataScore(source, current) + Number(!!prior && !prior.needsMetadata) * 16
|
||||
const nextScore = workMetadataScore(variant, alternate) + Number(!next.needsMetadata) * 16
|
||||
if (nextScore <= priorScore) continue
|
||||
prepared.set(work.putCode, alternate)
|
||||
workDetails.current.set(work.putCode, alternate)
|
||||
selectedByCode.set(work.putCode, variant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!active()) return
|
||||
|
||||
// Only incomplete references are sent to Crossref. Failures use ORCID data.
|
||||
// Only exact DOI lookup through Crossref remains. Missing metadata is
|
||||
// imported from ORCID as-is; no DataCite or arXiv requests are made.
|
||||
const doiByCode = new Map<number, string>()
|
||||
for (const work of rateLimited ? [] : selected.filter(work =>
|
||||
prepared.has(work.putCode) && needsMetadata(prepared.get(work.putCode)!)
|
||||
)) {
|
||||
const item = prepared.get(work.putCode)!
|
||||
const doi = workDoi(item, selectedByCode.get(work.putCode)!)
|
||||
if (!doi) continue
|
||||
const key = normalizedDoi(doi)
|
||||
if (!key) continue
|
||||
// Keep an unambiguous grouped DOI in the structured work for import.
|
||||
if (item.work && !item.work['external-ids']?.['external-id']?.some(
|
||||
id => id['external-id-type']?.toLowerCase() === 'doi'
|
||||
)) {
|
||||
const supplemented = { ...item, work: { ...item.work, 'external-ids': { 'external-id': [
|
||||
...(item.work['external-ids']?.['external-id'] ?? []),
|
||||
{ 'external-id-type': 'doi', 'external-id-value': doi },
|
||||
] } } }
|
||||
prepared.set(work.putCode, supplemented)
|
||||
workDetails.current.set(work.putCode, supplemented)
|
||||
}
|
||||
// Known arXiv DataCite DOIs are not registered at Crossref.
|
||||
if (!item.doi && !key.startsWith('10.48550/arxiv.')) doiByCode.set(work.putCode, key)
|
||||
}
|
||||
const lookupDois = [...new Set(doiByCode.values())]
|
||||
for (let start = 0; start < lookupDois.length && !rateLimited; start += 150) {
|
||||
const batch = lookupDois.slice(start, start + 150)
|
||||
let result: Awaited<ReturnType<typeof fetchDoiMetadataBatch>>
|
||||
try {
|
||||
result = await fetchDoiMetadataBatch(batch, controller.signal)
|
||||
} catch (error) {
|
||||
if (!active()) return
|
||||
// Even an immediately rejected request was attempted. Do not mark
|
||||
// later, untouched batches as Crossref failures.
|
||||
result = { entries: new Map(), attempted: new Set(batch),
|
||||
rateLimited: new Set(isRateLimit(error) ? batch : []), error }
|
||||
}
|
||||
if (!active()) return
|
||||
for (const [code, doi] of doiByCode) {
|
||||
if (!batch.includes(doi)) continue
|
||||
const metadata = result.entries.get(doi)
|
||||
if (metadata && needsMetadata(prepared.get(code)!)) {
|
||||
const enriched = { ...prepared.get(code)!, doi: metadata }
|
||||
prepared.set(code, enriched)
|
||||
workDetails.current.set(code, enriched)
|
||||
failedEnrichment.delete(code)
|
||||
} else if (result.attempted.has(doi)) {
|
||||
failedEnrichment.set(code, result.rateLimited.has(doi) ? 'rate-limit' : 'unavailable')
|
||||
}
|
||||
}
|
||||
if (result.error && (isRateLimit(result.error) ||
|
||||
(result.error instanceof Error && result.error.name === 'AbortError'))) {
|
||||
rateLimited = isRateLimit(result.error)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!active()) return
|
||||
// No DOI or no Crossref attempt is not a failure. Import ORCID-only
|
||||
// entries for failed Crossref lookups in the same guarded transaction.
|
||||
const ready = selected.filter(work => {
|
||||
const item = prepared.get(work.putCode)
|
||||
return item && inspect(item)
|
||||
})
|
||||
let skippedCodes = new Set<number>()
|
||||
if (ready.length) {
|
||||
const committed = await onInsert(ready.map(work => prepared.get(work.putCode)!))
|
||||
if (!committed) {
|
||||
if (active()) setImportError(t('import_failed'))
|
||||
return // Do not report an import that was rejected by the editor.
|
||||
}
|
||||
if (!active()) return
|
||||
setImportSummary({ imported: committed.imported, enriched: committed.enriched })
|
||||
skippedCodes = new Set(committed.skippedIndices.flatMap(index =>
|
||||
ready[index] ? [ready[index].putCode] : []))
|
||||
const unusable = new Set(committed.unusableIndices)
|
||||
setCrossrefFailures(ready.flatMap((work, index) => {
|
||||
if (skippedCodes.has(work.putCode)) return []
|
||||
const reason = failedEnrichment.get(work.putCode) ||
|
||||
(unusable.has(index) ? 'unavailable' : null)
|
||||
return reason ? [{ title: work.title || t('untitled'), reason }] : []
|
||||
}))
|
||||
}
|
||||
const unimported = selected.filter(work => !ready.includes(work) || skippedCodes.has(work.putCode))
|
||||
if (unimported.length) {
|
||||
setUnimportedTitles(unimported.map(work => work.title || t('untitled')))
|
||||
const message = t(skippedCodes.size
|
||||
? 'some_works_could_not_be_imported' : 'some_works_could_not_be_fetched')
|
||||
setImportError(rateLimited
|
||||
? `${message}. ${t('provider_rate_limited')}` : message)
|
||||
}
|
||||
// The report replaces the works list after any successful import.
|
||||
} catch (err) {
|
||||
if (active()) {
|
||||
setImportError(errorMessage(err, t('import_failed'), t('invalid_response_from_server'), t('provider_rate_limited')))
|
||||
}
|
||||
} finally {
|
||||
if (active()) {
|
||||
delete requests.current.import
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
cancelRequest('works')
|
||||
setWorksLoading(false)
|
||||
setStep('search')
|
||||
setWorksError(null)
|
||||
setImportError(null)
|
||||
}
|
||||
|
||||
const changeNameQuery = (value: string) => {
|
||||
cancelRequest('search')
|
||||
setAuthors([])
|
||||
setSearchError(null)
|
||||
setSearchLoading(false)
|
||||
setNameQuery(value)
|
||||
}
|
||||
|
||||
return {
|
||||
step, searchMode, nameQuery, orcidInput, searchLoading, searchError, authors,
|
||||
selectedAuthorName, works, worksLoading, worksError, selectedPutCodes,
|
||||
importing, importError, importSummary, crossrefFailures, unimportedTitles, allSelected,
|
||||
setOrcidInput, setSearchError, setWorksError, setImportError,
|
||||
handleClose, goBack, handleSearchModeChange, changeNameQuery,
|
||||
handleSearchByName, handleGoToWorksByOrcid, handlePickAuthor,
|
||||
toggleWork, toggleAll, handleImport,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/** ORCID and Crossref data types, parsing and HTTP access for the BibTeX editor. */
|
||||
import { fetchJSON, ProviderHttpError, ProviderResponseError } from './provider-fetch'
|
||||
import { encodeDoiPath } from '../utils/bib-doi'
|
||||
|
||||
export type AuthorResult = {
|
||||
orcid: string
|
||||
givenNames: string
|
||||
familyNames: string
|
||||
institutionNames: string[]
|
||||
}
|
||||
|
||||
export type WorkVariant = {
|
||||
title: string
|
||||
year: string
|
||||
type: string
|
||||
putCode: number
|
||||
externalIds?: NonNullable<OrcidWork['external-ids']>['external-id']
|
||||
preferredExternalIds?: NonNullable<OrcidWork['external-ids']>['external-id']
|
||||
sourceName?: string
|
||||
journalTitle?: string
|
||||
}
|
||||
|
||||
export type Work = WorkVariant & { variants?: WorkVariant[] }
|
||||
|
||||
export type OrcidWork = {
|
||||
url?: { value?: string }
|
||||
type?: string
|
||||
source?: { 'source-name'?: { value?: string } }
|
||||
title?: { title?: { value?: string } }
|
||||
'publication-date'?: { year?: { value?: string } }
|
||||
'journal-title'?: { value?: string }
|
||||
contributors?: {
|
||||
contributor?: Array<{
|
||||
'credit-name'?: { value?: string }
|
||||
'contributor-attributes'?: { 'contributor-role'?: string }
|
||||
}>
|
||||
}
|
||||
'external-ids'?: {
|
||||
'external-id'?: Array<{
|
||||
'external-id-type'?: string
|
||||
'external-id-value'?: string
|
||||
'external-id-url'?: { value?: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
// Structural Crossref response used by the host editor's existing converter.
|
||||
export type CrossRefMessage = {
|
||||
type?: string
|
||||
title?: string[]
|
||||
author?: Array<{ family?: string; given?: string; name?: string }>
|
||||
issued?: { 'date-parts'?: number[][] }
|
||||
'container-title'?: string[]
|
||||
volume?: string
|
||||
issue?: string
|
||||
page?: string
|
||||
DOI?: string
|
||||
URL?: string
|
||||
publisher?: string
|
||||
}
|
||||
export type DoiMetadata = { crossref: CrossRefMessage }
|
||||
|
||||
export type OrcidImportItem = (
|
||||
| { bibtex: string; work?: OrcidWork }
|
||||
| { work: OrcidWork }
|
||||
) & { doi?: DoiMetadata }
|
||||
|
||||
// Reject malformed nested ORCID data at the provider boundary. A broken work is
|
||||
// skipped independently; a valid embedded BibTeX citation can still be used.
|
||||
function isUsableWork(value: unknown): value is OrcidWork {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const work = value as OrcidWork
|
||||
const text = (value: unknown) => value == null || typeof value === 'string'
|
||||
const named = (value: unknown): value is { value?: string } =>
|
||||
value == null || (!!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
text((value as { value?: unknown }).value))
|
||||
const ids = work['external-ids']?.['external-id']
|
||||
const people = work.contributors?.contributor
|
||||
return named(work.title?.title) && named(work['publication-date']?.year) &&
|
||||
named(work['journal-title']) &&
|
||||
(ids == null || (Array.isArray(ids) && ids.every(id => id && typeof id === 'object' &&
|
||||
text(id['external-id-type']) && text(id['external-id-value'])))) &&
|
||||
(people == null || (Array.isArray(people) && people.every(person => person && typeof person === 'object' &&
|
||||
named(person['credit-name']) &&
|
||||
(person['contributor-attributes'] == null ||
|
||||
text(person['contributor-attributes']['contributor-role'])))))
|
||||
}
|
||||
|
||||
export function parseImportItem(data: unknown): OrcidImportItem | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const result = data as { bibtex?: unknown; work?: unknown }
|
||||
const work = isUsableWork(result.work) ? result.work : null
|
||||
if (typeof result.bibtex === 'string' && result.bibtex.trim()) {
|
||||
return {
|
||||
bibtex: result.bibtex,
|
||||
...(work ? { work } : {}),
|
||||
}
|
||||
}
|
||||
return work ? { work } : null
|
||||
}
|
||||
|
||||
export type ImportResult = {
|
||||
imported: number
|
||||
enriched: number
|
||||
unusableIndices: number[]
|
||||
skippedIndices: number[]
|
||||
}
|
||||
|
||||
const ORCID_FORMAT = /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/
|
||||
|
||||
export function isValidOrcid(orcid: string): boolean {
|
||||
if (!ORCID_FORMAT.test(orcid)) return false
|
||||
const digits = orcid.replaceAll('-', '')
|
||||
let total = 0
|
||||
for (const digit of digits.slice(0, 15)) total = (total + Number(digit)) * 2
|
||||
const check = (12 - (total % 11)) % 11
|
||||
return digits[15] === (check === 10 ? 'X' : String(check))
|
||||
}
|
||||
|
||||
const ORCID_PUBLIC_API = 'https://pub.orcid.org/v3.0'
|
||||
const CROSSREF_API = 'https://api.crossref.org/works'
|
||||
const ORCID_MAX_BATCH = 100
|
||||
const DOI_PATTERN = /^10\.\d{4,9}\/\S+$/
|
||||
const SOLR_SPECIAL = /([+\-&|!(){}\[\]^"~*?:\\/])/g
|
||||
|
||||
function publicOrcid(orcid: string): string {
|
||||
if (!isValidOrcid(orcid)) throw new Error('Invalid ORCID identifier')
|
||||
return `${ORCID_PUBLIC_API}/${orcid}`
|
||||
}
|
||||
|
||||
function validCodes(codes: number[]) {
|
||||
if (!codes.length || codes.length > ORCID_MAX_BATCH ||
|
||||
codes.some(code => !Number.isSafeInteger(code) || code < 1) ||
|
||||
new Set(codes).size !== codes.length) throw new Error('Invalid ORCID put-codes')
|
||||
}
|
||||
|
||||
function formatWork(work: OrcidWork & { citation?: { 'citation-type'?: string; 'citation-value'?: string } }): OrcidImportItem {
|
||||
const citation = work.citation
|
||||
if (citation?.['citation-type'] === 'bibtex' &&
|
||||
typeof citation['citation-value'] === 'string' &&
|
||||
citation['citation-value'].trim().startsWith('@')) {
|
||||
return { bibtex: citation['citation-value'].trim(), work }
|
||||
}
|
||||
return { work }
|
||||
}
|
||||
|
||||
/** ORCID is an external API: skip malformed summaries without discarding other works. */
|
||||
export function normalizeWorks(response: unknown): Work[] {
|
||||
const groups = (response as { group?: unknown } | null)?.group
|
||||
if (!Array.isArray(groups)) throw new ProviderResponseError('Invalid ORCID works response')
|
||||
const works: Work[] = []
|
||||
const string = (value: unknown) => typeof value === 'string' ? value : ''
|
||||
const read = (value: unknown, ...keys: string[]): string => {
|
||||
let current = value
|
||||
for (const key of keys) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) return ''
|
||||
current = (current as Record<string, unknown>)[key]
|
||||
}
|
||||
return string(current)
|
||||
}
|
||||
type ExternalId = NonNullable<NonNullable<OrcidWork['external-ids']>['external-id']>[number]
|
||||
const ids = (value: unknown): ExternalId[] => {
|
||||
const raw = (value as { 'external-ids'?: { 'external-id'?: unknown } } | null)?.['external-ids']?.['external-id']
|
||||
return Array.isArray(raw) ? raw.filter((id): id is ExternalId =>
|
||||
!!id && typeof id === 'object' && !Array.isArray(id) &&
|
||||
typeof id['external-id-type'] === 'string' && typeof id['external-id-value'] === 'string'
|
||||
) : []
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (!group || typeof group !== 'object' || Array.isArray(group)) continue
|
||||
const raw = (group as { 'work-summary'?: unknown })['work-summary']
|
||||
if (!Array.isArray(raw)) continue
|
||||
const summaries = raw.filter((item): item is Record<string, unknown> =>
|
||||
!!item && typeof item === 'object' && !Array.isArray(item) &&
|
||||
Number.isSafeInteger(item['put-code']) && Number(item['put-code']) > 0
|
||||
)
|
||||
if (!summaries.length) continue
|
||||
const displayIndex = (item: Record<string, unknown>) => {
|
||||
const value = item['display-index']
|
||||
return value == null || value === '' || !Number.isFinite(Number(value))
|
||||
? -Infinity : Number(value)
|
||||
}
|
||||
const ranked = summaries.sort((a, b) => displayIndex(b) - displayIndex(a))
|
||||
const externalIds = [...ids(group), ...ranked.flatMap(ids)]
|
||||
const source = (item: Record<string, unknown>): WorkVariant => ({
|
||||
putCode: item['put-code'] as number,
|
||||
title: read(item, 'title', 'title', 'value'),
|
||||
year: read(item, 'publication-date', 'year', 'value'),
|
||||
type: read(item, 'type'),
|
||||
sourceName: read(item, 'source', 'source-name', 'value'),
|
||||
journalTitle: read(item, 'journal-title', 'value'),
|
||||
externalIds,
|
||||
preferredExternalIds: ids(item),
|
||||
})
|
||||
const candidates = ranked.map(source)
|
||||
const score = (item: WorkVariant) => Number(!!item.title.trim()) * 2 +
|
||||
Number(/^\d{4}$/.test(item.year)) * 2 +
|
||||
Number(!!item.journalTitle?.trim()) +
|
||||
Number(item.preferredExternalIds?.some(id => id['external-id-type']?.toLowerCase() === 'doi')) * 2
|
||||
// Respect the highest display-index when it has usable summary data.
|
||||
// Otherwise use the richest summary; full contributors are checked on import.
|
||||
let selected = candidates[0]
|
||||
if (score(selected) < 6) {
|
||||
for (const candidate of candidates.slice(1)) {
|
||||
if (score(candidate) > score(selected)) selected = candidate
|
||||
}
|
||||
}
|
||||
works.push({ ...selected, variants: candidates.filter(item => item.putCode !== selected.putCode) })
|
||||
}
|
||||
return works.sort((a, b) => (Number.parseInt(b.year, 10) || 0) - (Number.parseInt(a.year, 10) || 0))
|
||||
}
|
||||
|
||||
/** Consider only structurally usable Crossref fields. */
|
||||
function usefulCrossref(message: CrossRefMessage) {
|
||||
return (Array.isArray(message.author) && message.author.some(person =>
|
||||
!!person && typeof person === 'object' &&
|
||||
((typeof person.family === 'string' && !!person.family.trim()) ||
|
||||
(typeof person.name === 'string' && !!person.name.trim())))) ||
|
||||
(Array.isArray(message.issued?.['date-parts']) &&
|
||||
Number.isInteger(message.issued['date-parts']?.[0]?.[0])) ||
|
||||
(Array.isArray(message.title) && message.title.some(title => typeof title === 'string' && !!title.trim())) ||
|
||||
(Array.isArray(message['container-title']) && message['container-title'].some(title => typeof title === 'string' && !!title.trim())) ||
|
||||
(typeof message.publisher === 'string' && !!message.publisher.trim()) ||
|
||||
(typeof message.volume === 'string' && !!message.volume.trim()) ||
|
||||
(typeof message.page === 'string' && !!message.page.trim())
|
||||
}
|
||||
|
||||
/** Normalize before deduplicating and matching exact Crossref DOI identities. */
|
||||
function doiValue(doi: string): string | null {
|
||||
const trimmed = doi.trim()
|
||||
const urlPrefix = /^https?:\/\/(?:dx\.)?doi\.org\//i
|
||||
let value = trimmed.replace(urlPrefix, '').replace(/^doi:/i, '')
|
||||
if (urlPrefix.test(trimmed)) {
|
||||
try {
|
||||
// DOI URLs escape path punctuation; a bare DOI may contain literal percent signs.
|
||||
value = decodeURIComponent(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return DOI_PATTERN.test(value) && value.length <= 300 ? value : null
|
||||
}
|
||||
|
||||
export function normalizedDoi(doi: string): string | null {
|
||||
return doiValue(doi)?.toLowerCase() ?? null
|
||||
}
|
||||
|
||||
/** Shared identity and usability check for both Crossref endpoints. */
|
||||
function crossrefMetadata(value: unknown, requestedDoi: string): DoiMetadata | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const message = value as CrossRefMessage
|
||||
return typeof message.DOI === 'string' && normalizedDoi(message.DOI) === requestedDoi &&
|
||||
usefulCrossref(message) ? { crossref: message } : null
|
||||
}
|
||||
|
||||
/** Batch ordinary DOIs via Crossref filters. DOIs containing commas need an
|
||||
* individually encoded URL because the filter syntax uses commas as separators.
|
||||
* Retain completed chunks and continue past non-rate-limit provider failures.
|
||||
*/
|
||||
export async function fetchDoiMetadataBatch(
|
||||
dois: string[], signal: AbortSignal
|
||||
): Promise<{ entries: Map<string, DoiMetadata>; attempted: Set<string>; rateLimited: Set<string>; error?: unknown }> {
|
||||
if (!dois.length || dois.length > 150) throw new Error('Invalid DOI batch')
|
||||
const unique = [...new Set(dois.map(normalizedDoi).filter((doi): doi is string =>
|
||||
!!doi && !doi.startsWith('10.48550/arxiv.')
|
||||
))]
|
||||
const entries = new Map<string, DoiMetadata>()
|
||||
const attempted = new Set<string>()
|
||||
const rateLimited = new Set<string>()
|
||||
const chunks: string[][] = []
|
||||
let current: string[] = []
|
||||
for (const doi of unique) {
|
||||
if (doi.includes(',')) {
|
||||
if (current.length) chunks.push(current)
|
||||
chunks.push([doi])
|
||||
current = []
|
||||
continue
|
||||
}
|
||||
if (current.length && (current.length === 10 || [...current, doi].join(',').length > 1400)) {
|
||||
chunks.push(current)
|
||||
current = []
|
||||
}
|
||||
current.push(doi)
|
||||
}
|
||||
if (current.length) chunks.push(current)
|
||||
|
||||
let error: unknown
|
||||
for (const batch of chunks) {
|
||||
if (signal.aborted) break
|
||||
batch.forEach(doi => attempted.add(doi))
|
||||
const commaDoi = batch.length === 1 && batch[0].includes(',')
|
||||
try {
|
||||
if (commaDoi) {
|
||||
const doiPath = encodeDoiPath(batch[0])
|
||||
const result = await fetchJSON(`${CROSSREF_API}/${doiPath}`, signal) as {
|
||||
message?: unknown
|
||||
}
|
||||
const metadata = crossrefMetadata(result?.message, batch[0])
|
||||
if (metadata) entries.set(batch[0], metadata)
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
filter: batch.map(doi => `doi:${doi}`).join(','),
|
||||
rows: String(batch.length),
|
||||
select: 'DOI,type,title,author,issued,container-title,volume,issue,page,URL,publisher',
|
||||
})
|
||||
const result = await fetchJSON(`${CROSSREF_API}?${params}`, signal, 4 * 1024 * 1024) as {
|
||||
message?: { items?: unknown[] }
|
||||
}
|
||||
if (!Array.isArray(result?.message?.items)) throw new ProviderResponseError('Invalid Crossref batch')
|
||||
for (const value of result.message.items) {
|
||||
const doi = (value && typeof value === 'object' && 'DOI' in value &&
|
||||
typeof value.DOI === 'string') ? normalizedDoi(value.DOI) : null
|
||||
if (!doi || !batch.includes(doi)) continue
|
||||
const metadata = crossrefMetadata(value, doi)
|
||||
if (metadata) entries.set(doi, metadata)
|
||||
}
|
||||
}
|
||||
} catch (failure) {
|
||||
// Missing or temporarily unavailable metadata must not block other chunks.
|
||||
if (commaDoi && failure instanceof ProviderHttpError && failure.status === 404) continue
|
||||
if (isRateLimit(failure)) {
|
||||
error = failure
|
||||
batch.forEach(doi => rateLimited.add(doi))
|
||||
break
|
||||
}
|
||||
// Cancellation or forbidden access is not a transient DOI-specific failure.
|
||||
if (signal.aborted || (failure instanceof Error && failure.name === 'AbortError') ||
|
||||
(failure instanceof ProviderHttpError && (failure.status === 401 || failure.status === 403))) {
|
||||
error = failure
|
||||
break
|
||||
}
|
||||
error ??= failure
|
||||
}
|
||||
}
|
||||
return { entries, attempted, rateLimited, ...(error ? { error } : {}) }
|
||||
}
|
||||
|
||||
/** Respect the selected ORCID source before considering grouped versions.
|
||||
* A group-level DOI is usable only when every listed DOI is the same.
|
||||
*/
|
||||
export function workDoi(item: OrcidImportItem, summary: Work): string | null {
|
||||
type ExternalId = NonNullable<Work['externalIds']>[number]
|
||||
const candidates = (ids?: ExternalId[]) => [...new Set((ids ?? [])
|
||||
.filter(id => id['external-id-type']?.toLowerCase() === 'doi')
|
||||
.map(id => normalizedDoi(id['external-id-value'] ?? ''))
|
||||
.filter((doi): doi is string => doi !== null))]
|
||||
const detailed = candidates(item.work?.['external-ids']?.['external-id'])
|
||||
if (detailed.length) return detailed.length === 1 ? detailed[0] : null
|
||||
const preferred = candidates(summary.preferredExternalIds)
|
||||
if (preferred.length) return preferred.length === 1 ? preferred[0] : null
|
||||
const grouped = candidates(summary.externalIds)
|
||||
return grouped.length === 1 ? grouped[0] : null
|
||||
}
|
||||
|
||||
export function isRateLimit(err: unknown) {
|
||||
return err instanceof ProviderHttpError &&
|
||||
(err.status === 429 || (err.provider === 'orcid' && err.status === 503))
|
||||
}
|
||||
|
||||
export function errorMessage(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
invalidResponse: string,
|
||||
tooMany: string
|
||||
) {
|
||||
if (isRateLimit(err)) {
|
||||
return tooMany
|
||||
}
|
||||
if (err instanceof ProviderResponseError) return invalidResponse
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Search public ORCID author records. */
|
||||
export async function searchOrcidAuthors(name: string, signal: AbortSignal) {
|
||||
const query = name.trim()
|
||||
if (!query || query.length > 200) throw new Error('Invalid author search')
|
||||
const parts = query.split(/\s+/)
|
||||
const escape = (value: string) => value.replace(SOLR_SPECIAL, '\\$1')
|
||||
const q = parts.length > 1
|
||||
? `given-names:"${escape(parts.slice(0, -1).join(' '))}" AND family-name:"${escape(parts.at(-1)!)}"`
|
||||
: escape(query)
|
||||
const result = await fetchJSON(`${ORCID_PUBLIC_API}/expanded-search/?q=${encodeURIComponent(q)}&start=0&rows=20`, signal) as {
|
||||
'expanded-result'?: Array<Record<string, unknown>>
|
||||
}
|
||||
if (!Array.isArray(result?.['expanded-result'])) throw new ProviderResponseError('Invalid ORCID author response')
|
||||
return { results: result['expanded-result'].flatMap(author => {
|
||||
if (!author || typeof author !== 'object' ||
|
||||
typeof author['orcid-id'] !== 'string' || !isValidOrcid(author['orcid-id'])) return []
|
||||
return [{
|
||||
orcid: author['orcid-id'] as string,
|
||||
givenNames: typeof author['given-names'] === 'string' ? author['given-names'] : '',
|
||||
familyNames: typeof author['family-names'] === 'string' ? author['family-names'] : '',
|
||||
institutionNames: Array.isArray(author['institution-name'])
|
||||
? author['institution-name'].filter((name): name is string => typeof name === 'string') : [],
|
||||
}]
|
||||
}) }
|
||||
}
|
||||
|
||||
/** Fetch lightweight publication summaries. */
|
||||
export async function fetchOrcidWorks(orcid: string, signal: AbortSignal) {
|
||||
const result = await fetchJSON(`${publicOrcid(orcid)}/works`, signal, 8 * 1024 * 1024)
|
||||
return { works: normalizeWorks(result) }
|
||||
}
|
||||
|
||||
/** Fetch only the selected ORCID work details. */
|
||||
export async function fetchOrcidWorkDetails(orcid: string, codes: number[], signal: AbortSignal) {
|
||||
validCodes(codes)
|
||||
const result = await fetchJSON(`${publicOrcid(orcid)}/works/${codes.join(',')}`, signal, 8 * 1024 * 1024) as {
|
||||
bulk?: Array<{ work?: OrcidWork & { citation?: { 'citation-type'?: string; 'citation-value'?: string } } }>
|
||||
}
|
||||
if (!Array.isArray(result?.bulk)) throw new ProviderResponseError('Invalid ORCID detail response')
|
||||
const returned = new Map<number, OrcidImportItem>()
|
||||
for (const row of result.bulk) {
|
||||
const code = (row?.work as OrcidWork & { 'put-code'?: number } | undefined)?.['put-code']
|
||||
if (Number.isSafeInteger(code) && code && codes.includes(code)) {
|
||||
const item = parseImportItem(formatWork(row.work!))
|
||||
if (item) returned.set(code, item)
|
||||
}
|
||||
}
|
||||
return { results: codes.map(putCode => ({ putCode, item: returned.get(putCode) ?? null })) }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Fetch only fixed public metadata providers in the browser, without credentials. */
|
||||
export type MetadataProvider = 'orcid' | 'crossref'
|
||||
|
||||
export class ProviderHttpError extends Error {
|
||||
constructor(readonly provider: MetadataProvider, readonly status: number) {
|
||||
super(`${provider} HTTP ${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderResponseError extends Error {}
|
||||
|
||||
function providerFor(url: string): MetadataProvider {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password) {
|
||||
throw new Error('Unexpected metadata provider URL')
|
||||
}
|
||||
const host = parsed.hostname
|
||||
if (host === 'pub.orcid.org') return 'orcid'
|
||||
if (host === 'api.crossref.org') return 'crossref'
|
||||
throw new Error('Unexpected metadata provider')
|
||||
}
|
||||
|
||||
export async function fetchJSON(url: string, signal: AbortSignal, maxBytes = 2 * 1024 * 1024): Promise<unknown> {
|
||||
const provider = providerFor(url)
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
const controller = new AbortController()
|
||||
const deadline = setTimeout(() => controller.abort(), 15_000)
|
||||
const onAbort = () => controller.abort()
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
if (signal.aborted) controller.abort()
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new ProviderHttpError(provider, response.status)
|
||||
}
|
||||
const length = Number(response.headers.get('content-length'))
|
||||
if (length > maxBytes) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error('Metadata response too large')
|
||||
}
|
||||
// A missing stream cannot be consumed with a bounded allocation.
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('Metadata response has no readable body')
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
let finished = false
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
size += value.byteLength
|
||||
if (size > maxBytes) throw new Error('Metadata response too large')
|
||||
chunks.push(value)
|
||||
}
|
||||
} finally {
|
||||
if (!finished) await reader.cancel().catch(() => undefined)
|
||||
reader.releaseLock()
|
||||
}
|
||||
const bytes = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes))
|
||||
} catch {
|
||||
throw new ProviderResponseError('Invalid metadata JSON response')
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** ORCID work-summary fallback and provider selection, independent of UI. */
|
||||
import type { OrcidImportItem, Work, WorkVariant } from './orcid-api'
|
||||
|
||||
/** Preserve title/year from the selected summary when absent in full details. */
|
||||
export function withWorkSummary(item: OrcidImportItem, summary: Work): OrcidImportItem {
|
||||
if (!item.work) return item
|
||||
const work = item.work
|
||||
const year = summary.year.trim()
|
||||
const title = summary.title.trim()
|
||||
const missingYear = !!year && !work['publication-date']?.year?.value?.trim()
|
||||
const missingTitle = !!title && !work.title?.title?.value?.trim()
|
||||
if (!missingYear && !missingTitle) return item
|
||||
return { ...item, work: {
|
||||
...work,
|
||||
...(missingYear ? { 'publication-date': {
|
||||
...work['publication-date'], year: { value: year },
|
||||
} } : {}),
|
||||
...(missingTitle ? { title: { title: { value: title } } } : {}),
|
||||
} }
|
||||
}
|
||||
|
||||
/** A conservative structured-metadata score; citation parsing stays in the host. */
|
||||
export function workMetadataScore(summary: WorkVariant, item: OrcidImportItem): number {
|
||||
const work = item.work
|
||||
const ids = work?.['external-ids']?.['external-id'] ?? summary.preferredExternalIds ?? []
|
||||
return Number(!!(work?.title?.title?.value || summary.title).trim()) * 2 +
|
||||
Number(/^\d{4}$/.test(work?.['publication-date']?.year?.value || summary.year)) * 2 +
|
||||
Number(!!(work?.['journal-title']?.value || summary.journalTitle)?.trim()) +
|
||||
Number(ids.some(id => id['external-id-type']?.toLowerCase() === 'doi')) * 2 +
|
||||
Number(!!work?.contributors?.contributor?.some(person => person['credit-name']?.value?.trim())) * 8
|
||||
}
|
||||
|
||||
/** Do not fetch alternate details for otherwise usable records. */
|
||||
export function hasMissingWorkFields(summary: WorkVariant, item: OrcidImportItem): boolean {
|
||||
if (!item.work) return false
|
||||
return !(item.work.title?.title?.value || summary.title).trim() ||
|
||||
!/^\d{4}$/.test(item.work['publication-date']?.year?.value || summary.year)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { hasBalancedCurlyBraces } from './bib-validate'
|
||||
import { isBracedAuthor, splitBibtexAnd } from './bib-author-names'
|
||||
|
||||
export type BibAuthorRow = {
|
||||
value: string
|
||||
saved: string
|
||||
}
|
||||
|
||||
/** A single editor state for both author input modes. */
|
||||
export type BibAuthorDraft =
|
||||
| { mode: 'rows'; rows: BibAuthorRow[] }
|
||||
| { mode: 'text'; value: string; saved: string; invalidRow?: string }
|
||||
|
||||
export type BibNameDrafts = Partial<Record<string, BibAuthorDraft>>
|
||||
|
||||
export const MAX_AUTHOR_ROWS = 10
|
||||
|
||||
const joinAuthors = (values: string[]) =>
|
||||
values.filter(value => value.trim()).join(' and ')
|
||||
|
||||
const authorInputValue = (value: string) =>
|
||||
isBracedAuthor(value) ? value.slice(1, -1) : value
|
||||
|
||||
/** Preserve untouched names, but brace edited names only when needed. */
|
||||
export function authorSourceValue(value: string, previous = ''): string {
|
||||
if (!value.trim()) return ''
|
||||
if (previous && value === authorInputValue(previous)) return previous
|
||||
return /\band(?:\s|$)/i.test(value) && !isBracedAuthor(value)
|
||||
? `{${value}}`
|
||||
: value
|
||||
}
|
||||
|
||||
export function createAuthorDraft(value: string): BibAuthorDraft {
|
||||
const authors = splitBibtexAnd(value).filter(author => author.trim())
|
||||
if (authors.length > MAX_AUTHOR_ROWS) {
|
||||
return { mode: 'text', value, saved: value }
|
||||
}
|
||||
return {
|
||||
mode: 'rows',
|
||||
rows: (authors.length ? authors : ['']).map(saved => ({
|
||||
value: authorInputValue(saved),
|
||||
saved,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function authorDraftValue(draft: BibAuthorDraft): string {
|
||||
return draft.mode === 'text'
|
||||
? draft.value
|
||||
: joinAuthors(draft.rows.map(row => authorSourceValue(row.value, row.saved)))
|
||||
}
|
||||
|
||||
export function authorDraftSavedValue(draft: BibAuthorDraft): string {
|
||||
return draft.mode === 'text'
|
||||
? draft.saved
|
||||
: joinAuthors(draft.rows.map(row => row.saved))
|
||||
}
|
||||
|
||||
export function isAuthorDraftValid(draft: BibAuthorDraft): boolean {
|
||||
return draft.mode === 'text'
|
||||
? draft.invalidRow == null && hasBalancedCurlyBraces(draft.value)
|
||||
: draft.rows.every(row => hasBalancedCurlyBraces(row.value))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Whether braces enclose the complete author rather than only part of it. */
|
||||
export function isBracedAuthor(value: string): boolean {
|
||||
if (!value.startsWith('{') || !value.endsWith('}')) return false
|
||||
let depth = 0
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value[i] === '{') depth++
|
||||
if (value[i] === '}' && --depth === 0 && i !== value.length - 1) return false
|
||||
}
|
||||
return depth === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Split authors conservatively on top-level `and`. This function is suitable for
|
||||
* valid source text; malformed per-author brace balancing is handled by the visual
|
||||
* author controls rather than repaired here. Braces preceded by backslashes
|
||||
* still affect nesting, matching the BibTeX source parser.
|
||||
*/
|
||||
export function splitBibtexAnd(value: string): string[] {
|
||||
const parts: string[] = []
|
||||
let start = 0
|
||||
let braceDepth = 0
|
||||
let hasAuthorText = false
|
||||
const contentEnd = value.trimEnd().length
|
||||
|
||||
const pushPart = (end: number) => {
|
||||
const part = value.slice(start, end).trim()
|
||||
if (part) parts.push(part)
|
||||
start = end
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i]
|
||||
|
||||
// The field's outer quotes were already parsed by BibtexFieldValue.
|
||||
// Any quotes remaining here are literal author text, not delimiters.
|
||||
if (char === '{') {
|
||||
braceDepth++
|
||||
hasAuthorText = true
|
||||
continue
|
||||
}
|
||||
if (char === '}') {
|
||||
if (braceDepth > 0) braceDepth--
|
||||
hasAuthorText = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
char.toLowerCase() !== 'a' ||
|
||||
(i > 0 && !/\s/.test(value[i - 1] || ''))
|
||||
) {
|
||||
if (!/\s/.test(char)) hasAuthorText = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the separator in place. Matching against `value.slice(i)` at every
|
||||
// word boundary repeatedly copied the remaining author string and could make
|
||||
// long names/author lists quadratic.
|
||||
const isAnd =
|
||||
value[i + 1]?.toLowerCase() === 'n' &&
|
||||
value[i + 2]?.toLowerCase() === 'd' &&
|
||||
(i + 3 === value.length || /\s/.test(value[i + 3]))
|
||||
// Only split when there is an author on both sides. A leading or
|
||||
// trailing 'and' is literal text, not a separator.
|
||||
if (!isAnd || braceDepth !== 0 || !hasAuthorText || i + 3 >= contentEnd) {
|
||||
hasAuthorText = true
|
||||
continue
|
||||
}
|
||||
|
||||
pushPart(i)
|
||||
hasAuthorText = false
|
||||
i += 2
|
||||
start = i + 1
|
||||
while (start < value.length && /\s/.test(value[start])) start++
|
||||
i = start - 1
|
||||
}
|
||||
|
||||
const last = value.slice(start).trim()
|
||||
if (last) parts.push(last)
|
||||
return parts.length > 0 ? parts : ['']
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** BibTeX directives that do not represent bibliography entries. */
|
||||
export const NON_BIBLIOGRAPHY_ENTRY_TYPES: ReadonlySet<string> = new Set([
|
||||
'comment',
|
||||
'preamble',
|
||||
'string',
|
||||
])
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Shared DOI input conventions for paste detection and metadata fetching. */
|
||||
const DOI_PREFIX = /^(?:(?:https?:\/\/)?(?:dx\.)?doi\.org\/|doi:\s*)/i
|
||||
const DOI = /^10\.\d{4,9}\/\S+$/
|
||||
|
||||
/** Accept a bare DOI, doi: prefix, or doi.org URL with or without a scheme. */
|
||||
export function normaliseDoi(raw: string): string {
|
||||
return raw.trim().replace(DOI_PREFIX, '')
|
||||
}
|
||||
|
||||
/** Classify pasted DOI input, including malformed prefixed DOIs for diagnostics. */
|
||||
export function isDoiLine(raw: string): boolean {
|
||||
const text = raw.trim()
|
||||
const normalized = normaliseDoi(text)
|
||||
return DOI.test(normalized) || (normalized !== text && normalized.length > 0)
|
||||
}
|
||||
|
||||
export function isValidDoi(doi: string): boolean {
|
||||
return DOI.test(doi)
|
||||
}
|
||||
|
||||
/** Preserve DOI slash separators while escaping reserved characters in URL paths. */
|
||||
export function encodeDoiPath(doi: string): string {
|
||||
return doi.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user