Merge branch 'main' into local-api-reader-open-items

This commit is contained in:
JRanne 2026-09-18 14:56:32 +03:00 • committed by GitHub
commit 08f9bfa53f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
689 changed files with 63808 additions and 10976 deletions

View file

@ -1,62 +1,121 @@
name: CI
on: [push, pull_request]
on:
push:
pull_request:
workflow_dispatch:
schedule:
# Monthly, to catch runner image and OS changes
- cron: '0 6 1 * *'
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build, Upload, Test
# Decide whether to run the network-filesystem tests: always for manual and scheduled
# runs, and otherwise only when the changes touch the Gecko version or the files
# responsible for network-filesystem database handling
changes:
name: Detect changes
runs-on: ubuntu-latest
if: >
github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name != github.repository
outputs:
network: ${{ steps.check.outputs.network }}
steps:
- name: Check for network-filesystem-related changes
id: check
env:
EVENT: ${{ github.event_name }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
BEFORE: ${{ github.event.before }}
BASE: ${{ github.event.pull_request.base.sha }}
run: |
network=false
if [[ "$EVENT" == "workflow_dispatch" || "$EVENT" == "schedule" ]]; then
network=true
else
base="$BASE"
[[ -z "$base" ]] && base="$BEFORE"
git init -q repo && cd repo
git remote add origin "https://github.com/$REPO"
git fetch -q --depth 1 origin "$SHA"
# Run the tests if the base of the change can't be determined (e.g., a new
# branch or an unfetchable pre-force-push commit)
if [[ -z "$base" || "$base" == 0000000000000000000000000000000000000000 ]] \
|| ! git fetch -q --depth 1 origin "$base" 2>/dev/null; then
network=true
elif git diff --name-only "$base" "$SHA" \
| grep -qE '^(app/config\.sh|chrome/content/zotero/xpcom/(db|file)\.js|\.github/workflows/ci\.yml)$'; then
network=true
fi
fi
echo "network=$network" >> "$GITHUB_OUTPUT"
test:
name: Test (${{ matrix.shard }})
runs-on: ubuntu-latest
timeout-minutes: 30
needs: changes
# Same-repo PRs are covered by the push run, so run pull_request jobs only for forks
if: >
github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name != github.repository
strategy:
matrix:
# Numbered shards split the full suite. 'smb' and 'nfs' run the db and file tests
# with the test data directory on a loopback CIFS or NFS mount, so that connection
# initialization itself runs against a network filesystem, which is where
# network-filesystem failures (startup crashes and hangs) occur. The network shards
# run only when the 'changes' job detects relevant changes.
shard: ${{ fromJSON(needs.changes.outputs.network == 'true'
&& '["1", "2", "3", "4", "smb", "nfs"]'
|| '["1", "2", "3", "4"]') }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
lfs: true
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 24
#cache: npm
# On GitHub
- name: Install xvfb
if: env.ACT != 'true'
run: sudo apt update && sudo apt install -y xvfb
# Local via act
- name: Install packages for act
if: env.ACT == 'true'
run: apt update && apt install -y zstd xvfb dbus-x11 libgtk-3-0 libx11-xcb1 libdbus-glib-1-2 libxt6
- name: Cache xulrunner
id: xulrunner-cache
uses: actions/cache@v4
with:
path: app/xulrunner/firefox-x86_64
key: xulrunner-${{ hashFiles('app/config.sh', 'app/scripts/fetch_xulrunner') }}
- name: Fetch xulrunner
if: steps.xulrunner-cache.outputs.cache-hit != 'true'
run: app/scripts/fetch_xulrunner -p l
- name: Cache Node modules
id: node-cache
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
key: node-modules-24-${{ hashFiles('package-lock.json') }}
- name: Install Node modules
if: steps.node-cache.outputs.cache-hit != 'true'
run: npm install
- name: Build Zotero
run: npm run build
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
env:
NODE_OPTIONS: --openssl-legacy-provider
# Create deployment ZIP from the build output, then replace build/ with the
# unzipped contents so that tests run against the same artifact that gets
# deployed. This catches problems that only manifest after a zip round-trip
@ -74,19 +133,282 @@ jobs:
cd build
unzip ../build.zip
# Build deployment ZIPs from main, version branches (e.g., 9.0, 10.0), and *-hotfix branches
- name: Set up SMB share
if: matrix.shard == 'smb'
timeout-minutes: 10
run: |
sudo apt install -y --no-install-recommends samba cifs-utils
sudo mkdir -p /srv/zotero-smb /mnt/zotero-smb
sudo chown $(whoami) /srv/zotero-smb
sudo tee -a /etc/samba/smb.conf > /dev/null <<EOF
[ztest]
path = /srv/zotero-smb
read only = no
EOF
(echo smbtestpass; echo smbtestpass) | sudo smbpasswd -s -a $(whoami)
sudo systemctl restart smbd
sudo mount -t cifs //127.0.0.1/ztest /mnt/zotero-smb \
-o user=$(whoami),pass=smbtestpass,vers=3.0,uid=$(id -u),gid=$(id -g)
- name: Set up NFS share
if: matrix.shard == 'nfs'
timeout-minutes: 10
run: |
sudo apt install -y --no-install-recommends nfs-kernel-server
sudo mkdir -p /srv/zotero-nfs /mnt/zotero-nfs
sudo chown $(whoami) /srv/zotero-nfs
echo "/srv/zotero-nfs localhost(rw,no_subtree_check,insecure)" | sudo tee -a /etc/exports
sudo exportfs -ra
sudo mount -t nfs4 localhost:/srv/zotero-nfs /mnt/zotero-nfs
- name: Run tests
if: matrix.shard != 'smb' && matrix.shard != 'nfs'
run: xvfb-run test/runtests.sh -f -r 3 -p ${{ matrix.shard }}/4
# The step timeout turns a startup hang into a failure
- name: Run tests on network share
if: matrix.shard == 'smb' || matrix.shard == 'nfs'
timeout-minutes: 10
run: TMPDIR=/mnt/zotero-${{ matrix.shard }} xvfb-run test/runtests.sh -f -r 3 -b db file
# Run the db and file tests on macOS with the test data directory on a loopback NFS
# mount. On macOS, SQLite selects locking methods without shared-memory support on
# network filesystems, which is what the network-filesystem database handling has to
# detect and handle, so this runs the real code path that crashed on SMB/NFS/sshfs
# data directories.
test-mac:
name: Test (macOS NFS)
runs-on: macos-15
timeout-minutes: 30
needs: changes
if: >
needs.changes.outputs.network == 'true'
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name != github.repository)
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
lfs: true
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 24
- name: Cache xulrunner
id: xulrunner-cache
uses: actions/cache@v4
with:
path: app/xulrunner/Firefox.app
key: xulrunner-mac-${{ hashFiles('app/config.sh', 'app/scripts/fetch_xulrunner') }}
- name: Fetch xulrunner
if: steps.xulrunner-cache.outputs.cache-hit != 'true'
run: app/scripts/fetch_xulrunner -p m
- name: Cache Node modules
id: node-cache
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-mac-24-${{ hashFiles('package-lock.json') }}
- name: Install Node modules
if: steps.node-cache.outputs.cache-hit != 'true'
run: npm install
- name: Build Zotero
run: npm run build
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Set up NFS share
run: |
sudo mkdir -p /private/var/zotero-nfs
sudo chown $(whoami) /private/var/zotero-nfs
echo "/private/var/zotero-nfs -mapall=$(whoami) localhost" | sudo tee /etc/exports
sudo nfsd enable || sudo nfsd start
sleep 2
showmount -e localhost
mkdir -p "$HOME/zotero-nfs"
sudo mount_nfs -o vers=3 localhost:/private/var/zotero-nfs "$HOME/zotero-nfs"
# The step timeout turns a startup hang into a failure
- name: Run tests on network share
timeout-minutes: 10
run: TMPDIR="$HOME/zotero-nfs" test/runtests.sh -f -r 3 -b db file
# Boot the Windows build -- which uses custom-built Firefox components (xul.dll) rather
# than stock Mozilla binaries -- and run the db and file tests. A Gecko bump commit
# updates the custom-component hashes in config.sh along with the version, so this
# tests each new set of custom components.
test-win:
name: Test (Windows ${{ matrix.arch }})
runs-on: ${{ matrix.os }}
timeout-minutes: 40
needs: changes
if: >
needs.changes.outputs.network == 'true'
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name != github.repository)
strategy:
fail-fast: false
matrix:
include:
- arch: x64
os: windows-latest
- arch: arm64
os: windows-11-arm
defaults:
run:
shell: bash
steps:
# The repository contains symlinks, which Git checks out as plain files on Windows
# by default, breaking the build
- name: Enable symlinks
run: git config --global core.symlinks true
- uses: actions/checkout@v4
with:
submodules: recursive
lfs: true
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 24
# Git Bash has unzip but not zip, which fetch_xulrunner needs to repack omni.ja
- name: Install zip
run: choco install zip --no-progress -y
- name: Cache xulrunner
id: xulrunner-cache
uses: actions/cache@v4
with:
path: app/xulrunner/firefox-win-${{ matrix.arch }}
key: xulrunner-win-${{ matrix.arch }}-${{ hashFiles('app/config.sh', 'app/scripts/fetch_xulrunner') }}
- name: Fetch xulrunner
if: steps.xulrunner-cache.outputs.cache-hit != 'true'
run: app/scripts/fetch_xulrunner -p w -a ${{ matrix.arch }}
- name: Cache Node modules
id: node-cache
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-win-24-${{ hashFiles('package-lock.json') }}
- name: Install Node modules
if: steps.node-cache.outputs.cache-hit != 'true'
run: npm install
# Committed symlinks point into node_modules, which doesn't exist at checkout time,
# so Git creates the directory ones with the wrong symlink type on Windows. Recreate
# them now that the targets exist.
- name: Recreate symlinks
run: |
git ls-files -s | awk '$1 == 120000 { print $4 }' | xargs rm
git checkout -- .
- name: Build Zotero
run: npm run build
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
env:
NODE_OPTIONS: --openssl-legacy-provider
# The step timeout turns a startup hang into a failure
- name: Run tests
timeout-minutes: 15
run: test/runtests.sh -f -r 3 -b -x app/staging/Zotero_win-${{ matrix.arch }}/zotero.exe db file
utilities:
name: Utilities Tests
runs-on: ubuntu-latest
if: >
github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name != github.repository
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 24
- name: Cache utilities Node modules
id: utilities-node-cache
uses: actions/cache@v4
with:
path: chrome/content/zotero/xpcom/utilities/node_modules
key: utilities-node-modules-24-${{ hashFiles('chrome/content/zotero/xpcom/utilities/package-lock.json') }}
- name: Install utilities Node modules
if: steps.utilities-node-cache.outputs.cache-hit != 'true'
run: npm install --prefix chrome/content/zotero/xpcom/utilities
- name: Run utilities tests
run: |
npm test --prefix chrome/content/zotero/xpcom/utilities -- -j resource/schema/global/schema.json
# Build deployment ZIPs from main, version branches (e.g., 9.0, 10.0), and *-hotfix branches
deploy:
name: Build, Upload
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.repository == 'zotero/zotero'
steps:
- name: Check if deployment branch
id: check-deploy
if: |
env.ACT != 'true'
&& github.repository == 'zotero/zotero'
&& github.event_name == 'push'
if: env.ACT != 'true'
run: |
branch="${GITHUB_REF#refs/heads/}"
if [[ "$branch" == "main" || "$branch" =~ ^[0-9]+\.[0-9]+$ || "$branch" == *-hotfix ]]; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
if: steps.check-deploy.outputs.deploy == 'true'
with:
submodules: recursive
lfs: true
- name: Install Node
if: steps.check-deploy.outputs.deploy == 'true'
uses: actions/setup-node@v4
with:
node-version: 24
- name: Cache Node modules
id: node-cache
if: steps.check-deploy.outputs.deploy == 'true'
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-24-${{ hashFiles('package-lock.json') }}
- name: Install Node modules
if: steps.check-deploy.outputs.deploy == 'true' && steps.node-cache.outputs.cache-hit != 'true'
run: npm install
- name: Build Zotero
if: steps.check-deploy.outputs.deploy == 'true'
run: npm run build
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Create deployment ZIP
if: steps.check-deploy.outputs.deploy == 'true'
run: |
cd build
zip -r ../build.zip *
cd ..
- uses: ruby/setup-ruby@v1
if: steps.check-deploy.outputs.deploy == 'true'
with:
@ -103,21 +425,3 @@ jobs:
cp build.zip build-zip/$GITHUB_SHA.zip
gem install --no-document dpl -v '>= 2.0'
dpl s3 --bucket zotero-download --local_dir build-zip --upload_dir ci/client --acl public_read
- name: Run tests
run: xvfb-run test/runtests.sh -f -r 3
- name: Cache utilities Node modules
id: utilities-node-cache
uses: actions/cache@v4
with:
path: chrome/content/zotero/xpcom/utilities/node_modules
key: utilities-node-modules-${{ hashFiles('chrome/content/zotero/xpcom/utilities/package-lock.json') }}
- name: Install utilities Node modules
if: steps.utilities-node-cache.outputs.cache-hit != 'true'
run: npm install --prefix chrome/content/zotero/xpcom/utilities
- name: Run utilities tests
run: |
npm test --prefix chrome/content/zotero/xpcom/utilities -- -j resource/schema/global/schema.json

View file

@ -7,8 +7,8 @@ Copyright=Copyright (c) 2006-2025 Contributors
ID=zotero@zotero.org
[Gecko]
MinVersion=140.0
MaxVersion=140.99.*
MinVersion=153.0
MaxVersion=153.99.*
[XRE]
EnableExtensionManager=1

View file

@ -50,6 +50,7 @@ if (processTestOptions) {
TestOptions.bail = cmdLine.handleFlag("bail", false);
TestOptions.startAt = cmdLine.handleFlagWithParam("startAtTestFile", false);
TestOptions.stopAt = cmdLine.handleFlagWithParam("stopAtTestFile", false);
TestOptions.shard = cmdLine.handleFlagWithParam("shard", false);
TestOptions.grep = cmdLine.handleFlagWithParam("grep", false);
TestOptions.timeout = cmdLine.handleFlagWithParam("ZoteroTestTimeout", false);
TestOptions.retries = cmdLine.handleFlagWithParam("retries", false) || 0;

View file

@ -42,7 +42,7 @@ Options
-f FILE ZIP file to build from (cannot be used with -d)
-t add devtools
-p PLATFORMS build for platforms PLATFORMS (m=Mac, w=Windows, l=Linux)
-a ARCH architecture to build (arm64, x64, i686, win32)
-a ARCH architecture to build (arm64, x64, win32)
* Ignored for Mac (always universal)
* If omitted on Windows/Linux, all standard archs are built
-c CHANNEL use update channel CHANNEL
@ -59,6 +59,17 @@ function cleanup {
}
trap cleanup EXIT
# Copy the contents of a directory into another directory, which may already exist,
# using rsync if it's available (not on Windows)
function copy_dir {
if command -v rsync > /dev/null; then
rsync -a "$1/" "$2/"
else
mkdir -p "$2"
cp -a "$1/." "$2/"
fi
}
function abspath {
echo $(cd $(dirname $1); pwd)/$(basename $1);
}
@ -210,7 +221,7 @@ if [ $BUILD_LINUX == 1 ]; then
if [[ -n $arch ]]; then
check_xulrunner_hash l $(get_canonical_arch l $arch)
else
for _a in x64 arm64 i686; do
for _a in x64 arm64; do
check_xulrunner_hash l "$_a"
done
fi
@ -296,7 +307,7 @@ elif [[ $BUILD_LINUX == 1 ]]; then
if [[ -n $arch ]]; then
omni_arch=$(get_canonical_arch l $arch)
else
for cand in x86_64 arm64 i686; do
for cand in x86_64 arm64; do
[[ -d "${LINUX_RUNTIME_PATH_PREFIX}${cand}" ]] && { omni_arch="$cand"; break; }
done
fi
@ -311,7 +322,6 @@ cd $omni_dir
rm actors/AboutLogins{Parent,Child}.sys.mjs
rm actors/AboutMessagePreview{Parent,Child}.sys.mjs
rm actors/AboutNewTab{Parent,Child}.sys.mjs
rm actors/AboutPocket{Parent,Child}.sys.mjs
rm actors/AboutPrivateBrowsing{Parent,Child}.sys.mjs
rm actors/AboutProtections{Parent,Child}.sys.mjs
rm actors/AboutReader{Parent,Child}.sys.mjs
@ -350,11 +360,10 @@ browser_keep=(
content/browser/parent/ext-browser.js
# For spellchecking
content/browser/built_in_addons.json
# Statically imported by BackupService, which SelectableProfileService pulls in when it's
# instantiated as a command-line handler at startup
content/browser/backup/backup-constants.mjs
)
if [ $BUILD_WIN == 1 ]; then
# Windows window controls
browser_keep+=(skin/classic/browser/window-controls)
fi
for file in "${browser_keep[@]}"; do
mkdir -p "$(dirname "chrome/browser-fx/$file")"
mv "chrome/browser/$file" "chrome/browser-fx/$file"
@ -376,11 +385,18 @@ if [ -n "$ZIP_FILE" ]; then
echo "Building from $ZIP_FILE"
unzip -q $ZIP_FILE -d "$omni_dir"
else
rsync_params=""
if [ $include_tests -eq 0 ]; then
rsync_params="--exclude /test"
if command -v rsync > /dev/null; then
rsync_params=""
if [ $include_tests -eq 0 ]; then
rsync_params="--exclude /test"
fi
rsync -a $rsync_params "$SOURCE_DIR/" ./
else
copy_dir "$SOURCE_DIR" .
if [ $include_tests -eq 0 ]; then
rm -rf ./test
fi
fi
rsync -a $rsync_params "$SOURCE_DIR/" ./
fi
mv defaults defaults-z
@ -406,6 +422,8 @@ echo "" >> $prefs_file
echo "# Zotero extension prefs" >> $prefs_file
echo "" >> $prefs_file
cat defaults-z/preferences/zotero.js >> $prefs_file
# Babel strips the trailing newline, so anything appended below would share a line
echo "" >> $prefs_file
rm -rf defaults-z
@ -574,11 +592,11 @@ fi
# Copy platform-specific assets
if [ $BUILD_MAC == 1 ]; then
rsync -a "$CALLDIR/assets/mac/" ./
copy_dir "$CALLDIR/assets/mac" .
elif [ $BUILD_WIN == 1 ]; then
rsync -a "$CALLDIR/assets/win/" ./
copy_dir "$CALLDIR/assets/win" .
elif [ $BUILD_LINUX == 1 ]; then
rsync -a "$CALLDIR/assets/unix/" ./
copy_dir "$CALLDIR/assets/unix" .
fi
# Add word processor plug-ins
@ -676,8 +694,8 @@ if [ $BUILD_MAC == 1 ]; then
# Merge relevant assets from Firefox
mkdir "$CONTENTSDIR/MacOS"
cp -r "$MAC_RUNTIME_PATH/Contents/MacOS/"!(firefox|firefox-bin|crashreporter.app|minidump-analyzer|nmhproxy|pingsender|updater.app) "$CONTENTSDIR/MacOS"
cp -r "$MAC_RUNTIME_PATH/Contents/Resources/"!(application.ini|browser|defaults|precomplete|removed-files|updater.ini|update-settings.ini|webapprt*|*.icns|*.lproj) "$CONTENTSDIR/Resources"
cp -r "$MAC_RUNTIME_PATH/Contents/MacOS/"!(firefox|firefox-bin|crashhelper|crashreporter.app|minidump-analyzer|nmhproxy|pingsender|updater.app) "$CONTENTSDIR/MacOS"
cp -r "$MAC_RUNTIME_PATH/Contents/Resources/"!(application.ini|Assets.car|browser|defaults|precomplete|removed-files|updater.ini|update-settings.ini|webapprt*|*.icns|*.lproj) "$CONTENTSDIR/Resources"
# Add our custom ChannelPrefs.framework and change channel if not a source build
mkdir "$CONTENTSDIR/Frameworks"
@ -721,7 +739,7 @@ if [ $BUILD_MAC == 1 ]; then
echo
# Copy app files
rsync -a "$base_dir/" "$CONTENTSDIR/Resources/"
copy_dir "$base_dir" "$CONTENTSDIR/Resources"
# Add word processor plug-ins
mkdir "$CONTENTSDIR/Resources/integration"
@ -732,12 +750,66 @@ if [ $BUILD_MAC == 1 ]; then
find "$CONTENTSDIR" -depth -type d -name .git -exec rm -rf {} \;
find "$CONTENTSDIR" \( -name .DS_Store -or -name update.rdf \) -exec rm -f {} \;
# Add Safari App Extension -- this depends on signing but needs to be done before generating
# Add Safari extensions -- this depends on signing but needs to be done before generating
# the precomplete file
#
# $SAFARI_APPEX is a stub appex built from the safari-web-extension wrapper project. The web
# extension itself comes from $SAFARI_EXT_RESOURCES (a zotero-connectors build/safari
# directory), which replaces the stub's placeholder resources here before signing.
#
# $SAFARI_APP_EXTENSION is an optional prebuilt legacy Safari App Extension, embedded
# alongside the web extension for Safari versions that can't load Developer ID web
# extensions (supported in Safari 18.4 and later). On Safari versions that can load the
# web extension, the SFSafariAppExtensionBundleIdentifiersToReplace key causes it to
# replace the App Extension.
if [[ $SIGN == 1 ]] && [[ -n "$SAFARI_APPEX" ]] && [[ -d "$SAFARI_APPEX" ]]; then
if [[ -z "${SAFARI_EXT_RESOURCES:-}" ]] || [[ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]]; then
echo "SAFARI_EXT_RESOURCES doesn't contain a web extension -- aborting" 2>&1
exit 1
fi
bundle_identifier=$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$APPDIR/Contents/Info.plist")
mkdir "$APPDIR/Contents/PlugIns"
cp -R $SAFARI_APPEX "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
rm -rf "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/test/"
webext_appex="$APPDIR/Contents/PlugIns/ZoteroSafariWebExtension.appex"
cp -R "$SAFARI_APPEX" "$webext_appex"
appex_resources="$webext_appex/Contents/Resources"
rm -rf "$appex_resources"
mkdir "$appex_resources"
cp -R "$SAFARI_EXT_RESOURCES/." "$appex_resources/"
# Show the connector version in Safari
connector_version=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1]))['version'])" "$appex_resources/manifest.json")
if [[ $connector_version == *999* ]] && [ "$UPDATE_CHANNEL" != "test" ]; then
echo "Placeholder connector version $connector_version not allowed for '$UPDATE_CHANNEL' channel -- aborting" 2>&1
exit 1
fi
/usr/libexec/PlistBuddy -c "Set CFBundleShortVersionString $connector_version" \
"$webext_appex/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set CFBundleVersion $connector_version" \
"$webext_appex/Contents/Info.plist"
# Give the appex the same bundle identifier prefix as the parent app
/usr/libexec/PlistBuddy -c "Set CFBundleIdentifier $bundle_identifier.SafariWebExtension" \
"$webext_appex/Contents/Info.plist"
# Replace the legacy App Extension on Safari versions that can load the web extension
/usr/libexec/PlistBuddy -c "Add :NSExtension:SFSafariAppExtensionBundleIdentifiersToReplace array" \
"$webext_appex/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Add :NSExtension:SFSafariAppExtensionBundleIdentifiersToReplace:0 string $bundle_identifier.SafariExtension" \
"$webext_appex/Contents/Info.plist"
# Add legacy Safari App Extension
if [[ -n "${SAFARI_APP_EXTENSION:-}" ]]; then
if [[ ! -d "$SAFARI_APP_EXTENSION" ]]; then
echo "SAFARI_APP_EXTENSION not found at $SAFARI_APP_EXTENSION -- aborting" 2>&1
exit 1
fi
appext_appex="$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
cp -R "$SAFARI_APP_EXTENSION" "$appext_appex"
rm -rf "$appext_appex/Contents/Resources/safari/test"
/usr/libexec/PlistBuddy -c "Set CFBundleIdentifier $bundle_identifier.SafariExtension" \
"$appext_appex/Contents/Info.plist"
fi
fi
# Copy over removed-files and make a precomplete file
@ -773,34 +845,36 @@ if [ $BUILD_MAC == 1 ]; then
# Sign .jnilib (Java native shared library) within LibreOffice extension, since notarization
# started failing without this. The .jnilib is within a .jar within the .oxt, so we have to
# extract both, sign the library, and then update each ZIP.
# extract both, sign the libraries, and then update each ZIP.
#
# TODO: Remove this block once the plugin ships a jna.jar without the macOS native
# libraries, which are never loaded (JNA is used only on Windows)
pushd "$BUILD_DIR"
mkdir libreoffice-repack
cd libreoffice-repack
unzip -q "$APPDIR/Contents/Resources/integration/libreoffice/Zotero_LibreOffice_Integration.oxt" external_jars/jna.jar
unzip -q external_jars/jna.jar com/sun/jna/darwin/libjnidispatch.jnilib
/usr/bin/codesign --force --options runtime --sign "$DEVELOPER_ID" com/sun/jna/darwin/libjnidispatch.jnilib
zip -u external_jars/jna.jar com/sun/jna/darwin/libjnidispatch.jnilib
unzip -q external_jars/jna.jar 'com/sun/jna/darwin*/libjnidispatch.jnilib'
/usr/bin/codesign --force --options runtime --sign "$DEVELOPER_ID" com/sun/jna/darwin*/libjnidispatch.jnilib
zip -u external_jars/jna.jar com/sun/jna/darwin*/libjnidispatch.jnilib
zip -u "$APPDIR/Contents/Resources/integration/libreoffice/Zotero_LibreOffice_Integration.oxt" external_jars/jna.jar
cd ..
rm -rf libreoffice-repack
popd
# Sign Safari App Extension
# Sign Safari extensions
#
# Even though it's signed by Xcode, we sign it again to make sure it matches the parent app signature
if [ -d "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex" ]; then
# Even though they're signed by Xcode, we sign them again to make sure they match the parent app signature
for appex in "$APPDIR"/Contents/PlugIns/*.appex; do
if [ ! -d "$appex" ]; then
continue
fi
echo
# Extract entitlements, which differ from parent app
/usr/bin/codesign -d --entitlements "$BUILD_DIR/safari-entitlements.plist" --xml "$SAFARI_APPEX"
/usr/bin/codesign -d --entitlements "$BUILD_DIR/safari-entitlements.plist" --xml "$appex"
# Change appex bundle identifier to have same prefix as parent app
bundle_identifier=$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$APPDIR/Contents/Info.plist")
perl -pi -e "s/org\.zotero\.SafariExtensionApp\.SafariExtension/$bundle_identifier.SafariExtension/" "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Info.plist"
find "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex/Contents" -name '*.dylib' -exec /usr/bin/codesign --force --options runtime --entitlements "$entitlements_file" --sign "$DEVELOPER_ID" {} \;
/usr/bin/codesign --force --options runtime --entitlements "$BUILD_DIR/safari-entitlements.plist" --sign "$DEVELOPER_ID" "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
fi
find "$appex/Contents" -name '*.dylib' -exec /usr/bin/codesign --force --options runtime --entitlements "$entitlements_file" --sign "$DEVELOPER_ID" {} \;
/usr/bin/codesign --force --options runtime --entitlements "$BUILD_DIR/safari-entitlements.plist" --sign "$DEVELOPER_ID" "$appex"
done
# Sign final app package
echo
@ -808,11 +882,14 @@ if [ $BUILD_MAC == 1 ]; then
# Verify app
/usr/bin/codesign --verify -vvvv "$APPDIR"
# Verify Safari App Extension
if [[ -n "$SAFARI_APPEX" ]] && [[ -d "$SAFARI_APPEX" ]]; then
# Verify Safari extensions
for appex in "$APPDIR"/Contents/PlugIns/*.appex; do
if [ ! -d "$appex" ]; then
continue
fi
echo
/usr/bin/codesign --verify -vvvv "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
fi
/usr/bin/codesign --verify -vvvv "$appex"
done
fi
# Build and notarize disk image
@ -885,7 +962,7 @@ if [ $BUILD_WIN == 1 ]; then
#
# 'i686' is a huge directory containing x86 versions of xul.dll and other files in
# Firefox ARM64 builds for use with the EME DRM plugins
cp -R "$runtime_path"/!(application.ini|browser|crashreporter*|default-browser-agent.exe|defaultagent*|defaults|devtools-files|firefox*|i686|maintenanceservice*|minidump-analyzer.exe|pingsender.exe|private_browsing*|precomplete|removed-files|uninstall|update*) "$APPDIR"
cp -R "$runtime_path"/!(application.ini|browser|crashhelper.exe|crashreporter*|default-browser-agent.exe|defaultagent*|defaults|desktop-launcher|devtools-files|firefox*|i686|maintenanceservice*|minidump-analyzer.exe|nmhproxy.exe|pingsender.exe|private_browsing*|precomplete|removed-files|uninstall|update*) "$APPDIR"
# Copy zotero.exe, which is built directly from Firefox source and then modified by
# ResourceHacker to add icons
@ -910,11 +987,11 @@ if [ $BUILD_WIN == 1 ]; then
fi
# Copy app files
rsync -a "$base_dir/" "$APPDIR/"
copy_dir "$base_dir" "$APPDIR"
#mv "$APPDIR/app/application.ini" "$APPDIR/"
# Copy in common files
rsync -a "$COMMON_APPDIR/" "$APPDIR/"
copy_dir "$COMMON_APPDIR" "$APPDIR"
cat "$CALLDIR/win/installer/updater_append.ini" >> "$APPDIR/updater.ini"
@ -1056,7 +1133,7 @@ if [ $BUILD_LINUX == 1 ]; then
if [[ -n $arch ]]; then
archs=("$(get_canonical_arch l $arch)")
else
archs=(x64 arm64 i686)
archs=(x64 arm64)
fi
for arch in "${archs[@]}"; do
[[ $arch == x64 ]] && arch="x86_64"
@ -1070,7 +1147,7 @@ if [ $BUILD_LINUX == 1 ]; then
mkdir "$APPDIR"
# Merge relevant assets from Firefox
cp -r "$runtime_path/"!(application.ini|browser|defaults|devtools-files|crashreporter|crashreporter.ini|firefox|pingsender|precomplete|removed-files|run-mozilla.sh|update-settings.ini|updater|updater.ini) "$APPDIR"
cp -r "$runtime_path/"!(application.ini|browser|crashhelper|crashreporter|crashreporter.ini|defaults|devtools-files|firefox|pingsender|precomplete|removed-files|run-mozilla.sh|update-settings.ini|updater|updater.ini) "$APPDIR"
# Use our own launcher that calls the original Firefox executable with -app
mv "$APPDIR"/firefox-bin "$APPDIR"/zotero-bin
@ -1086,7 +1163,7 @@ if [ $BUILD_LINUX == 1 ]; then
chmod 755 "$APPDIR/updater"
# Copy app files
rsync -a "$base_dir/" "$APPDIR/"
copy_dir "$base_dir" "$APPDIR"
# Add word processor plug-ins
mkdir "$APPDIR/integration"

View file

@ -1,17 +1,17 @@
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Version of Gecko to build with
GECKO_VERSION_MAC="140.11.0esr"
GECKO_VERSION_LINUX="140.11.0esr"
GECKO_VERSION_WIN="140.10.0esr"
RUST_VERSION=1.86.0
GECKO_VERSION_MAC="153.1.0esr"
GECKO_VERSION_LINUX="153.1.0esr"
GECKO_VERSION_WIN="153.1.0esr"
RUST_VERSION=1.94.0
# URL prefix for custom builds of Firefox components
custom_components_url="https://download.zotero.org/dev/firefox-components/"
custom_components_hash_mac=""
custom_components_hash_win_x64="d4f13db655004401c114fb937b1535a74919b6516fae637e35847734bde4d602"
custom_components_hash_win_arm64="69390facb8502fc40127f1ad80012858a3717935d5806331f1b17f54b05c2ca8"
custom_components_hash_win32="163e98eecf2c17579f47e1c2cfa1eb44e204cc26bcb220bb7c31168ab866e2ef"
custom_components_hash_win_x64="ac2cd5e2d4190c07af1649ca0372ff07dae70afd880d309441dafb575ccdc61a"
custom_components_hash_win_arm64="2369eb75912fb91633fb16d0f756c78d41f533522a3415bb4746c2a911547083"
custom_components_hash_win32="8eea54a3754b5f5331fc9147a6e7741976d70ddb48c8759f3b1a77e8729d8019"
APP_NAME="Zotero"
APP_ID="zotero\@zotero.org"

View file

@ -6,7 +6,7 @@ APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
. "$APP_ROOT_DIR/config.sh"
if [ -z "${1:-}" ]; then
echo "Usage: $0 x64|arm64|i686" >&2
echo "Usage: $0 x64|arm64" >&2
exit 1
fi
@ -24,8 +24,6 @@ if [ $arch = "x64" ]; then
rust_target=x86_64
elif [ $arch = "arm64" ]; then
rust_target=aarch64
elif [ $arch = "i686" ]; then
rust_target=i686
else
echo "Unknown architecture $arch" >&2
exit 1
@ -37,8 +35,6 @@ touch "$gecko_path/mozconfig"
if [ $arch == "arm64" ]; then
echo "ac_add_options --target=aarch64-linux-gnu" >> "$gecko_path/mozconfig"
elif [ $arch == "i686" ]; then
echo "ac_add_options --target=i686" >> "$gecko_path/mozconfig"
fi
cat "$SCRIPT_DIR/mozconfig" >> "$gecko_path/mozconfig"

View file

@ -11,7 +11,7 @@ fi
version="$1"
for arch in x86_64 aarch64 i686; do
for arch in x86_64 aarch64; do
package="firefox-${version}.en-US.linux-${arch}.tar.xz"
if [ $arch = 'aarch64' ]; then
arch="arm64"
@ -36,8 +36,8 @@ if [ `uname` = "Darwin" ]; then
else
tar=tar
fi
$tar --owner=0 --group=0 --numeric-owner --no-xattrs --mode=0755 -cJvf updater.tar.xz updater-x86_64 updater-arm64 updater-i686
$tar --owner=0 --group=0 --numeric-owner --no-xattrs --mode=0755 -cJvf updater.tar.xz updater-x86_64 updater-arm64
rm updater-x86_64 updater-arm64 updater-i686
rm updater-x86_64 updater-arm64
echo "Done: updater.tar.xz created"

View file

@ -303,6 +303,7 @@ my(@gCleanup, %gConfig, $gDarwinMajor, $gDryRun, $gVerbosity);
'cmd_chmod' => 'chmod',
'cmd_diskutil' => 'diskutil',
'cmd_du' => 'du',
'cmd_find' => 'find',
'cmd_hdid' => 'hdid',
'cmd_hdiutil' => 'hdiutil',
'cmd_mkdir' => 'mkdir',
@ -311,6 +312,7 @@ my(@gCleanup, %gConfig, $gDarwinMajor, $gDryRun, $gVerbosity);
'cmd_rm' => 'rm',
'cmd_rsync' => 'rsync',
'cmd_SetFile' => '/Developer/Tools/SetFile',
'cmd_xattr' => 'xattr',
# create_directly indicates whether hdiutil create supports
# -srcfolder and -srcdevice. It does on >= 10.3 (Panther).
@ -996,6 +998,65 @@ sub diskImageMaker($$$$$$$$) {
cleanupDie('unlink hybridImage failed: '.$!);
}
}
# hdiutil makehybrid gives every file on the image non-empty
# Finder info (it sets an icon location), which appears as a
# com.apple.FinderInfo extended attribute that "codesign
# --verify --strict" rejects as detritus and that can cause
# Safari to silently ignore a bundled web extension (observed
# with Safari 17.6 on macOS 12; the exact trigger conditions
# are unknown). Mount a read-write copy of the image and strip
# the attributes before compressing. Note that Finder flags set
# with --attribute are stored in the same Finder info, so if
# --attribute is ever used, the affected files would need to be
# excluded from the strip.
if($uncompressedImage eq $hybridImage) {
my($udrwImage);
$udrwImage = giveExtension($tempDir.'/udrw', '.dmg');
if(command($gConfig{'cmd_hdiutil'}, 'convert', '-format', 'UDRW',
'-ov', $hybridImage, '-o', $udrwImage) != 0) {
cleanupDie('hdiutil convert to UDRW failed');
}
push(@gCleanup,
sub {commandInternalVerbosity(0, 'unlink', $udrwImage);});
$uncompressedImage = $udrwImage;
# $hybridImage is no longer needed. Remove it and its cleanup
# entry, which is below the entry for $udrwImage.
my(@tempCleanup) = splice(@gCleanup, -2);
push(@gCleanup, $tempCleanup[1]);
if(commandInternal('unlink', $hybridImage) != 1) {
cleanupDie('unlink hybridImage failed: '.$!);
}
}
my($rootDevice, $partitionDevice, $partitionMountPoint);
if(!(($rootDevice, $partitionDevice, $partitionMountPoint) =
hdidMountImage($tempMount, $uncompressedImage))) {
cleanupDie('hdid mount failed');
}
push(@gCleanup, sub {commandVerbosity(0,
$gConfig{'cmd_diskutil'}, 'eject', $rootDevice);});
if(command($gConfig{'cmd_find'}, $partitionMountPoint,
'-mindepth', '1', '-not', '-type', 'l',
'-xattrname', 'com.apple.FinderInfo',
'-exec', $gConfig{'cmd_xattr'}, '-d', 'com.apple.FinderInfo',
'{}', '+') != 0) {
cleanupDie('stripping com.apple.FinderInfo failed');
}
# Pop diskutil eject
pop(@gCleanup);
if(command($gConfig{'cmd_diskutil'}, 'eject', $rootDevice) != 0) {
cleanupDie('diskutil eject failed');
}
}
else {
# makehybrid is not available, fall back to making a UDRW and

View file

@ -19,7 +19,8 @@ binary=$1
from_channel=$2
to_channel=$3
# `strings` has a 4-character minimum by default, but we need 3 for 'dev'
strings_cmd="strings -n 3"
# `-arch all` scans all slices of the universal binary rather than just the host architecture
strings_cmd="strings -n 3 -arch all"
if [ ${#to_channel} -gt 7 ]; then
echo "Channel length cannot exceed 7 characters -- aborting" >&2

@ -1 +1 @@
Subproject commit c216d787c4a3dfbb440b5db620fb14f2f185ad74
Subproject commit 227551c1644c6cb1156226d4db5d1b293fdb271d

@ -1 +1 @@
Subproject commit 084f16d78b4924b15ad24c83dd5d8aaaeb9998d8
Subproject commit 300886d1984de7a0006d7a48310baa0c8c99bea0

@ -1 +1 @@
Subproject commit 72e83647756b759ec80923922218fff8bf8b0343
Subproject commit c0aa6e4bef039d94e17e81cb28b1fe9170c45b96

View file

@ -1,49 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
. "$APP_ROOT_DIR/config.sh"
CHANNEL="release"
BRANCH="8.0"
BUILD_ONLY=0
if [ "`uname`" = "Darwin" ]; then
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/release/ZoteroSafariExtension.appex"
fi
while getopts "b" opt; do
case $opt in
b)
BUILD_ONLY=1
;;
esac
shift $((OPTIND-1)); OPTIND=1
done
cd "$SCRIPT_DIR"
./check_requirements
hash=`./get_repo_branch_hash $BRANCH`
source_dir=`./get_commit_files $hash`
build_dir=`mktemp -d`
function cleanup {
rm -rf "$source_dir"
rm -rf "$build_dir"
}
trap cleanup EXIT
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
VERSION="`cat \"$build_dir/version\"`"
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
if [ $BUILD_ONLY -eq 1 ]; then
echo
echo "Build only -- skipping deploy."
echo "To deploy, run on the deploy server:"
echo " $DEPLOY_PATH/deploy $CHANNEL $VERSION $BUILD_PLATFORMS"
else
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
fi

View file

@ -10,7 +10,16 @@ CHANNEL="release"
BRANCH="9.0"
BUILD_ONLY=0
if [ "`uname`" = "Darwin" ]; then
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/release/ZoteroSafariExtension.appex"
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/release/safari"
if [ ! -d "$SAFARI_APPEX" ]; then
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
exit 1
fi
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
exit 1
fi
fi
while getopts "b" opt; do
@ -26,6 +35,7 @@ cd "$SCRIPT_DIR"
./check_requirements
hash=`./get_repo_branch_hash $BRANCH`
./check_app_matches_commit $hash
source_dir=`./get_commit_files $hash`
build_dir=`mktemp -d`

View file

@ -37,11 +37,14 @@ win_path="$STAGE_DIR/Zotero_win-x64"
linux_path="$STAGE_DIR/Zotero_linux-x86_64"
added=0
build_id=$(date +%Y%m%d%H%M%S)
for path in "$mac_path" "$win_path" "$linux_path"; do
if [ -d "$path" ]; then
echo "$path/app/omni.ja"
echo "Updating $(basename $(dirname $(dirname $path)))"
zip "$path/app/omni.ja" $files
# Bump BuildID so that startup caches are invalidated
perl -pi -e "s/^BuildID=.*/BuildID=$build_id/" "$path/app/application.ini"
added=1
fi
done

View file

@ -4,6 +4,7 @@ Update a builds manifest with info on a given build
"""
import argparse
import os
import re
import sys
import shutil
import json
@ -25,7 +26,7 @@ def main():
try:
file = args.file
version = args.version
short_version = version[0:3]
short_version = re.match(r'\d+\.\d+', version).group(0)
# Back up JSON file
shutil.copy2(file, file + '.bak')

View file

@ -8,15 +8,39 @@ ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
CHANNEL="beta"
BRANCH="main"
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/beta/ZoteroSafariExtension.appex"
BUILD_ONLY=0
if [ "`uname`" = "Darwin" ]; then
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/beta/safari"
export SAFARI_APP_EXTENSION="$ROOT_DIR/../safari-app-extension-builds/beta/ZoteroSafariExtension.appex"
if [ ! -d "$SAFARI_APPEX" ]; then
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
exit 1
fi
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
exit 1
fi
if [ ! -d "$SAFARI_APP_EXTENSION" ]; then
echo "Safari App Extension not found at $SAFARI_APP_EXTENSION -- aborting" >&2
exit 1
fi
fi
# Set Safari extension LSMinimumSystemVersion to Mojave for betas
perl -pi -e 's/<string>11\.0<\/string>/<string>10.14<\/string>/' "$SAFARI_APPEX"/Contents/Info.plist
while getopts "b" opt; do
case $opt in
b)
BUILD_ONLY=1
;;
esac
shift $((OPTIND-1)); OPTIND=1
done
cd "$SCRIPT_DIR"
./check_requirements
hash=`./get_repo_branch_hash $BRANCH`
./check_app_matches_commit $hash
source_dir=`./get_commit_files $hash`
build_dir=`mktemp -d`
@ -29,4 +53,12 @@ trap cleanup EXIT
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
VERSION="`cat \"$build_dir/version\"`"
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
if [ $BUILD_ONLY -eq 1 ]; then
echo
echo "Build only -- skipping deploy."
echo "To deploy, run on the deploy server:"
echo " $DEPLOY_PATH/deploy $CHANNEL $VERSION $BUILD_PLATFORMS"
else
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
fi

View file

@ -10,13 +10,23 @@ if [ -n "${ZOTERO_PROFILE:-}" ]; then
profile_args=(-p "$ZOTERO_PROFILE")
fi
REBUILD=0
FORCE_FULL=0
NO_REBUILD=0
SKIP_BUNDLED_FILES=0
DEBUGGER=0
while getopts "rbd" opt; do
while getopts "rfnbd" opt; do
case $opt in
r)
REBUILD=1
# Deprecated -- rebuilding is now the default
echo "-r is deprecated -- rebuilding is now the default (use -n to skip)" >&2
;;
f)
FORCE_FULL=1
;;
n)
NO_REBUILD=1
;;
b)
@ -28,7 +38,6 @@ while getopts "rbd" opt; do
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 1
;;
esac
@ -37,12 +46,15 @@ done
# Remove options from $@
shift $((OPTIND-1))
if [ $REBUILD -eq 1 ]; then
if [ $NO_REBUILD -eq 0 ]; then
PARAMS=""
if [ $DEBUGGER -eq 1 ]; then
PARAMS="-t"
fi
if [ $FORCE_FULL -eq 1 ]; then
PARAMS="$PARAMS -f"
fi
# Check if build watch is running
# If not, run now
if ! ps u | grep js-build/build.js | grep -v grep > /dev/null; then
@ -50,16 +62,11 @@ if [ $REBUILD -eq 1 ]; then
echo
cd $ROOT_DIR
# TEMP: --openssl-legacy-provider avoids a build error in pdf.js
NODE_OPTIONS=--openssl-legacy-provider npm run build
NODE_OPTIONS=--openssl-legacy-provider node js-build/build.js
echo
fi
"$SCRIPT_DIR/dir_build" -q $PARAMS
if [ "`uname`" = "Darwin" ]; then
# Sign the Word dylib so it works on Apple Silicon
"$SCRIPT_DIR/codesign_local" "$APP_ROOT_DIR/staging/Zotero.app"
fi
"$SCRIPT_DIR/dir_build" $PARAMS
fi
PARAMS=""
@ -87,4 +94,5 @@ else
exit 1
fi
"$APP_ROOT_DIR/staging/$command" "${profile_args[@]}" -ZoteroDebugText -purgecaches $PARAMS "$@"
echo
"$APP_ROOT_DIR/staging/$command" "${profile_args[@]}" -ZoteroDebugText $PARAMS "$@"

View file

@ -121,7 +121,7 @@ do
fi
# Combine and deduplicate
INCREMENTALS="`echo -e "$INCREMENTALS\n$PINNED" | sort -u | grep -v '^$'`"
INCREMENTALS="`echo -e "$INCREMENTALS\n$PINNED" | sort -u | grep -v '^$' || true`"
echo "$INCREMENTALS"
echo
@ -144,7 +144,7 @@ chmod g+ws "$TEMP_DIR/version_info"
cp "$DIST_DIR"/files-* "$TEMP_DIR/version_info"
chmod g+w "$TEMP_DIR"/version_info/files-*
# Generate build-{os}.json for each platform
SHORT_VERSION="${VERSION:0:3}"
SHORT_VERSION=$(echo "$VERSION" | grep -oE '^[0-9]+\.[0-9]+')
DETAILS_URL="https://www.zotero.org/support/${SHORT_VERSION}_changelog"
for i in `seq 0 1 $((${#PLATFORMS}-1))`
do

View file

@ -0,0 +1,37 @@
#!/bin/bash
set -euo pipefail
# Check that app/ in this checkout matches app/ in the commit being built.
#
# The build-and-deploy scripts run the build scripts and config from the local
# checkout but build source files from the tip of the remote branch, so a stale
# checkout can silently build with the wrong Gecko version or omni patches.
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
if [ -z "${1:-}" ]; then
echo "Usage: $0 commit-hash" >&2
exit 1
fi
hash=$1
cd "$ROOT_DIR"
if ! git cat-file -e "$hash^{commit}" 2>/dev/null; then
git fetch -q origin
fi
if ! git cat-file -e "$hash^{commit}" 2>/dev/null; then
echo "Commit $hash not found locally after fetching -- aborting" >&2
exit 1
fi
if ! git diff --quiet HEAD "$hash" -- app/; then
echo >&2
echo "app/ files in this checkout differ from app/ in $hash:" >&2
echo >&2
git --no-pager diff --stat HEAD "$hash" -- app/ >&2
echo >&2
echo "Update this checkout (e.g., 'git pull') and try again" >&2
exit 1
fi

View file

@ -8,12 +8,24 @@ ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
CHANNEL="dev"
BRANCH="main"
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/dev/ZoteroSafariExtension.appex"
if [ "`uname`" = "Darwin" ]; then
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/dev/safari"
if [ ! -d "$SAFARI_APPEX" ]; then
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
exit 1
fi
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
exit 1
fi
fi
cd "$SCRIPT_DIR"
./check_requirements
hash=`./get_repo_branch_hash $BRANCH`
./check_app_matches_commit $hash
source_dir=`./get_commit_files $hash`
build_dir=`mktemp -d`

View file

@ -5,6 +5,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
. "$APP_ROOT_DIR/config.sh"
. "$SCRIPT_DIR/utils.sh"
function usage {
cat >&2 <<DONE
@ -15,9 +16,9 @@ Architecture is ignored for Mac builds, which are always shipped as universal bi
Options
-p PLATFORM Platform to build (m=Mac, w=Windows, l=Linux)
-a ARCH Target architecture (arm64, x64, i686, win32) — **Windows/Linux only**
-a ARCH Target architecture (arm64, x64, win32) — **Windows/Linux only**
-t add devtools
-q quick build (skip compression and other optional steps for faster restarts during development)
-f force a full rebuild instead of updating the staged build in place
DONE
exit 1
}
@ -25,8 +26,8 @@ DONE
platform=""
arch=""
devtools=0
quick_build=0
while getopts "tp:a:q" opt; do
force_full=0
while getopts "tp:a:f" opt; do
case $opt in
t)
devtools=1
@ -47,12 +48,11 @@ while getopts "tp:a:q" opt; do
a)
arch="$OPTARG"
;;
q)
quick_build=1
f)
force_full=1
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 1
usage
;;
esac
done
@ -76,29 +76,100 @@ if [[ $platform = "m" ]]; then
else
# Windows / Linux: derive arch if not supplied
if [[ -z $arch ]]; then
case "$(uname -m)" in
arm64|aarch64) arch="arm64" ;;
x86_64) arch="x64" ;;
i?86) arch="i686" ;;
esac
# An x64 build of Git for Windows runs emulated on Windows on ARM, where it
# reports x86_64 from `uname -m`, while `uname -s` reports the native
# architecture
if [[ $platform = "w" && "$(uname -s)" == *ARM64* ]]; then
arch="arm64"
else
case "$(uname -m)" in
arm64|aarch64) arch="arm64" ;;
x86_64) arch="x64" ;;
esac
fi
fi
fi
CHANNEL="source"
# Remove stale symlinks left behind in build/ when source files are deleted,
# which would otherwise break the build
if [ -d "$ROOT_DIR/build" ]; then
# With -L, -type l matches only broken symlinks
stale_links=$(find -L "$ROOT_DIR/build" -type l -print)
if [ -n "$stale_links" ]; then
echo "Removing stale symlinks from build/:" >&2
echo "$stale_links" >&2
while IFS= read -r link; do
rm "$link"
done <<< "$stale_links"
fi
fi
# Try to update the staged build in place instead of doing a full rebuild
if [[ $force_full -eq 0 ]]; then
incr_cmd=("$SCRIPT_DIR/incremental_update" -p "$platform")
[[ -n $arch ]] && incr_cmd+=( -a "$arch" )
[[ $devtools -eq 1 ]] && incr_cmd+=( -t )
set +e
"${incr_cmd[@]}"
incr_status=$?
set -e
if [ $incr_status -ne 2 ]; then
exit $incr_status
fi
fi
hash=$(git -C "$ROOT_DIR" rev-parse --short HEAD)
build_dir=$(mktemp -d)
cleanup() { rm -rf "$build_dir"; }
manifest_tmp=""
cleanup() {
rm -rf "$build_dir"
if [ -n "$manifest_tmp" ]; then
rm -f "$manifest_tmp"
fi
}
trap cleanup EXIT
# Snapshot the state of build/ and app/ before the build for later incremental
# updates
app_hash=""
if [ -d "$ROOT_DIR/build" ]; then
manifest_tmp=$(mktemp)
generate_build_manifest "$ROOT_DIR/build" > "$manifest_tmp"
app_hash=$(generate_app_hash "$APP_ROOT_DIR")
fi
"$SCRIPT_DIR/prepare_build" -s "$ROOT_DIR/build" -o "$build_dir" -c "$CHANNEL" -m "$hash"
build_cmd=("$APP_ROOT_DIR/build.sh" -d "$build_dir" -p "$platform" -c "$CHANNEL" -s)
build_cmd=("$APP_ROOT_DIR/build.sh" -d "$build_dir" -p "$platform" -c "$CHANNEL" -s -q)
[[ -n $arch ]] && build_cmd+=( -a "$arch" )
[[ $devtools -eq 1 ]] && build_cmd+=( -t )
[[ $quick_build -eq 1 ]] && build_cmd+=( -q )
"${build_cmd[@]}"
# Save manifest for future incremental updates
if [ -n "$manifest_tmp" ]; then
if [[ -z "${ZOTERO_TEST:-}" ]] || [[ "${ZOTERO_TEST:-}" == "0" ]]; then
include_tests=0
else
include_tests=1
fi
{
echo "#format=2"
echo "#include_tests=$include_tests"
echo "#devtools=$devtools"
echo "#app_hash=$app_hash"
cat "$manifest_tmp"
} > "$STAGE_DIR/.build-manifest"
fi
# Ad-hoc-sign the Word dylib so it works on Apple Silicon. This only needs to
# happen when the staged build is fully rebuilt -- incremental updates don't
# touch the dylib.
if [[ $platform = "m" ]] && [[ "$(uname -s)" = "Darwin" ]]; then
"$SCRIPT_DIR/codesign_local" "$STAGE_DIR/Zotero.app"
fi
echo Done

View file

@ -35,7 +35,7 @@ Required
Optional / Conditional
-a ARCH - Mac: disallowed (builds are universal)
- Windows: x64 | arm64 | win32
- Linux: x64 (or x86_64) | arm64 | i686
- Linux: x64 (or x86_64) | arm64
If omitted on Windows/Linux, all standard archs are fetched.
DONE
exit 1
@ -87,7 +87,7 @@ if [[ $BUILD_WIN == 1 || $BUILD_LINUX == 1 ]]; then
fi
if [[ $BUILD_LINUX == 1 ]]; then
[[ $arch == x64 ]] && arch="x86_64"
case $arch in x86_64|i686|arm64) ;; *) echo "Invalid Linux arch: $arch" >&2; echo; usage;; esac
case $arch in x86_64|arm64) ;; *) echo "Invalid Linux arch: $arch" >&2; echo; usage;; esac
fi
fi
fi
@ -133,6 +133,12 @@ function modify_omni {
rm actors/AudioPlayback{Parent,Child}.sys.mjs
replace_line 'BROWSER_CHROME_URL:.+' 'BROWSER_CHROME_URL: "chrome:\/\/zotero\/content\/zoteroPane.xhtml",' modules/AppConstants.sys.mjs
# Used by OSKeyStore as the master-key label, visible in macOS Keychain Access.
# Verify that OSKeyStore still derives the label from MOZ_APP_BASENAME, so a
# future Mozilla change to a hardcoded string doesn't silently rebrand the
# keychain entry back to "Firefox Encrypted Storage".
replace_line 'MOZ_APP_BASENAME: "Firefox"' 'MOZ_APP_BASENAME: "Zotero"' modules/AppConstants.sys.mjs
check_line 'STORE_LABEL: AppConstants\.MOZ_APP_BASENAME \+ " Encrypted Storage"' modules/OSKeyStore.sys.mjs
# https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/internals/preferences.html
#
@ -195,15 +201,17 @@ function modify_omni {
file="modules/ActorManagerParent.sys.mjs"
# Remove deleted actors
remove_between 'AboutTranslations: \{' '^ },' $file
remove_between 'CookieBanner: \{' '^ },' $file
remove_between 'PictureInPictureLauncher: \{' '^ },' $file
remove_between 'PictureInPictureToggle: \{' '^ },' $file
remove_between 'PictureInPicture: \{' '^ },' $file
remove_between 'Thumbnails: \{' '^ },' $file
remove_between 'Translations: \{' '^ },' $file
remove_between 'TranslationsEngine: \{' '^ },' $file
remove_between 'AudioPlayback: \{' '^ },' $file
# Entries in the JSWINDOWACTORS/JSPROCESSACTORS literals:
remove_between '^ AudioPlayback: \{' '^ },' $file
remove_between '^ CookieBanner: \{' '^ },' $file
remove_between '^ Thumbnails: \{' '^ },' $file
remove_between '^ Translations: \{' '^ },' $file
remove_between '^ TranslationsEngine: \{' '^ },' $file
# Entries added conditionally after the literals:
remove_between '^ JSWINDOWACTORS\.AboutTranslations = \{' '^ \};' $file
remove_between '^ JSWINDOWACTORS\.PictureInPictureLauncher = \{' '^ \};' $file
remove_between '^ JSWINDOWACTORS\.PictureInPictureToggle = \{' '^ \};' $file
remove_between '^ JSWINDOWACTORS\.PictureInPicture = \{' '^ \};' $file
# Do not trigger LoginManager event that logs an error on autocomplete submission
remove_line 'DOMInputPasswordAdded: \{\},' $file
@ -387,8 +395,12 @@ function modify_omni {
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.css"
echo >> $file
# Hide search bar, Themes and Plugins tabs, and sidebar footer
echo '.main-search, button[name="theme"], button[name="plugin"], sidebar-footer { display: none; }' >> $file
# Hide the search bar and the whole sidebar, since we only ever show plugins and the main pane
# is already headed "Manage Your Plugins"
echo '.main-search, #sidebar { display: none; }' >> $file
# Center the content in the window now that it isn't offset by the sidebar
echo '#full { grid-template-columns: 1fr; }' >> $file
echo '#content { max-width: calc(var(--page-main-content-width) + var(--main-margin-start)); margin-inline: auto; }' >> $file
echo '.main-heading { margin-top: 2em; }' >> $file
# Hide Details/Permissions tabs in addon details so we only show details
echo 'addon-details > button-group { display: none !important; }' >> $file
@ -404,46 +416,46 @@ function modify_omni {
echo '.addon-detail-row-homepage .text-link { cursor: pointer; color: LinkText; }' >> $file
echo '.addon-detail-row-homepage .text-link:hover { text-decoration: underline; }' >> $file
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.js"
file="chrome/toolkit/content/mozapps/extensions/aboutaddons-utils.mjs"
# Hide unsigned-addon warning
replace_line 'if \(!isCorrectlySigned\(addon\)\) \{' 'if (!isCorrectlySigned(addon)) {return {};' $file
replace_line 'export function isUnsignedWarningMessageDisabled\(\) \{' \
'export function isUnsignedWarningMessageDisabled() {if (true) return true;' $file
# Use our own localized string for blocked plugin notification
replace_line 'details-notification-hard-blocked-\$\{typeSuffix\}' 'plugins-blocked-plugin' $file
# Hide Recommendations tab in sidebar and recommendations in main pane
replace_line 'function isDiscoverEnabled\(\) \{' 'function isDiscoverEnabled() {return false;' $file
file="chrome/toolkit/content/mozapps/extensions/components/addon-details.mjs"
# Hide Private Browsing setting in addon details
replace_line 'pbRow\.' '\/\/pbRow.' $file
replace_line 'let isAllowed = await isAllowedInPrivateBrowsing' '\/\/let isAllowed = await isAllowedInPrivateBrowsing' $file
# Use our own strings for the removal prompt
replace_line 'let \{ BrowserAddonUI \} = windowRoot.ownerGlobal;' '' $file
replace_line 'await BrowserAddonUI.promptRemoveExtension' 'promptRemoveExtension' $file
# Customize empty-list message
replace_line 'createEmptyListMessage\(\) {' 'createEmptyListMessage() {
var p = document.createElement("p");
p.id = "empty-list-message";
return p;' $file
# Swap in include.js, which we need for Zotero.getString(), for abuse-reports.js, which we don't need
# Open plugin links in external browser
replace_line 'let homepageURL = homepageRow.querySelector\(\"a\"\);' 'let homepageURL = homepageRow.querySelector(\"\.text-link\");' $file
replace_line 'homepageURL.href = addon.homepageURL;' 'homepageURL.setAttribute("href", addon.homepageURL);' $file
replace_line '<a target="_blank" dir="ltr"><\/a>' \
'<label target="_blank" class="text-link" dir="ltr"><\/label>' \
chrome/toolkit/content/mozapps/extensions/aboutaddons.html
'<label target="_blank" class="text-link" dir="ltr"><\/label>' $file
file="chrome/toolkit/content/mozapps/extensions/components/addon-card.mjs"
# Use our own strings for the removal prompt
replace_line 'let \{ BrowserAddonUI \} = windowRoot.window;' '' $file
replace_line 'await BrowserAddonUI.promptRemoveExtension' 'promptRemoveExtension' $file
# Customize empty-list message
replace_line 'createEmptyListMessage\(\) {' 'createEmptyListMessage() {
var p = document.createElement("p");
p.id = "empty-list-message";
return p;' chrome/toolkit/content/mozapps/extensions/components/addon-list.mjs
# Add zotero.ftl to the addons page for our own localized strings
replace_line 'href="toolkit\/about\/aboutAddons.ftl"' 'href="toolkit\/about\/aboutAddons.ftl" \/>\n <link rel="localization" href="zotero.ftl"' \
chrome/toolkit/content/mozapps/extensions/aboutaddons.html
# Use our own localized string for blocked plugin notification
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.js"
replace_line 'details-notification-hard-blocked-\$\{typeSuffix\}' 'plugins-blocked-plugin' $file
# TODO: Use our own strings, but for now fix the bundled English ones
replace_line 'Manage Your Extensions' 'Manage Your Plugins' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line '= Extensions' '= Plugins' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line 'Add-on' 'Plugin' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line 'add-ons' 'plugins' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line 'Select add-on to install' 'Select plugin to install' localization/en-US/toolkit/about/aboutAddons.ftl
# Hide Recommendations tab in sidebar and recommendations in main pane
replace_line 'function isDiscoverEnabled\(\) \{' 'function isDiscoverEnabled() {return false;' chrome/toolkit/content/mozapps/extensions/aboutaddonsCommon.js
replace_line 'pref\("extensions.htmlaboutaddons.recommendations.enabled".+' 'pref("extensions.htmlaboutaddons.recommendations.enabled", false);' greprefs.js
# Hide Report option
@ -460,7 +472,7 @@ function modify_omni {
chrome/toolkit/content/global/commonDialog.xhtml
# commonDialog.css link is split across multiple lines, so we have to do a weird substitution,
# so check the one-line global.css to make sure the format hasn't changed
check_line '<html:link rel="stylesheet" href="chrome:\/\/global\/skin\/global.css" \/>'
check_line '<html:link rel="stylesheet" href="chrome:\/\/global\/skin\/global.css" \/>' chrome/toolkit/content/global/commonDialog.xhtml
replace_line 'chrome:\/\/global\/skin\/commonDialog.css"' \
'chrome:\/\/global\/skin\/commonDialog.css"\/>
<html:link rel="stylesheet" href="chrome:\/\/zotero-platform\/content\/zotero.css"' \
@ -490,8 +502,19 @@ function modify_omni {
}
popupset.appendChild(this._autoScrollPopup);' chrome/toolkit/content/global/elements/browser-custom-element.mjs
# Remove aria-autocomplete unnecessarily added to search-textbox to not confuse screen readers
remove_line 'this.inputField.setAttribute\("aria-autocomplete' chrome/toolkit/content/global/elements/search-textbox.js
# Add the dropmarker for toolbarbuttons with wantdropmarker, which Firefox no longer creates
replace_line 'this.appendChild\(this.constructor.fragment.cloneNode\(true\)\);' \
'this.appendChild(this.constructor.fragment.cloneNode(true));
if (this.hasAttribute("wantdropmarker")) {
let dropmarker = document.createXULElement("dropmarker");
dropmarker.setAttribute("type", "menu");
dropmarker.className = "toolbarbutton-menu-dropmarker";
this.appendChild(dropmarker);
}' chrome/toolkit/content/global/elements/toolbarbutton.js
# Let clicks on the dropmarker pass through to the toolbarbutton, which only opens its menu
# when it's the original event target, as xul.css did when Firefox created the dropmarker
echo '.toolbarbutton-menu-dropmarker { pointer-events: none; }' >> chrome/toolkit/content/global/xul.css
# Remove non-native text input styles
remove_between 'html\|input\:where\(' '^}' chrome/toolkit/skin/classic/global/global-shared.css
@ -505,10 +528,12 @@ function modify_omni {
overflow: var(--moz-text-control-overflow);
}' >> chrome/toolkit/res/forms.css
# By default, an autocomplete popup's width is calculated based on the input that opened it.
# Allow an ancestor to designate itself as the width container instead. (For creator inputs.)
replace_line 'aElement.getBoundingClientRect\(\).width' \
'(aElement.closest(".autocomplete-popup-width-container") || aElement).getBoundingClientRect().width' \
# Size an autocomplete popup to the input that opened it, or to an ancestor that designates
# itself as the width container. Firefox sizes the popup to its content. (For creator inputs.)
replace_line '\/\/ invalidate\(\) depends on the width attribute' \
'let widthElement = aElement.closest(".autocomplete-popup-width-container") || aElement;
this.style.setProperty("--panel-width", Math.max(widthElement.getBoundingClientRect().width, 100) + "px");
\/\/ invalidate() depends on the width attribute' \
chrome/toolkit/content/global/elements/autocomplete-popup.js
zip -qr9XD omni.ja *
@ -556,7 +581,7 @@ if [ $BUILD_MAC == 1 ]; then
echo "Using Firefox $GECKO_VERSION.app.zip"
unzip "Firefox $GECKO_VERSION.app.zip"
else
curl -o Firefox.dmg "$DOWNLOAD_URL/mac/en-US/Firefox%20$GECKO_VERSION.dmg"
curl -fo Firefox.dmg "$DOWNLOAD_URL/mac/en-US/Firefox%20$GECKO_VERSION.dmg"
set +e
hdiutil detach -quiet /Volumes/Firefox 2>/dev/null
set -e
@ -642,7 +667,7 @@ if [ $BUILD_WIN == 1 ]; then
else
moz_arch=$arch
fi
curl -O "$DOWNLOAD_URL/$moz_arch/en-US/Firefox%20Setup%20$GECKO_VERSION.exe"
curl -fO "$DOWNLOAD_URL/$moz_arch/en-US/Firefox%20Setup%20$GECKO_VERSION.exe"
# Store local copy of unmodified app
cp "Firefox%20Setup%20$GECKO_VERSION.exe" "$archived_file"
fi
@ -758,7 +783,7 @@ if [ $BUILD_LINUX == 1 ]; then
if [[ -n $arch ]]; then
arch_list=("$arch")
else
arch_list=(x86_64 arm64 i686)
arch_list=(x86_64 arm64)
fi
for arch in "${arch_list[@]}"; do
xdir="firefox-linux-$arch"
@ -774,7 +799,7 @@ if [ $BUILD_LINUX == 1 ]; then
else
moz_arch=$arch
fi
curl -O "$DOWNLOAD_URL/linux-$moz_arch/en-US/firefox-$GECKO_VERSION.tar.xz"
curl -fO "$DOWNLOAD_URL/linux-$moz_arch/en-US/firefox-$GECKO_VERSION.tar.xz"
# Store local copy of unmodified app
cp "firefox-$GECKO_VERSION.tar.xz" "$archived_file"
fi

322
app/scripts/incremental_update Executable file
View file

@ -0,0 +1,322 @@
#!/bin/bash
set -euo pipefail
#
# Incrementally update a staged build in staging/ by zipping changed files from
# the client build/ directory directly into app/omni.ja, based on a manifest
# saved by dir_build after the last full build.
#
# Only files that are copied into omni.ja unmodified by build.sh can be updated
# this way. If any other file changed (or a file was removed), a full rebuild
# is required.
#
# Exit codes:
# 0 -- staged build was updated (or nothing changed)
# 2 -- a full rebuild is required
#
# This is normally run automatically by dir_build, not directly.
#
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ROOT_DIR="$(dirname "$APP_ROOT_DIR")"
. "$APP_ROOT_DIR/config.sh"
. "$SCRIPT_DIR/utils.sh"
function usage {
cat >&2 <<DONE
Usage: $0 -p PLATFORM [-a ARCH] [-t]
Options
-p PLATFORM platform of the staged build to update (m=Mac, w=Windows, l=Linux)
-a ARCH target architecture (Windows/Linux only)
-t devtools requested
DONE
exit 1
}
platform=""
arch=""
devtools=0
while getopts "p:a:t" opt; do
case $opt in
p)
platform="$OPTARG"
;;
a)
arch="$OPTARG"
;;
t)
devtools=1
;;
*)
usage
;;
esac
done
if [ -z "$platform" ]; then
usage
fi
BUILD_SRC_DIR="$ROOT_DIR/build"
MANIFEST_FILE="$STAGE_DIR/.build-manifest"
function fall_back {
echo "$1 -- performing full build" >&2
exit 2
}
if [ ! -d "$BUILD_SRC_DIR" ]; then
fall_back "No build/ directory"
fi
if [ ! -f "$MANIFEST_FILE" ]; then
fall_back "No manifest from previous build"
fi
if [[ "$(sed -n 's/^#format=//p' "$MANIFEST_FILE")" != "2" ]]; then
fall_back "Unsupported manifest format"
fi
# Make sure a staged build exists for the requested platform
case $platform in
m)
app_dir="$STAGE_DIR/Zotero.app/Contents/Resources"
;;
w)
app_dir="$STAGE_DIR/Zotero_$(get_canonical_arch w "$arch")"
;;
l)
app_dir="$STAGE_DIR/Zotero_linux-$(get_canonical_arch l "$arch")"
;;
*)
fall_back "Unknown platform '$platform'"
;;
esac
if [ ! -f "$app_dir/app/omni.ja" ]; then
fall_back "No staged build for requested platform"
fi
# If the previous build didn't include tests or devtools and they're requested
# now, files are missing from the staged build
manifest_tests=$(sed -n 's/^#include_tests=//p' "$MANIFEST_FILE")
manifest_tests=${manifest_tests:-0}
manifest_devtools=$(sed -n 's/^#devtools=//p' "$MANIFEST_FILE")
manifest_devtools=${manifest_devtools:-0}
if [[ -z "${ZOTERO_TEST:-}" ]] || [[ "${ZOTERO_TEST:-}" == "0" ]]; then
want_tests=0
else
want_tests=1
fi
if [[ $want_tests -eq 1 ]] && [[ $manifest_tests != "1" ]]; then
fall_back "Tests requested but not included in staged build"
fi
if [[ $devtools -eq 1 ]] && [[ $manifest_devtools != "1" ]]; then
fall_back "Devtools requested but not included in staged build"
fi
# If anything changed in app/ (build scripts, assets, bundled modules, Firefox
# runtime), a full rebuild is required
manifest_app_hash=$(sed -n 's/^#app_hash=//p' "$MANIFEST_FILE")
app_hash=$(generate_app_hash "$APP_ROOT_DIR")
if [[ -z "$manifest_app_hash" ]] || [[ "$app_hash" != "$manifest_app_hash" ]]; then
fall_back "Files in app/ changed"
fi
file_list=$(mktemp)
new_stats=$(mktemp)
carried=$(mktemp)
candidates=$(mktemp)
removed=$(mktemp)
cand_entries=$(mktemp)
trap 'rm -f "$file_list" "$new_stats" "$carried" "$candidates" "$removed" "$cand_entries"' EXIT
cd "$BUILD_SRC_DIR"
# Quickly find files that may have changed based on size/mtime, so that only
# those have to be hashed. Unmodified manifest entries are carried over,
# new/modified files become candidates, and files no longer present are
# considered removed.
build_manifest_file_list > "$file_list"
stat_file_list < "$file_list" > "$new_stats"
awk -F'\t' -v carried="$carried" -v candidates="$candidates" -v removed="$removed" '
NR==FNR {
if (/^#/) next
hash[$3] = $1
stat[$3] = $2
next
}
{
if (!($2 in stat)) {
print > candidates
}
else {
if ($1 == stat[$2]) {
print hash[$2] "\t" $0 > carried
}
else {
print > candidates
}
delete stat[$2]
}
}
END {
for (path in stat) print path > removed
}
' "$MANIFEST_FILE" "$new_stats"
# Hash the candidates and compare to the previous hashes to find real changes
# -- A = added, C = changed, R = removed
if [ -s "$candidates" ]; then
paste \
<(cut -f2 "$candidates" | sed 's,^,./,' | tr '\n' '\0' | hash_file_list | cut -f1) \
"$candidates" \
> "$cand_entries"
fi
changes=$(
if [ -s "$cand_entries" ]; then
awk -F'\t' '
NR==FNR {
if (/^#/) next
old[$3] = $1
next
}
{
if (!($3 in old)) print "A\t" $3
else if (old[$3] != $1) print "C\t" $3
}
' "$MANIFEST_FILE" "$cand_entries"
fi
if [ -s "$removed" ]; then
awk '{print "R\t" $0}' "$removed"
fi
)
# Make sure all changed files can be zipped directly into omni.ja, and skip
# ones that aren't included in the staged build
zip_files=()
ftl_files=()
test_copy_files=()
while IFS=$'\t' read -r status path; do
if [ -z "$status" ]; then
continue
fi
if [ "$status" == "R" ]; then
case "$path" in
test/*)
# Ignore removed test files if tests weren't included
if [[ $manifest_tests != "1" ]]; then
continue
fi
;;
esac
fall_back "$path was removed"
fi
case "$path" in
# Copied into localization/ subdirectories by build.sh
chrome/locale/*/zotero/mozilla/*)
fall_back "Can't update $path in place"
;;
# Pruned by prepare_build
chrome/content/zotero/locale/csl/*)
fall_back "Can't update $path in place"
;;
# Copied to localization/<locale>/ in addition to the chrome path
chrome/locale/*/zotero/*.ftl)
zip_files+=("$path")
ftl_files+=("$path")
;;
chrome/*|components/*|resource/*)
zip_files+=("$path")
;;
# Appended to the main chrome.manifest by build.sh
test/chrome.manifest)
fall_back "Can't update $path in place"
;;
test/*)
# If tests weren't included in the staged build, ignore changes
# to test files
if [[ $manifest_tests == "1" ]]; then
zip_files+=("$path")
case "$path" in
# Also copied to tests/ at the top level of the app
test/tests/*)
test_copy_files+=("$path")
;;
esac
fi
;;
*)
fall_back "Can't update $path in place"
;;
esac
done <<< "$changes"
# Find all staged omni.ja files to update (multiple can exist -- e.g., all
# Windows architectures)
omni_files=()
while IFS= read -r file; do
omni_files+=("$file")
done < <(find "$STAGE_DIR" -path '*/app/omni.ja')
if [ ${#zip_files[@]} -gt 0 ]; then
echo "Changed files:"
for path in "${zip_files[@]}"; do
echo " $path"
done
cd "$BUILD_SRC_DIR"
for omni_file in "${omni_files[@]}"; do
echo "Updating ${omni_file#"$STAGE_DIR"/}"
zip -qX "$omni_file" "${zip_files[@]}"
done
# Copy .ftl files to their localization/ paths
if [ ${#ftl_files[@]} -gt 0 ]; then
l10n_dir=$(mktemp -d)
for path in "${ftl_files[@]}"; do
locale=${path#chrome/locale/}
locale=${locale%%/*}
mkdir -p "$l10n_dir/localization/$locale"
cp -L "$path" "$l10n_dir/localization/$locale/$(basename "$path")"
done
pushd "$l10n_dir" > /dev/null
for omni_file in "${omni_files[@]}"; do
zip -qrX "$omni_file" localization
done
popd > /dev/null
rm -rf "$l10n_dir"
fi
# Copy test files to tests/ directories
if [ ${#test_copy_files[@]} -gt 0 ]; then
for path in "${test_copy_files[@]}"; do
for omni_file in "${omni_files[@]}"; do
dest="${omni_file%/app/omni.ja}/tests/${path#test/tests/}"
mkdir -p "$(dirname "$dest")"
cp -L "$path" "$dest"
done
done
fi
# Bump BuildID so that startup caches are invalidated even without
# -purgecaches
build_id=$(date +%Y%m%d%H%M%S)
for omni_file in "${omni_files[@]}"; do
perl -pi -e "s/^BuildID=.*/BuildID=$build_id/" "$(dirname "$omni_file")/application.ini"
done
echo "Updated staged build with ${#zip_files[@]} changed file(s)"
elif [ -n "$changes" ]; then
echo "No staged files changed"
else
echo "No changes since last build"
fi
{
echo "#format=2"
echo "#include_tests=$manifest_tests"
echo "#devtools=$manifest_devtools"
echo "#app_hash=$app_hash"
LC_ALL=C sort -t$'\t' -k3 "$carried" "$cand_entries"
} > "$MANIFEST_FILE.tmp"
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"

View file

@ -71,34 +71,42 @@ def main():
tmp_src_dir = os.path.join(tmp_dir, 'zotero')
# Export a clean copy of the source tree
subprocess.check_call([
'rsync',
'-aL',
# Exclude hidden files
'--exclude', '.*',
'--exclude', '#*',
'--exclude', 'package.json',
'--exclude', 'package-lock.json',
'.' + os.sep,
tmp_src_dir + os.sep
])
# Export a clean copy of the source tree -- with Python on Windows, where rsync,
# if present at all, doesn't handle Windows-style paths
if os.name == 'nt' or not shutil.which('rsync'):
shutil.copytree(
'.',
tmp_src_dir,
ignore=shutil.ignore_patterns('.*', '#*', 'package.json', 'package-lock.json')
)
else:
subprocess.check_call([
'rsync',
'-aL',
# Exclude hidden files
'--exclude', '.*',
'--exclude', '#*',
'--exclude', 'package.json',
'--exclude', 'package-lock.json',
'.' + os.sep,
tmp_src_dir + os.sep
])
# Make sure rsync worked
# Make sure the copy worked
d = os.path.join(tmp_src_dir, 'chrome')
if not os.path.isdir(d):
raise FileNotFoundError(d + " not found")
# Delete CSL locale support files
subprocess.check_call([
'find',
os.path.normpath(tmp_src_dir + '/chrome/content/zotero/locale/csl/'),
'-mindepth', '1',
'!', '-name', '*.xml',
'!', '-name', 'locales.json',
#'-print',
'-delete'
])
csl_dir = os.path.normpath(tmp_src_dir + '/chrome/content/zotero/locale/csl/')
for root, dirs, files in os.walk(csl_dir, topdown=False):
for name in files:
if not name.endswith('.xml') and name != 'locales.json':
os.remove(os.path.join(root, name))
for name in dirs:
d = os.path.join(root, name)
if not os.listdir(d):
os.rmdir(d)
# Delete styles build script
os.remove(os.path.join(tmp_src_dir, 'styles', 'update'))

View file

@ -8,12 +8,29 @@ ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
CHANNEL="test"
BRANCH="main"
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/test/ZoteroSafariExtension.appex"
if [ "`uname`" = "Darwin" ]; then
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/test/safari"
export SAFARI_APP_EXTENSION="$ROOT_DIR/../safari-app-extension-builds/test/ZoteroSafariExtension.appex"
if [ ! -d "$SAFARI_APPEX" ]; then
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
exit 1
fi
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
exit 1
fi
if [ ! -d "$SAFARI_APP_EXTENSION" ]; then
echo "Safari App Extension not found at $SAFARI_APP_EXTENSION -- aborting" >&2
exit 1
fi
fi
cd "$SCRIPT_DIR"
./check_requirements
hash=`./get_repo_branch_hash $BRANCH`
./check_app_matches_commit $hash
source_dir=`./get_commit_files $hash`
build_dir=`mktemp -d`

View file

@ -39,8 +39,87 @@ get_canonical_arch() {
esac
}
# Print a sorted NUL-separated list of the files in the current directory to
# include in the build manifest. Follows symlinks, like the rsync calls in
# prepare_build and build.sh.
function build_manifest_file_list {
# Exclusions match the rsync call in prepare_build
find -L . -mindepth 1 \( -name '.*' -o -name '#*' \) -prune \
-o -type f ! -name 'package.json' ! -name 'package-lock.json' -print0 \
| LC_ALL=C sort -z
}
# Read a NUL-separated file list on stdin and print "size|mtime<TAB>path" for
# each file
function stat_file_list {
if [[ "$OSTYPE" == "darwin"* ]]; then
xargs -0 stat -L -f '%z|%Fm|%N'
else
xargs -0 stat -L -c '%s|%y|%n'
fi | sed -E 's,^([0-9]+[|][^|]+)[|](\./)?,\1'$'\t'','
}
# Read a NUL-separated file list on stdin and print "hash<TAB>path" for each
# file
function hash_file_list {
local md5_cmd
if command -v md5sum > /dev/null 2>&1; then
md5_cmd="md5sum"
else
md5_cmd="md5 -r"
fi
xargs -0 $md5_cmd | sed -E 's,^([0-9a-f]{32}) ?(\./)?,\1'$'\t'','
}
# Generate a manifest of a build directory for incremental updates, with one
# "hash<TAB>size|mtime<TAB>path" line per file, sorted by path
function generate_build_manifest {
local dir=$1
(
cd "$dir"
local file_list
file_list=$(mktemp)
build_manifest_file_list > "$file_list"
paste \
<(hash_file_list < "$file_list" | cut -f1) \
<(stat_file_list < "$file_list")
rm -f "$file_list"
)
}
# Generate a hash of the files in app/ that affect build output, for detecting
# when a staged build can't be updated incrementally. Since some of the files
# are large binaries, use file sizes and modification times rather than
# contents. The xulrunner runtimes are covered by the hash-* files written by
# fetch_xulrunner.
function generate_app_hash {
local app_dir=$1
local md5_cmd
if command -v md5sum > /dev/null 2>&1; then
md5_cmd="md5sum"
else
md5_cmd="md5 -r"
fi
(
cd "$app_dir"
local paths=(assets build.sh config.sh scripts mac win linux modules update-packaging)
if [ -f config-custom.sh ]; then
paths+=(config-custom.sh)
fi
{
if [[ "$OSTYPE" == "darwin"* ]]; then
find -L "${paths[@]}" -name '.*' -prune -o -type f -print0 | xargs -0 stat -L -f '%N|%z|%m'
else
find -L "${paths[@]}" -name '.*' -prune -o -type f -print0 | xargs -0 stat -L -c '%n|%s|%Y'
fi
cat xulrunner/hash-* 2> /dev/null || true
} | LC_ALL=C sort | $md5_cmd | awk '{print $1}'
)
}
function check_line {
pattern=$1
file=$2
if ! grep -E -q "$pattern" "$file"; then
echo "$pattern" not found in "$file" -- aborting 2>&1
exit 1

View file

@ -8,7 +8,7 @@
# * **Windows (w)** ‑a is **required**. Accepted values:
# win32 | x64 | arm64
# * **Linux (l)** ‑a is **required**. Accepted values:
# x86_64 | i686 | arm64 (alias x64 → x86_64)
# x86_64 | arm64 (alias x64 → x86_64)
set -euo pipefail
@ -26,7 +26,7 @@ Usage: xulrunner_hash.sh -p <m|w|l> [-a ARCH]
-p PLATFORM m (mac), w (windows), l (linux) [required]
-a ARCH *required* for Windows & Linux builds
Windows: x64 | arm64 | win32
Linux: x64 (or x86_64) | arm64 | i686
Linux: x64 (or x86_64) | arm64
Mac: disallowed – always universal
DONE
exit 1
@ -65,7 +65,7 @@ case $platform in
[[ -z $arch ]] && { echo "-a is required for Linux builds" >&2; echo; usage; }
[[ $arch == x64 ]] && arch="x86_64"
case $arch in
x86_64|i686|arm64) ;;
x86_64|arm64) ;;
*) echo "Invalid Linux arch: $arch" >&2; echo; usage;;
esac
GECKO_VERSION="$GECKO_VERSION_LINUX"
@ -91,4 +91,5 @@ fi
input_string="${platform}-${arch}-${components_hash}|${GECKO_VERSION}|${xulrunner_content}"
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $2}'
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $NF}'

View file

@ -97,6 +97,8 @@ if [[ $BUILD_MAC == 0 ]] && [[ $BUILD_WIN == 0 ]] && [[ $BUILD_LINUX == 0 ]]; th
usage
fi
TO_MAJOR_VERSION="${TO%%.*}"
rm -rf "$UPDATE_STAGE_DIR"
mkdir "$UPDATE_STAGE_DIR"
@ -157,15 +159,17 @@ for version in "$FROM" "$TO"; do
mkdir "$CACHE_DIR"
fi
# Build archive list (no Linux arm64 for Zotero 7)
# Build archive list (no Linux arm64 before Zotero 8, no Linux i686 from Zotero 11)
ARCHIVES=(
"$MAC_ARCHIVE"
"$WIN32_ARCHIVE"
"$WIN_X64_ARCHIVE"
"$WIN_ARM64_ARCHIVE"
"$LINUX_X86_ARCHIVE"
"$LINUX_X86_64_ARCHIVE"
)
if [ "$TO_MAJOR_VERSION" -lt 11 ]; then
ARCHIVES+=("$LINUX_X86_ARCHIVE")
fi
if [ "$MAJOR_VERSION" -ge 8 ]; then
ARCHIVES+=("$LINUX_ARM64_ARCHIVE")
fi
@ -235,8 +239,13 @@ for version in "$FROM" "$TO"; do
set -e
done
# Delete cached files older than 14 days
find "$CACHE_DIR" -ctime +14 -delete
# Delete cached archives older than 14 days
find "$CACHE_DIR" -maxdepth 1 -type f -ctime +14 -delete
# Delete cached diffs and compressed files not created or used in 60 days
# (cache hits touch the entries)
if [ -d "$CACHE_DIR/update-packaging" ]; then
find "$CACHE_DIR/update-packaging" -type f -ctime +60 -delete
fi
# Unpack Zotero.app
if [ $BUILD_MAC == 1 ]; then
@ -257,9 +266,14 @@ for version in "$FROM" "$TO"; do
# Unpack Windows zips
if [ $BUILD_WIN == 1 ]; then
if [[ -f "$WIN32_ARCHIVE" ]] && [[ -f "$WIN_X64_ARCHIVE" ]] && [[ -f "$WIN_ARM64_ARCHIVE" ]]; then
# Unpack architectures in parallel
UNPACK_PIDS=""
for build in "$WIN32_ARCHIVE" "$WIN_X64_ARCHIVE" "$WIN_ARM64_ARCHIVE"; do
unzip -q "$build"
rm "$build"
(unzip -q "$build" && rm "$build") &
UNPACK_PIDS="$UNPACK_PIDS $!"
done
for pid in $UNPACK_PIDS; do
wait $pid
done
INCREMENTALS_FOUND=1
else
@ -269,8 +283,11 @@ for version in "$FROM" "$TO"; do
# Unpack Linux tarballs
if [ $BUILD_LINUX == 1 ]; then
# Zotero 7 has no Linux arm64 build
LINUX_BUILDS_TO_UNPACK=("$LINUX_X86_ARCHIVE" "$LINUX_X86_64_ARCHIVE")
# Zotero 7 has no Linux arm64 build; Zotero 11 has no Linux i686 build
LINUX_BUILDS_TO_UNPACK=("$LINUX_X86_64_ARCHIVE")
if [ "$TO_MAJOR_VERSION" -lt 11 ]; then
LINUX_BUILDS_TO_UNPACK+=("$LINUX_X86_ARCHIVE")
fi
if [ "$MAJOR_VERSION" -ge 8 ]; then
LINUX_BUILDS_TO_UNPACK+=("$LINUX_ARM64_ARCHIVE")
fi
@ -284,9 +301,14 @@ for version in "$FROM" "$TO"; do
done
if [ "$MISSING" -eq 0 ]; then
# Unpack architectures in parallel
UNPACK_PIDS=""
for build in "${LINUX_BUILDS_TO_UNPACK[@]}"; do
tar -xf "$build"
rm "$build"
(tar -xf "$build" && rm "$build") &
UNPACK_PIDS="$UNPACK_PIDS $!"
done
for pid in $UNPACK_PIDS; do
wait $pid
done
INCREMENTALS_FOUND=1
else
@ -301,12 +323,21 @@ done
export MOZ_PRODUCT_VERSION="$TO"
export MAR_CHANNEL_ID="$CHANNEL"
# Reuse diffs and compressed files across incremental builds, since FROM
# versions often share files with each other and with the TO version. Entries
# that haven't been used recently are cleaned up above.
export UPDATE_CACHE_DIR="$ROOT_DIR/cache/update-packaging"
CHANGES_MADE=0
for build in "mac" "win32" "win-x64" "win-arm64" "linux-i686" "linux-x86_64" "linux-arm64"; do
# Zotero 7 has no Linux arm64 builds
if [[ $build == "linux-arm64" ]] && [[ $FROM == 7.* ]]; then
continue
fi
# Zotero 11 has no Linux i686 builds, so there's nothing to update to
if [[ $build == "linux-i686" ]] && [[ $TO_MAJOR_VERSION -ge 11 ]]; then
continue
fi
if [[ $build == "mac" ]]; then
if [[ $BUILD_MAC == 0 ]]; then
continue

View file

@ -9,7 +9,9 @@
#
# -----------------------------------------------------------------------------
QUIET=0
# Preserve a value set by the parent so it survives in the child processes
# spawned by run_parallel_tasks()
QUIET=${QUIET:-0}
# By default just assume that these tools exist on our path
MAR=${MAR:-mar}
@ -33,7 +35,11 @@ if [ $? -ne 0 ]; then
fi
fi
fi
# Ensure that we're always using the right compression settings
# Ensure that we're always using the right compression settings.
# -T1 (single-threaded) is required: multi-threaded xz produces non-deterministic
# output, which would break both the reproducibility of MARs and the content-hash
# cache in process_update_task(). See get_cache_tag() for how changes here are
# accounted for in the cache.
export XZ_OPT="-T1 -7e"
# -----------------------------------------------------------------------------
@ -156,10 +162,10 @@ append_remove_instructions() {
if [ -n "$f" ]; then
# Exclude comments
if [ ! $(echo "$f" | grep -c '^#') = 1 ]; then
if [ $(echo "$f" | grep -c '\/$') = 1 ]; then
if [ $(echo "$f" | grep -c '/$') = 1 ]; then
verbose_notice " rmdir \"$f\""
echo "rmdir \"$f\"" >> "$filev3"
elif [ $(echo "$f" | grep -c '\/\*$') = 1 ]; then
elif [ $(echo "$f" | grep -c '/\*$') = 1 ]; then
# Remove the *
f=$(echo "$f" | sed -e 's:\*$::')
verbose_notice " rmrfdir \"$f\""
@ -175,6 +181,236 @@ append_remove_instructions() {
fi
}
# Return the number of parallel jobs to use for diffing/compression, from
# UPDATE_PACKAGING_JOBS or the number of cores
get_parallel_jobs() {
if [ -n "${UPDATE_PACKAGING_JOBS:-}" ]; then
echo "$UPDATE_PACKAGING_JOBS"
return 0
fi
nproc 2>/dev/null && return 0
sysctl -n hw.ncpu 2>/dev/null && return 0
echo 1
}
# Run per-file tasks from a task file in parallel by re-invoking the calling
# script with --run-task for each line. Tasks are ordered largest-file-first so
# that long-running jobs (e.g., diffing xul) start as early as possible.
#
# $1 - task file, with one "<type><TAB><relative path>" task per line
# $2 - directory to measure file sizes against for job ordering
# $3 - script to re-invoke (callers pass "$0" and handle --run-task before
# option parsing by calling process_update_task)
#
# Callers must export the variables needed by process_update_task().
run_parallel_tasks() {
local taskfile="$1"
local basedir="$2"
local script="$3"
local jobs
if [ ! -s "$taskfile" ]; then
return 0
fi
# xargs requires a resolvable command path
script="$(cd "$(dirname "$script")" && pwd)/$(basename "$script")"
# Compute the cache tag once here and export it so the per-file child
# processes inherit it rather than recomputing it for every file
if [ -n "${UPDATE_CACHE_DIR:-}" ] && [ -z "${UPDATE_CACHE_TAG:-}" ]; then
UPDATE_CACHE_TAG="$(get_cache_tag)"
export UPDATE_CACHE_TAG
fi
jobs=$(get_parallel_jobs)
notice "Processing $(wc -l < "$taskfile" | tr -d ' ') files with $jobs parallel jobs"
(cd "$basedir" && du -ak .) > "$taskfile.sizes"
awk -F'\t' 'NR==FNR { sizes[substr($2, 3)] = $1; next } { print sizes[$2] "\t" $0 }' \
"$taskfile.sizes" "$taskfile" \
| sort -rn \
| cut -f 2- \
| tr '\n' '\000' \
| xargs -0 -n 1 -P "$jobs" "$script" --run-task
}
# -----------------------------------------------------------------------------
# Diff and compression cache
#
# Building updates repeats the same expensive work: the same file contents get
# xz-compressed once per architecture, per FROM version, and per release, and
# the same before/after file pair gets binary-diffed again and again. When
# UPDATE_CACHE_DIR is set, we cache the results by content hash and reuse them
# across builds. Two kinds of entry are stored:
#
# full-<tag>-<hash>.xz A single file compressed with xz, keyed
# on the file's contents. Reused whenever
# the same content shows up again, in any
# architecture, version, or release.
#
# patch-<tag>-<oldhash>-<newhash>.xz The compressed binary diff between a
# file's old and new contents, keyed on
# both. Reused whenever the same
# before/after pair recurs (e.g., a file
# unchanged across several FROM versions
# being patched to the same TO).
#
# Both entries are written even when only one is ultimately packaged, so a file
# that loses the patch-vs-full size comparison in one build still seeds the
# cache for the next. <tag> is get_cache_tag(), so a toolchain/settings change
# starts a fresh namespace rather than reusing stale output. Entries are touched
# on use and expire via the time-based cleanup in build_autoupdate.sh.
# -----------------------------------------------------------------------------
# Print a SHA-256 content hash to use as a cache key
hash_file() {
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
# Print a SHA-256 hash of stdin
hash_stdin() {
if command -v sha256sum > /dev/null 2>&1; then
sha256sum | awk '{print $1}'
else
shasum -a 256 | awk '{print $1}'
fi
}
# Print a short tag identifying the toolchain and options that produce cached
# files, so that entries are invalidated when any of them change. The cache
# keys files by input content only, on the assumption that the same inputs
# always compress/diff to the same output; this tag makes that assumption hold
# across xz/mbsdiff upgrades and XZ_OPT/BCJ_OPTIONS changes. (A change to any
# input invalidates both the full and patch caches, which is more than strictly
# necessary but keeps this simple; a stale build is just a cold rebuild.)
# Stale-tag entries are removed by the time-based cleanup in build_autoupdate.sh.
get_cache_tag() {
local mbsdiff_bin
mbsdiff_bin="$(command -v "$MBSDIFF" 2>/dev/null)"
{
$XZ --version 2>/dev/null | head -1
echo "XZ_OPT=$XZ_OPT"
echo "BCJ_OPTIONS=${BCJ_OPTIONS:-}"
# mbsdiff has no --version, so identify it by its binary contents
if [ -n "$mbsdiff_bin" ]; then
hash_file "$mbsdiff_bin"
else
echo "MBSDIFF=$MBSDIFF"
fi
} | hash_stdin | cut -c 1-16
}
# Atomically store file $2 in the cache as $1
cache_store() {
mkdir -p "$(dirname "$1")"
cp "$2" "$1.tmp.$$" && mv -f "$1.tmp.$$" "$1"
}
# xz-compress $1 to $2, reusing a cached result when one exists (see the cache
# overview above)
compress_full_file() {
local src="$1"
local dest="$2"
local key=""
if [ -n "${UPDATE_CACHE_DIR:-}" ]; then
key="$UPDATE_CACHE_DIR/full-${UPDATE_CACHE_TAG:-}-$(hash_file "$src").xz"
if [ -f "$key" ]; then
# Keep entries that are still in use from expiring
touch -c "$key" 2>/dev/null || true
cp "$key" "$dest"
return 0
fi
fi
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$src" > "$dest"
if [ -n "$key" ]; then
cache_store "$key" "$dest"
fi
}
# Process a single task line from run_parallel_tasks():
#
# full - compress the new file into the work directory
# diff - if the file changed, generate a binary diff and keep the smaller of
# the compressed patch ($f.patch) and the compressed file ($f)
#
# Expects newdir and workdir in the environment, plus olddir for diff tasks.
# The results are assembled into the manifest serially by the caller based on
# which files exist in the work directory. Diffs and compressed files are
# cached when enabled -- see the cache overview above.
process_update_task() {
local task="$1"
local tab=$(printf '\t')
local type="${task%%"$tab"*}"
local f="${task#*"$tab"}"
local oldfile_path newfile_path patch_path patchsize fullsize full_pid patchkey
if [ "$type" = "diff" ]; then
if diff "$olddir/$f" "$newdir/$f" > /dev/null; then
# Unchanged
return 0
fi
mkdir -p "$(dirname "$workdir/$f")"
verbose_notice "diffing \"$f\""
# Compress the full new file in the background while diffing, since both
# are needed to decide which to package
compress_full_file "$newdir/$f" "$workdir/$f" &
full_pid=$!
patchkey=""
if [ -n "${UPDATE_CACHE_DIR:-}" ]; then
patchkey="$UPDATE_CACHE_DIR/patch-${UPDATE_CACHE_TAG:-}-$(hash_file "$olddir/$f")-$(hash_file "$newdir/$f").xz"
fi
if [ -n "$patchkey" ] && [ -f "$patchkey" ]; then
# Keep entries that are still in use from expiring
touch -c "$patchkey" 2>/dev/null || true
cp "$patchkey" "$workdir/$f.patch.xz"
else
# mbsdiff doesn't like POSIX paths on Windows
if [ ${WIN_NATIVE:-0} -eq 1 ]; then
oldfile_path=$(cygpath -m "$olddir/$f")
newfile_path=$(cygpath -m "$newdir/$f")
patch_path=$(cygpath -m "$workdir/$f.patch")
else
oldfile_path="$olddir/$f"
newfile_path="$newdir/$f"
patch_path="$workdir/$f.patch"
fi
$MBSDIFF "$oldfile_path" "$newfile_path" "$patch_path"
$XZ $XZ_OPT --compress --lzma2 --format=xz --check=crc64 --force "$workdir/$f.patch"
if [ -n "$patchkey" ]; then
cache_store "$patchkey" "$workdir/$f.patch.xz"
fi
fi
wait $full_pid
copy_perm "$newdir/$f" "$workdir/$f"
patchsize=$(get_file_size "$workdir/$f.patch.xz")
fullsize=$(get_file_size "$workdir/$f")
if [ $patchsize -lt $fullsize ]; then
mv -f "$workdir/$f.patch.xz" "$workdir/$f.patch"
rm -f "$workdir/$f"
else
rm -f "$workdir/$f.patch.xz"
fi
else
mkdir -p "$(dirname "$workdir/$f")"
compress_full_file "$newdir/$f" "$workdir/$f"
copy_perm "$newdir/$f" "$workdir/$f"
fi
}
# List all files in the current directory, stripping leading "./"
# Pass a variable name and it will be filled as an array.
list_files() {

View file

@ -13,6 +13,12 @@ set -eo pipefail
. $(dirname "$0")/common.sh
# Process a single file task when invoked from run_parallel_tasks()
if [ "${1:-}" = "--run-task" ]; then
process_update_task "$2"
exit 0
fi
# -----------------------------------------------------------------------------
print_usage() {
@ -50,7 +56,7 @@ archive="$1"
targetdir="$2"
# Prevent the workdir from being inside the targetdir so it isn't included in
# the update mar.
if [ $(echo "$targetdir" | grep -c '\/$') = 1 ]; then
if [ $(echo "$targetdir" | grep -c '/$') = 1 ]; then
# Remove the /
targetdir=$(echo "$targetdir" | sed -e 's:\/$::')
fi
@ -88,6 +94,23 @@ notice ""
notice "Adding file add instructions to update manifests"
num_files=${#files[*]}
# Compress the files in parallel via run_parallel_tasks() in
# common.sh, and then assemble the manifest and archive list serially in the
# original order
tmpdir="$(mktemp -d)"
taskfile="$tmpdir/tasks"
TAB="$(printf '\t')"
> "$taskfile"
for ((i=0; $i<$num_files; i=$i+1)); do
echo "full$TAB${files[$i]}" >> "$taskfile"
done
newdir="$targetdir"
export newdir workdir BCJ_OPTIONS QUIET
run_parallel_tasks "$taskfile" "$targetdir" "$0"
rm -rf "$tmpdir"
for ((i=0; $i<$num_files; i=$i+1)); do
f="${files[$i]}"
@ -97,11 +120,6 @@ for ((i=0; $i<$num_files; i=$i+1)); do
make_add_instruction "$f" "$updatemanifestv3"
fi
dir=$(dirname "$f")
mkdir -p "$workdir/$dir"
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$targetdir/$f" > "$workdir/$f"
copy_perm "$targetdir/$f" "$workdir/$f"
targetfiles="$targetfiles \"$f\""
done

View file

@ -13,6 +13,12 @@ set -eo pipefail
. $(dirname "$0")/common.sh
# Process a single file task when invoked from run_parallel_tasks()
if [ "${1:-}" = "--run-task" ]; then
process_update_task "$2"
exit 0
fi
# -----------------------------------------------------------------------------
print_usage() {
@ -112,7 +118,7 @@ olddir="$2"
newdir="$3"
# Prevent the workdir from being inside the targetdir so it isn't included in
# the update mar.
if [ $(echo "$newdir" | grep -c '\/$') = 1 ]; then
if [ $(echo "$newdir" | grep -c '/$') = 1 ]; then
# Remove the /
newdir=$(echo "$newdir" | sed -e 's:\/$::')
fi
@ -164,93 +170,67 @@ num_oldfiles=${#oldfiles[*]}
remove_array=
num_removes=0
# The main file loop is split into three passes so that the
# CPU-heavy work can run in parallel: classify each file into a task list, run
# the diffing/compression tasks in parallel via run_parallel_tasks() in
# common.sh, and then assemble the manifest and archive list serially in the
# original order based on the files the tasks left in the work directory.
#
# (This also drops the unused MBSDIFF_HOOK/funsize path from the upstream
# script -- the diff behavior itself is unchanged and now lives in
# process_update_task() in common.sh.)
tmpdir="$(mktemp -d)"
taskfile="$tmpdir/tasks"
TAB="$(printf '\t')"
> "$taskfile"
for ((i=0; $i<$num_oldfiles; i=$i+1)); do
f="${oldfiles[$i]}"
# If this file exists in the new directory as well, then check if it differs.
if [ -f "$newdir/$f" ]; then
if check_for_add_if_not_update "$f"; then
# The full workdir may not exist yet, so create it if necessary.
mkdir -p "$(dirname "$workdir/$f")"
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
copy_perm "$newdir/$f" "$workdir/$f"
echo "full$TAB$f" >> "$taskfile"
elif check_for_forced_update "$requested_forced_updates" "$f"; then
echo "full$TAB$f" >> "$taskfile"
else
echo "diff$TAB$f" >> "$taskfile"
fi
fi
done
# Newly added files -- in the new directory but not the old one. (This replaces
# the O(n^2) nested skip loop from the upstream script.)
printf '%s\n' "${oldfiles[@]}" | sort > "$tmpdir/oldlist"
printf '%s\n' "${newfiles[@]}" | sort > "$tmpdir/newlist"
comm -13 "$tmpdir/oldlist" "$tmpdir/newlist" | sed '/^$/d' | sort -r > "$tmpdir/newonly"
while IFS= read -r f; do
echo "full$TAB$f" >> "$taskfile"
done < "$tmpdir/newonly"
export olddir newdir workdir MBSDIFF BCJ_OPTIONS QUIET
run_parallel_tasks "$taskfile" "$newdir" "$0"
# Assemble the manifest and archive list in the original order. A diffed file
# left $f.patch in the work directory if the patch was smaller, $f if the
# compressed file was smaller, and neither if the file was unchanged.
for ((i=0; $i<$num_oldfiles; i=$i+1)); do
f="${oldfiles[$i]}"
if [ -f "$newdir/$f" ]; then
if check_for_add_if_not_update "$f"; then
make_add_if_not_instruction "$f" "$updatemanifestv3"
archivefiles="$archivefiles \"$f\""
continue 1
fi
if check_for_forced_update "$requested_forced_updates" "$f"; then
# The full workdir may not exist yet, so create it if necessary.
mkdir -p "$(dirname "$workdir/$f")"
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
copy_perm "$newdir/$f" "$workdir/$f"
elif check_for_forced_update "$requested_forced_updates" "$f"; then
make_add_instruction "$f" "$updatemanifestv3" 1
archivefiles="$archivefiles \"$f\""
continue 1
fi
if ! diff "$olddir/$f" "$newdir/$f" > /dev/null; then
# Compute both the compressed binary diff and the compressed file, and
# compare the sizes. Then choose the smaller of the two to package.
dir=$(dirname "$workdir/$f")
mkdir -p "$dir"
verbose_notice "diffing \"$f\""
# MBSDIFF_HOOK represents the communication interface with funsize and,
# if enabled, caches the intermediate patches for future use and
# compute avoidance
#
# An example of MBSDIFF_HOOK env variable could look like this:
# export MBSDIFF_HOOK="myscript.sh -A https://funsize/api -c /home/user"
# where myscript.sh has the following usage:
# myscript.sh -A SERVER-URL [-c LOCAL-CACHE-DIR-PATH] [-g] [-u] \
# PATH-FROM-URL PATH-TO-URL PATH-PATCH SERVER-URL
#
# Note: patches are bzipped or xz stashed in funsize to gain more speed
# if service is not enabled then default to old behavior
# Disabled for Zotero
#if [ -z "$MBSDIFF_HOOK" ]; then
if true; then
# mbsdiff doesn't like POSIX paths on Windows
if [ $WIN_NATIVE -eq 1 ]; then
oldfile_path=$(cygpath -m "$olddir/$f")
newfile_path=$(cygpath -m "$newdir/$f")
patch_path=$(cygpath -m "$workdir/$f.patch")
else
oldfile_path="$olddir/$f"
newfile_path="$newdir/$f"
patch_path="$workdir/$f.patch"
fi
$MBSDIFF "$oldfile_path" "$newfile_path" "$patch_path"
$XZ $XZ_OPT --compress --lzma2 --format=xz --check=crc64 --force "$workdir/$f.patch"
else
# if service enabled then check patch existence for retrieval
if $MBSDIFF_HOOK -g "$olddir/$f" "$newdir/$f" "$workdir/$f.patch.xz"; then
verbose_notice "file \"$f\" found in funsize, diffing skipped"
else
# if not found already - compute it and cache it for future use
$MBSDIFF "$olddir/$f" "$newdir/$f" "$workdir/$f.patch"
$XZ $XZ_OPT --compress --lzma2 --format=xz --check=crc64 --force "$workdir/$f.patch"
$MBSDIFF_HOOK -u "$olddir/$f" "$newdir/$f" "$workdir/$f.patch.xz"
fi
fi
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
copy_perm "$newdir/$f" "$workdir/$f"
patchfile="$workdir/$f.patch.xz"
patchsize=$(get_file_size "$patchfile")
fullsize=$(get_file_size "$workdir/$f")
if [ $patchsize -lt $fullsize ]; then
make_patch_instruction "$f" "$updatemanifestv3"
mv -f "$patchfile" "$workdir/$f.patch"
rm -f "$workdir/$f"
archivefiles="$archivefiles \"$f.patch\""
else
make_add_instruction "$f" "$updatemanifestv3"
rm -f "$patchfile"
archivefiles="$archivefiles \"$f\""
fi
elif [ -f "$workdir/$f.patch" ]; then
make_patch_instruction "$f" "$updatemanifestv3"
archivefiles="$archivefiles \"$f.patch\""
elif [ -f "$workdir/$f" ]; then
make_add_instruction "$f" "$updatemanifestv3"
archivefiles="$archivefiles \"$f\""
fi
else
# remove instructions are added after add / patch instructions for
@ -265,33 +245,18 @@ done
# Newly added files
notice ""
notice "Adding file add instructions to update manifests"
num_newfiles=${#newfiles[*]}
for ((i=0; $i<$num_newfiles; i=$i+1)); do
f="${newfiles[$i]}"
# If we've already tested this file, then skip it
for ((j=0; $j<$num_oldfiles; j=$j+1)); do
if [ "$f" = "${oldfiles[j]}" ]; then
continue 2
fi
done
dir=$(dirname "$workdir/$f")
mkdir -p "$dir"
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
copy_perm "$newdir/$f" "$workdir/$f"
while IFS= read -r f; do
if check_for_add_if_not_update "$f"; then
make_add_if_not_instruction "$f" "$updatemanifestv3"
else
make_add_instruction "$f" "$updatemanifestv3"
fi
archivefiles="$archivefiles \"$f\""
done
done < "$tmpdir/newonly"
rm -rf "$tmpdir"
notice ""
notice "Adding file remove instructions to update manifests"

View file

@ -1,24 +0,0 @@
application.ini
chrome.manifest
chrome/*
components/*
defaults/*
deleted.txt
dictionaries/*
extensions/*
fonts/EmojiOneMozilla.ttf
gtk2/*
icudt58l.dat
install.rdf
libnssdbm3.chk
libnssdbm3.so
libsoftokn3.chk
pingsender
resource/*
run-zotero.sh
styles.zip
test/*
translators.index
translators.zip
xulrunner/*
zotero.jar

View file

@ -38,12 +38,6 @@ Contents/MacOS/updater.app/Contents/MacOS/updater-bin
Contents/MacOS/updates.xml
Contents/MacOS/updates/*
Contents/MacOS/zotero-bin
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/inject/translate_inject.js
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/node_modules.js
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/test/*
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/translate/cachedTypes.js
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/translate/resource/README.md
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/translate/resource/zoteroTypeSchemaData.js
Contents/Resources/CONTRIBUTING.md
Contents/Resources/COPYING
Contents/Resources/README.md

View file

@ -30,8 +30,8 @@ else
echo "Unknown architecture $arch" >&2
exit 1
fi
rustup target add $rust_target-pc-windows-msvc
rustup default $RUST_VERSION
rustup target add $rust_target-pc-windows-msvc
cp "$SCRIPT_DIR/mozconfig" "$gecko_path"

View file

@ -29,6 +29,11 @@ ac_add_options --disable-update-agent
export MOZILLA_OFFICIAL=1
export RELEASE_OR_BETA=1
# We don't need source-repo info embedded, and generating it would require a
# source-repo.h that Firefox only auto-populates for Mercurial, not our git
# checkout, which breaks packaging. Turn it off.
ac_add_options MOZ_INCLUDE_SOURCE_INFO=
MOZ_REQUIRE_SIGNING=
# Build updater without MAR signature verification

View file

@ -1,14 +1,9 @@
diff --git a/browser/app/nsBrowserApp.cpp b/browser/app/nsBrowserApp.cpp
index a85fdbfebe0f..819475717ffb 100644
index 4c5982c056fd..1ad4c9dfd597 100644
--- a/browser/app/nsBrowserApp.cpp
+++ b/browser/app/nsBrowserApp.cpp
@@ -156,19 +156,30 @@ static bool IsArg(const char* arg, const char* s) {
#endif
return false;
}
MOZ_RUNINIT Bootstrap::UniquePtr gBootstrap;
@@ -198,9 +198,20 @@ static bool HasFlag(int argc, char* argv[], const char* s) {
constinit Bootstrap::UniquePtr gBootstrap;
static int do_main(int argc, char* argv[], char* envp[]) {
+ // Allow profile downgrade for Zotero
@ -26,24 +21,14 @@ index a85fdbfebe0f..819475717ffb 100644
+
+ }
+ char *appDataFile = iniPath.get();
if ((!appDataFile || !*appDataFile) && (argc > 1 && IsArg(argv[1], "app"))) {
if ((!appDataFile || !*appDataFile) && (argc > 1 && IsFlag(argv[1], "app"))) {
if (argc == 2) {
Output("Incorrect number of arguments passed to -app");
return 255;
}
appDataFile = argv[2];
char appEnv[MAXPATHLEN];
diff --git a/browser/app/winlauncher/LauncherProcessWin.cpp b/browser/app/winlauncher/LauncherProcessWin.cpp
index 8167d2b81c91..a1ef657232bf 100644
index 8337336dc894..c55677631c88 100644
--- a/browser/app/winlauncher/LauncherProcessWin.cpp
+++ b/browser/app/winlauncher/LauncherProcessWin.cpp
@@ -156,17 +156,31 @@ static mozilla::LauncherFlags ProcessCmdLine(int& aArgc, wchar_t* aArgv[]) {
mozilla::CheckArgFlag::None) == mozilla::ARG_FOUND ||
mozilla::CheckArg(aArgc, aArgv, "remote-debugging-port", nullptr,
mozilla::CheckArgFlag::None) == mozilla::ARG_FOUND ||
mozilla::EnvHasValue("MOZ_AUTOMATION") ||
mozilla::EnvHasValue("MOZ_HEADLESS")) {
@@ -250,7 +250,21 @@ static mozilla::LauncherFlags ProcessCmdLine(int& aArgc, wchar_t* aArgv[]) {
result |= mozilla::LauncherFlags::eWaitForBrowser;
}
@ -66,21 +51,86 @@ index 8167d2b81c91..a1ef657232bf 100644
result |= mozilla::LauncherFlags::eNoDeelevate;
}
if (mozilla::CheckArg(aArgc, aArgv, ATTEMPTING_DEELEVATION_FLAG) ==
mozilla::ARG_FOUND) {
result |= mozilla::LauncherFlags::eDeelevating;
}
diff --git a/toolkit/components/remote/WinRemoteMessage.cpp b/toolkit/components/remote/WinRemoteMessage.cpp
index 16a59fa7beda..4aa85c4a7632 100644
--- a/toolkit/components/remote/WinRemoteMessage.cpp
+++ b/toolkit/components/remote/WinRemoteMessage.cpp
@@ -32,6 +32,31 @@ WinRemoteMessageSender::WinRemoteMessageSender(int32_t aArgc,
COPYDATASTRUCT* WinRemoteMessageSender::CopyData() { return &mData; }
+nsresult WinRemoteMessageReceiver::ParseV0(const nsACString& aBuffer) {
+ CommandLineParserWin<char> parser;
+ parser.HandleCommandLine(aBuffer);
+
+ mCommandLine = new nsCommandLine();
+ return mCommandLine->Init(parser.Argc(), parser.Argv(), nullptr,
+ nsICommandLine::STATE_REMOTE_AUTO);
+}
+
+nsresult WinRemoteMessageReceiver::ParseV1(const nsACString& aBuffer) {
+ CommandLineParserWin<char> parser;
+ size_t cch = parser.HandleCommandLine(aBuffer);
+ ++cch; // skip a null char
+
+ nsCOMPtr<nsIFile> workingDir;
+ if (cch < aBuffer.Length()) {
+ (void)NS_NewLocalFile(NS_ConvertUTF8toUTF16(Substring(aBuffer, cch)),
+ getter_AddRefs(workingDir));
+ }
+
+ mCommandLine = new nsCommandLine();
+ return mCommandLine->Init(parser.Argc(), parser.Argv(), workingDir,
+ nsICommandLine::STATE_REMOTE_AUTO);
+}
+
nsresult WinRemoteMessageReceiver::ParseV2(const nsAString& aBuffer) {
CommandLineParserWin<char16_t> parser;
size_t cch = parser.HandleCommandLine(aBuffer);
@@ -110,6 +135,12 @@ nsresult WinRemoteMessageReceiver::ParseV3(const nsACString& aBuffer) {
nsresult WinRemoteMessageReceiver::Parse(const COPYDATASTRUCT* aMessageData) {
switch (static_cast<WinRemoteMessageVersion>(aMessageData->dwData)) {
+ case WinRemoteMessageVersion::CommandLineOnly:
+ return ParseV0(nsDependentCSubstring(
+ reinterpret_cast<char*>(aMessageData->lpData), aMessageData->cbData));
+ case WinRemoteMessageVersion::CommandLineAndWorkingDir:
+ return ParseV1(nsDependentCSubstring(
+ reinterpret_cast<char*>(aMessageData->lpData), aMessageData->cbData));
case WinRemoteMessageVersion::CommandLineAndWorkingDirInUtf16:
return ParseV2(
nsDependentSubstring(reinterpret_cast<wchar_t*>(aMessageData->lpData),
diff --git a/toolkit/components/remote/WinRemoteMessage.h b/toolkit/components/remote/WinRemoteMessage.h
index b62fb73fa13d..ee6612f55f4b 100644
--- a/toolkit/components/remote/WinRemoteMessage.h
+++ b/toolkit/components/remote/WinRemoteMessage.h
@@ -28,10 +28,10 @@
// some sort, as v3 does, to reduce the chances of variants of bug 1847458.
enum class WinRemoteMessageVersion : uint32_t {
// "<CommandLine>\0" in utf8
- /* CommandLineOnly = 0, */
+ CommandLineOnly = 0,
// "<CommandLine>\0<WorkingDir>\0" in utf8
- /* CommandLineAndWorkingDir = 1, */
+ CommandLineAndWorkingDir = 1,
// L"<CommandLine>\0<WorkingDir>\0" in utf16, used by ESR 128
CommandLineAndWorkingDirInUtf16 = 2,
@@ -61,6 +61,8 @@ class WinRemoteMessageSender final {
class WinRemoteMessageReceiver final {
nsCOMPtr<nsICommandLineRunner> mCommandLine;
+ nsresult ParseV0(const nsACString& aBuffer);
+ nsresult ParseV1(const nsACString& aBuffer);
nsresult ParseV2(const nsAString& aBuffer);
nsresult ParseV3(const nsACString& aBuffer);
diff --git a/toolkit/mozapps/update/common/commonupdatedir.cpp b/toolkit/mozapps/update/common/commonupdatedir.cpp
index 0ba9fcef9417..ed286476fec3 100644
--- a/toolkit/mozapps/update/common/commonupdatedir.cpp
+++ b/toolkit/mozapps/update/common/commonupdatedir.cpp
@@ -38,17 +38,17 @@
// (i.e. C:\ProgramData\<ROOT_UPDATE_DIR_NAME>)
// It is really important that we properly set the permissions on this
// directory at creation time. Thus, it is really important that this code be
// the creator of this directory. We had many problems with the old update
// directory having been previously created by old versions of Firefox. To avoid
@@ -43,7 +43,7 @@
// this problem in the future, we are including a UUID in the root update
// directory name to attempt to ensure that it will be created by this code and
// won't already exist with the wrong permissions.
@ -89,21 +139,11 @@ index 0ba9fcef9417..ed286476fec3 100644
// This describes the directory between the "Mozilla" directory and the install
// path hash (i.e. C:\ProgramData\Mozilla\<UPDATE_PATH_MID_DIR_NAME>\<hash>)
# define UPDATE_PATH_MID_DIR_NAME "updates"
enum class WhichUpdateDir {
CurrentUpdateDir,
UnmigratedUpdateDir,
};
diff --git a/xpcom/build/BinaryPath.h b/xpcom/build/BinaryPath.h
index 1718caa3c66d..10fa59c0cd80 100644
index 2c8d78f02440..f55d3db32469 100644
--- a/xpcom/build/BinaryPath.h
+++ b/xpcom/build/BinaryPath.h
@@ -283,16 +283,43 @@ class BinaryPath {
if (NS_FAILED(Get(path))) {
return nullptr;
}
UniqueFreePtr<char> result;
result.reset(strdup(path));
@@ -286,6 +286,33 @@ class BinaryPath {
return result;
}
@ -137,81 +177,3 @@ index 1718caa3c66d..10fa59c0cd80 100644
#ifdef MOZILLA_INTERNAL_API
static nsresult GetFile(nsIFile** aResult) {
nsCOMPtr<nsIFile> lf;
# ifdef XP_WIN
wchar_t exePath[MAXPATHLEN];
nsresult rv = GetW(exePath);
# else
char exePath[MAXPATHLEN];
diff --git a/toolkit/components/remote/WinRemoteMessage.cpp b/toolkit/components/remote/WinRemoteMessage.cpp
--- a/toolkit/components/remote/WinRemoteMessage.cpp
+++ b/toolkit/components/remote/WinRemoteMessage.cpp
@@ -33,6 +33,31 @@
COPYDATASTRUCT* WinRemoteMessageSender::CopyData() { return &mData; }
+nsresult WinRemoteMessageReceiver::ParseV0(const nsACString& aBuffer) {
+ CommandLineParserWin<char> parser;
+ parser.HandleCommandLine(aBuffer);
+
+ mCommandLine = new nsCommandLine();
+ return mCommandLine->Init(parser.Argc(), parser.Argv(), nullptr,
+ nsICommandLine::STATE_REMOTE_AUTO);
+}
+
+nsresult WinRemoteMessageReceiver::ParseV1(const nsACString& aBuffer) {
+ CommandLineParserWin<char> parser;
+ size_t cch = parser.HandleCommandLine(aBuffer);
+ ++cch; // skip a null char
+
+ nsCOMPtr<nsIFile> workingDir;
+ if (cch < aBuffer.Length()) {
+ (void)NS_NewLocalFile(NS_ConvertUTF8toUTF16(Substring(aBuffer, cch)),
+ getter_AddRefs(workingDir));
+ }
+
+ mCommandLine = new nsCommandLine();
+ return mCommandLine->Init(parser.Argc(), parser.Argv(), workingDir,
+ nsICommandLine::STATE_REMOTE_AUTO);
+}
+
nsresult WinRemoteMessageReceiver::ParseV2(const nsAString& aBuffer) {
CommandLineParserWin<char16_t> parser;
size_t cch = parser.HandleCommandLine(aBuffer);
@@ -111,6 +136,12 @@
nsresult WinRemoteMessageReceiver::Parse(const COPYDATASTRUCT* aMessageData) {
switch (static_cast<WinRemoteMessageVersion>(aMessageData->dwData)) {
+ case WinRemoteMessageVersion::CommandLineOnly:
+ return ParseV0(nsDependentCSubstring(
+ reinterpret_cast<char*>(aMessageData->lpData), aMessageData->cbData));
+ case WinRemoteMessageVersion::CommandLineAndWorkingDir:
+ return ParseV1(nsDependentCSubstring(
+ reinterpret_cast<char*>(aMessageData->lpData), aMessageData->cbData));
case WinRemoteMessageVersion::CommandLineAndWorkingDirInUtf16:
return ParseV2(
nsDependentSubstring(reinterpret_cast<wchar_t*>(aMessageData->lpData),
diff --git a/toolkit/components/remote/WinRemoteMessage.h b/toolkit/components/remote/WinRemoteMessage.h
--- a/toolkit/components/remote/WinRemoteMessage.h
+++ b/toolkit/components/remote/WinRemoteMessage.h
@@ -29,10 +29,10 @@
// some sort, as v3 does, to reduce the chances of variants of bug 1847458.
enum class WinRemoteMessageVersion : uint32_t {
// "<CommandLine>\0" in utf8
- /* CommandLineOnly = 0, */
+ CommandLineOnly = 0,
// "<CommandLine>\0<WorkingDir>\0" in utf8
- /* CommandLineAndWorkingDir = 1, */
+ CommandLineAndWorkingDir = 1,
// L"<CommandLine>\0<WorkingDir>\0" in utf16, used by ESR 128
CommandLineAndWorkingDirInUtf16 = 2,
@@ -62,6 +62,8 @@
class WinRemoteMessageReceiver final {
nsCOMPtr<nsICommandLineRunner> mCommandLine;
+ nsresult ParseV0(const nsACString& aBuffer);
+ nsresult ParseV1(const nsACString& aBuffer);
nsresult ParseV2(const nsAString& aBuffer);
nsresult ParseV3(const nsACString& aBuffer);

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e66cbd095e4b714ef5f2494240190f67da78af77053368b65f380c7c7ef95f41
size 393284
oid sha256:13a76afa9dac4bfbb5f1a9b2c69ecc82f300a6f52a673c562da8f30a11b62438
size 876372

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:070a38196290224fcb0db22bbf0aa739d60188cf3df6137ced3b26819b7e98b6
size 524100
oid sha256:5ab890f9893d17d1b492f6762adc9d60d29e405eb089ca1311e522cb987cbca9
size 559376

View file

@ -293,11 +293,16 @@
</keyset>
<!-- nsMenuBarX hides these and uses them to build the Application menu. -->
<!-- macOS 13 changed "Preferences" to "Settings" so one of these gets picked -->
<menupopup id="menu_FilePopup">
<menuitem id="menu_preferences"
label="&preferencesCmdMac.label;"
key="key_preferencesCmdMac"
oncommand="Zotero.Utilities.Internal.openPreferences()"/>
<menuitem id="menu_settings"
data-l10n-id="menu-application-settings"
key="key_preferencesCmdMac"
oncommand="Zotero.Utilities.Internal.openPreferences()"/>
<menuitem id="menu_mac_services"
label="&servicesMenuMac.label;"/>
<menuitem id="menu_mac_hide_app"

View file

@ -37,6 +37,10 @@ ChromeUtils.defineESModuleGetters(globalThis, {
const progressListeners = new Set();
// From nsSandboxFlags.h
const SANDBOXED_ORIGIN = 0x10;
const SANDBOXED_SCRIPTS = 0x80;
/**
* Functions for creating and destroying hidden browser objects
**/
@ -54,6 +58,7 @@ export class HiddenBrowser {
*/
constructor(options = {}) {
this._destroyed = false;
this._allowJavaScript = options.allowJavaScript !== false;
this._createdPromise = (async () => {
let doc;
if (options.useHiddenFrame !== false) {
@ -61,7 +66,6 @@ export class HiddenBrowser {
this._frame = frame;
var windowlessBrowser = await frame.get();
windowlessBrowser.browsingContext.allowJavascript = options.allowJavaScript !== false;
windowlessBrowser.docShell.allowImages = false;
if (options.docShell) {
Object.assign(windowlessBrowser.docShell, options.docShell);
@ -100,7 +104,14 @@ export class HiddenBrowser {
if (options.customUserAgent) {
browser.browsingContext.customUserAgent = options.customUserAgent;
}
if (!this._allowJavaScript) {
// A system-principal document (e.g., a blob: URL created by chrome code) can run
// scripts even when scripting is otherwise disabled, so sandbox it with a null
// principal and no scripts.
browser.browsingContext.sandboxFlags |= SANDBOXED_ORIGIN | SANDBOXED_SCRIPTS;
}
this._browser = browser;
})();
@ -144,18 +155,12 @@ export class HiddenBrowser {
Zotero.debug(`Loading ${uri} in hidden browser`);
// Next bit adapted from Mozilla's HeadlessShell.jsm
try {
// Figure out whether the browser should be remote. We actually
// perform the load in PageDataChild, but remoteness changes
// need to happen here.
let oa = E10SUtils.predictOriginAttributes({ browser: this });
let remoteType = E10SUtils.getRemoteTypeForURI(
uri,
true,
false,
E10SUtils.DEFAULT_REMOTE_TYPE,
null,
oa
);
// Figure out whether the browser should be remote
let remoteType = ChromeUtils.predictRemoteTypeForURI(uri, {
preferredRemoteType: E10SUtils.DEFAULT_REMOTE_TYPE,
useRemoteTabs: true,
useRemoteSubframes: false
});
if (this.remoteType !== remoteType) {
// The following functions need to be called on the <browser> directly,
// not through our proxy (aka 'this')
@ -210,12 +215,18 @@ export class HiddenBrowser {
);
});
let loadURISuccess = await this.browsingContext.currentWindowGlobal.getActor("PageData")
.sendQuery("loadURI", { uri });
if (!loadURISuccess) {
let prepared = await this.browsingContext.currentWindowGlobal.getActor("PageData")
.sendQuery("prepareLoad");
if (!prepared) {
Zotero.logError(new Error("Load failed"));
return false;
}
// Start the load from the parent process. A load started by the content process
// is restricted to URLs that process could load itself, which excludes blob: URLs
// created by chrome code.
this._browser.loadURI(Services.io.newURI(uri), {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal()
});
await loadCompletePromise;
}
catch (e) {
@ -255,12 +266,22 @@ export class HiddenBrowser {
/**
* @param {String[]} props - 'characterSet', 'title', 'bodyText', 'documentHTML', 'cookie', 'channelInfo'
* @param {Object} [options]
* @param {Number} [options.timeout=30000] - Time to wait for each property in milliseconds.
* The queries wait for the document to be ready, so a page that never finishes loading
* would otherwise hang the query forever.
*/
async getPageData(props) {
async getPageData(props, { timeout = 30000 } = {}) {
var actor = this.browsingContext.currentWindowGlobal.getActor("PageData");
var data = {};
for (let prop of props) {
data[prop] = await actor.sendQuery(prop);
let timeoutPromise = new Promise((_, reject) => {
setTimeout(
() => reject(new Error(`Timed out getting '${prop}' from hidden browser`)),
timeout
);
});
data[prop] = await Promise.race([actor.sendQuery(prop), timeoutPromise]);
}
return data;
}

View file

@ -2,10 +2,10 @@ import { documentIsReady } from "chrome://zotero/content/actors/actorUtils.mjs";
export class PageDataChild extends JSWindowActorChild {
async receiveMessage(message) {
// Special case for loadURI: don't wait for document to be ready,
// Special case for prepareLoad: don't wait for document to be ready,
// since we haven't loaded anything yet
if (message.name === "loadURI") {
return this.loadURI(message.data.uri);
if (message.name === "prepareLoad") {
return this.prepareLoad();
}
let document = this.document;
@ -50,31 +50,13 @@ export class PageDataChild extends JSWindowActorChild {
}
}
loadURI(uri) {
prepareLoad() {
// https://searchfox.org/mozilla-central/rev/e69f323af80c357d287fb6314745e75c62eab92a/toolkit/actors/BackgroundThumbnailsChild.sys.mjs#44-85
let docShell = this.docShell.QueryInterface(Ci.nsIWebNavigation);
// Don't allow downloads/external apps
docShell.allowContentRetargeting = false;
this.docShell.allowContentRetargeting = false;
// Get the document to force a content viewer to be created, otherwise
// the first load can fail.
if (!this.document) {
return false;
}
let loadURIOptions = {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
};
try {
docShell.loadURI(
Services.io.newURI(uri),
loadURIOptions
);
return true;
}
catch (e) {
return false;
}
return !!this.document;
}
}

View file

@ -209,6 +209,9 @@ export class TranslationChild extends JSWindowActorChild {
sameZoneAs: this.contentWindow,
wantXrays: true,
wantGlobalProperties: ["XMLHttpRequest", "fetch", "WebSocket"],
// The translation framework adds Bluebird methods to Promise, which system-principal
// sandboxes freeze by default
freezeBuiltins: false,
});
let scriptURIs = [

View file

@ -1,194 +0,0 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
import CollectionViewItemTree from 'zotero/collectionViewItemTree';
import { COLUMNS } from 'zotero/itemTreeColumns';
var ZoteroAdvancedSearch = new function () {
this.onLoad = onLoad;
this.search = search;
this.clear = clear;
this.onItemActivate = onItemActivate;
this.itemsView = false;
this._loadedDeferred = Zotero.Promise.defer();
var _searchBox;
var _libraryID;
var _searchCounter = 0;
async function onLoad() {
_searchBox = document.getElementById('zotero-search-box');
// Set font size from pref
var sbc = document.getElementById('zotero-search-box-container');
Zotero.UIProperties.registerRoot(sbc);
_searchBox.onLibraryChange = this.onLibraryChange;
var io = window.arguments[0];
io.dataIn.search.loadPrimaryData()
.then(function () {
_searchBox.search = io.dataIn.search;
});
var elem = document.getElementById('zotero-items-tree');
const columns = COLUMNS.map((column) => {
column = Object.assign({}, column);
column.hidden = !['title', 'firstCreator', 'year', 'hasAttachment'].includes(column.dataKey);
return column;
});
this.itemsView = await CollectionViewItemTree.init(elem, {
id: "advanced-search",
dragAndDrop: true,
columnPicker: true,
onActivate: this.onItemActivate.bind(this),
columns,
});
await this.itemsView.changeCollectionTreeRow({
id: 'advanced-search-' + _searchCounter++,
ref: _searchBox.search,
visibilityGroup: 'default',
isSearchMode: () => true,
getItems: async () => [],
isLibrary: () => false,
isCollection: () => false,
isSearch: () => true,
isPublications: () => false,
isDuplicates: () => false,
isFeed: () => false,
isFeeds: () => false,
isFeedsOrFeed: () => false,
isRecentlyRead: () => false,
isSortable: () => true,
isShare: () => false,
isTrash: () => false,
isSearch: () => true
});
// Focus the first field in the window
Services.focus.moveFocus(window, null, Services.focus.MOVEFOCUS_FORWARD, 0);
this._loadedDeferred.resolve();
}
this.onUnload = function () {
this.itemsView.unregister();
}
function search() {
_searchBox.updateSearch();
_searchBox.active = true;
return this.itemsView.changeCollectionTreeRow({
id: 'advanced-search-' + _searchCounter++,
ref: _searchBox.search,
visibilityGroup: 'default',
isSearchMode: () => true,
isSearch: () => true,
getItems: async function () {
await Zotero.Libraries.get(_libraryID).waitForDataLoad('item');
var search = _searchBox.search.clone();
search.libraryID = _libraryID;
var ids = await search.search();
return Zotero.Items.get(ids);
}
});
}
function clear() {
this.itemsView.changeCollectionTreeRow(null);
var s = new Zotero.Search();
// Don't clear the selected library
s.libraryID = _searchBox.search.libraryID;
s.addCondition('title', 'contains', '');
_searchBox.search = s;
_searchBox.active = false;
}
this.save = async function () {
_searchBox.updateSearch();
var promptService = Services.prompt;
var libraryID = _searchBox.search.libraryID;
var searches = await Zotero.Searches.getAll(libraryID);
var prefix = Zotero.getString('pane.collections.untitled');
var name = Zotero.Utilities.Internal.getNextName(
prefix,
searches.map(s => s.name).filter(n => n.startsWith(prefix))
);
name = { value: name };
var result = promptService.prompt(window,
Zotero.getString('pane.collections.newSavedSeach'),
Zotero.getString('pane.collections.savedSearchName'), name, "", {});
if (!result) {
return;
}
if (!name.value) {
name.value = 'untitled';
}
var s = _searchBox.search.clone();
s.name = name.value;
await s.saveTx();
window.close();
};
this.onLibraryChange = function (libraryID) {
_libraryID = libraryID;
var library = Zotero.Libraries.get(libraryID);
var isEditable = library.editable && library.libraryType != 'publications';
document.getElementById('zotero-search-save').disabled = !isEditable;
}
function onItemActivate(event, items)
{
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
var lastWin = wm.getMostRecentWindow("navigator:browser");
if (!lastWin) {
return;
}
lastWin.ZoteroPane.selectItems(items.map(item => item.id));
lastWin.focus();
}
}

View file

@ -1,58 +0,0 @@
<?xml version="1.0"?>
<?xml-stylesheet href="chrome://global/skin/"?>
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/overlay.css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css"?>
<!DOCTYPE window [
<!ENTITY % zoteroDTD SYSTEM "chrome://zotero/locale/zotero.dtd">
%zoteroDTD;
<!ENTITY % searchboxDTD SYSTEM "chrome://zotero/locale/searchbox.dtd">
%searchboxDTD;
]>
<window
id="zotero-advanced-search-dialog"
title="&zotero.toolbar.advancedSearch;"
orient="vertical"
persist="screenX screenY width height"
onload="ZoteroAdvancedSearch.onLoad()"
onunload="ZoteroAdvancedSearch.onUnload();"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
windowtype="zotero:search"
style="display: flex;">
<script src="include.js"/>
<script>
</script>
<script src="customElements.js"/>
<script src="advancedSearch.js"/>
<popupset>
<panel is="autocomplete-richlistbox-popup"
id="search-autocomplete-popup"
type="autocomplete-richlistbox"
noautofocus="true"/>
</popupset>
<vbox id="zotero-search-box-container" flex="1">
<vbox id="zotero-search-box-controls">
<zoterosearch id="zotero-search-box" oncommand="if (this.active) { ZoteroAdvancedSearch.search(); }" flex="1"/>
<hbox id="zotero-search-buttons">
<button label="&zotero.search.search;" default="true" oncommand="ZoteroAdvancedSearch.search()"/>
<button label="&zotero.search.clear;" oncommand="ZoteroAdvancedSearch.clear()"/>
<button id="zotero-search-save" label="&zotero.search.saveSearch;" oncommand="ZoteroAdvancedSearch.save()"/>
</hbox>
</vbox>
<hbox class="virtualized-table-container" flex="1">
<html:div id="zotero-items-tree"/>
</hbox>
</vbox>
<keyset>
<key id="key_close" key="W" modifiers="accel" oncommand="window.close()"/>
</keyset>
</window>

View file

@ -50,6 +50,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
dragAndDrop: false,
filterLibraryIDs: false,
hideSources: [],
multiSelect: false,
onContextMenu: noop,
};
@ -59,13 +60,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
dragAndDrop: PropTypes.bool,
filterLibraryIDs: PropTypes.array,
hideSources: PropTypes.array,
multiSelect: PropTypes.bool,
onContextMenu: PropTypes.func,
};
constructor(props) {
super(props);
this.itemTreeView = null;
this.itemToSelect = null;
this.type = 'collection';
this.name = "CollectionTree";
@ -140,6 +141,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
componentDidMount() {
super.componentDidMount();
this.selection.select(0);
this.makeVisible();
if (this.props.dragAndDrop) {
@ -163,10 +166,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false; // In-line editing active
}
var libraryID = this.getSelectedLibraryID();
if (!libraryID) return true;
let treeRow = this.getRow(this.selection.focused);
var libraryID = treeRow?.ref?.libraryID;
if (!libraryID) return true;
if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
this.expandLibrary(libraryID, true);
}
@ -182,6 +185,16 @@ var CollectionTree = class CollectionTree extends LibraryTree {
else if (event.key == "F2" && !Zotero.isMac && treeRow.isCollection()) {
this.handleActivate(event, [this.selection.focused]);
}
else if (event.key == 'a' && !event.shiftKey
&& (Zotero.isMac ? (event.metaKey && !event.ctrlKey) : event.ctrlKey)) {
if (this.props.multiSelect) {
this._handleSelectAll();
}
// Own Cmd/Ctrl-A here so that neither the table's unscoped Select All nor
// the cmd_selectAll key command runs
event.preventDefault();
return false;
}
else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
// Specific logic for keypress navigation during collection filtering
// that skips context-rows
@ -242,6 +255,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
handleActivate = (event, indices) => {
// Activation (rename collection, edit saved search/feed, open library) acts on
// a single row, so ignore it when multiple rows are selected
if (this.selection.count > 1) {
return;
}
let index = indices[0];
let treeRow = this.getRow(index);
if (treeRow.isCollection() && this.editable && this.selection.focused == index) {
@ -275,7 +293,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (!treeRow.editingName) return;
treeRow.ref.name = treeRow.editingName;
delete treeRow.editingName;
await treeRow.ref.saveTx();
await treeRow.ref.saveTx({ undoAction: 'undo-action-rename-collection' });
window.Zotero_Tabs.rename("zotero-pane", treeRow.ref.name);
}
@ -306,6 +324,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
// Classes
div.className = "row";
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('first-selected', selection.isFirstRowOfSelectionBlock(index));
div.classList.toggle('last-selected', selection.isLastRowOfSelectionBlock(index));
// Focus ring on the focused-but-unselected row (e.g., macOS Cmd-arrow navigation)
div.classList.toggle('focused', selection.focused == index);
div.classList.toggle('highlighted', this._highlightedRows.has(treeRow.id));
div.classList.toggle('drop', this._dropRow == index);
div.classList.toggle('flashing', this._flashingRow == index);
@ -476,6 +498,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
onToggleOpenState: this.toggleOpenState,
getRowString: this.getRowString.bind(this),
multiSelect: this.props.multiSelect,
// The collection tree must always have a selection
requireSelection: true,
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
onKeyDown: this.handleKeyDown,
@ -726,10 +752,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return 0;
}
var currentLibraryID = this.getSelectedLibraryID();
var libraryID = items[0].libraryID;
// If in a different library
if (libraryID != currentLibraryID) {
// If not in one of the selected libraries
if (!this.getSelectedLibraryIDs().includes(libraryID)) {
Zotero.debug("Library ID differs; switching library");
await this.selectLibrary(libraryID);
}
@ -1241,9 +1266,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (treeRow.isLibrary(true) || treeRow.isCollection() || treeRow.isFeeds()) {
count = await this._expandRow(this._rows, index, true);
}
if (this.selection.focused > index) {
this.selection.select(this.selection.focused + count);
}
this.selection.adjustForRowInsertion(index, count);
this.selection.selectEventsSuppressed = false;
this._rows[index].isOpen = true;
@ -1305,22 +1328,46 @@ var CollectionTree = class CollectionTree extends LibraryTree {
* @returns {Promise<void>}
*/
async deleteSelection(deleteItems) {
var treeRow = this.getRow(this.selection.focused);
if (treeRow.isFeed()) {
await treeRow.ref.eraseTx();
return;
let treeRows = this.getSelectedRows();
// Feeds must be erased outside a transaction since Feed.erase()
// starts its own transaction internally
let feeds = treeRows.filter(r => r.isFeed());
let others = treeRows.filter(r => !r.isFeed());
for (let row of feeds) {
await row.ref.eraseTx();
}
treeRow.ref.deleted = true;
if (treeRow.isCollection()) {
await treeRow.ref.saveTx({ deleteItems });
return;
if (others.length) {
let collectionCount = others.filter(r => r.isCollection()).length;
let searchCount = others.length - collectionCount;
// Use the search label only when nothing but searches is selected;
// otherwise describe the action in terms of collections
let undoAction, undoActionArgs;
if (searchCount && !collectionCount) {
undoAction = 'undo-action-trash-search';
undoActionArgs = { count: searchCount };
}
else {
undoAction = 'undo-action-trash-collection';
undoActionArgs = { count: collectionCount + searchCount };
}
await Zotero.DB.executeTransaction(async () => {
for (let row of others) {
row.ref.deleted = true;
if (row.isCollection()) {
await row.ref.save({ deleteItems, undoAction, undoActionArgs });
}
else {
await row.ref.save({ undoAction, undoActionArgs });
}
}
});
}
await treeRow.ref.saveTx();
}
unregister() {
this._uninitialized = true;
Zotero.Notifier.unregisterObserver(this._unregisterID);
this.clearEventListeners();
}
////////////////////////////////////////////////////////////////////////////////
@ -1362,37 +1409,91 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
/**
* Return libraryID of selected row (which could be a collection, etc.)
* Throw for a plugin calling one of the removed single-selection methods while multiple
* rows are selected, where the items list can span collections or libraries and acting
* on one row's value isn't safe
*
* Pre-release builds throw regardless of the selection, so that plugin developers see
* the failure.
*
* Also used by the ZoteroPane methods that wrap these, which pass their own names.
*
* @param {String} name
* @param {String} replacement
*/
getSelectedLibraryID() {
var treeRow = this.getRow(this.selection.focused);
return treeRow && treeRow.ref && treeRow.ref.libraryID !== undefined
&& treeRow.ref.libraryID;
}
getSelectedCollection(asID) {
var collection = this.getRow(this.selection.focused);
if (collection && collection.isCollection()) {
return asID ? collection.ref.id : collection.ref;
_requireSingleSelection(name, replacement) {
if (this.selection.count > 1
|| Zotero.isBetaBuild || Zotero.isDevBuild || Zotero.isSourceBuild) {
throw new Error(`${name} was removed -- use ${replacement}`);
}
Zotero.Plugins.warnRemovedAPICall(name, replacement);
}
getSelectedLibraryID() {
this._requireSingleSelection("CollectionTree#getSelectedLibraryID()",
"getSelectedLibraryIDs()");
var libraryIDs = this.getSelectedLibraryIDs();
return libraryIDs.length ? libraryIDs[0] : false;
}
/**
* Return the libraryID of every selected row, in tree order
*
* @return {Integer[]}
*/
getSelectedLibraryIDs() {
var libraryIDs = [];
for (let index of [...this.selection.selected].sort((a, b) => a - b)) {
let row = this.getRow(index);
if (row && row.ref && row.ref.libraryID !== undefined
&& !libraryIDs.includes(row.ref.libraryID)) {
libraryIDs.push(row.ref.libraryID);
}
}
return libraryIDs;
}
getSelectedCollection(asID) {
this._requireSingleSelection("CollectionTree#getSelectedCollection()",
"getSelectedCollections()");
return this.getSelectedCollections(asID)[0];
}
getSelectedCollections(asID) {
var collections = [];
for (let index of this.selection.selected) {
let row = this.getRow(index);
if (row && row.isCollection()) {
collections.push(asID ? row.ref.id : row.ref);
}
}
return collections;
}
getSelectedSearch(asID) {
if (this.getRow(this.selection.focused)) {
var search = this.getRow(this.selection.focused);
if (search && search.isSearch()) {
return asID ? search.ref.id : search.ref;
this._requireSingleSelection("CollectionTree#getSelectedSearch()",
"getSelectedSearches()");
var searches = this.getSelectedSearches(asID);
return searches.length ? searches[0] : false;
}
getSelectedSearches(asID) {
var searches = [];
for (let index of this.selection.selected) {
let row = this.getRow(index);
if (row && row.isSearch()) {
searches.push(asID ? row.ref.id : row.ref);
}
}
return false;
return searches;
}
getSelectedGroup(asID) {
if (this.getRow(this.selection.focused)) {
var group = this.getRow(this.selection.focused);
if (group && group.isGroup()) {
return asID ? group.ref.id : group.ref;
}
this._requireSingleSelection("CollectionTree#getSelectedGroup()",
"getSelectedRows() filtered by isGroup()");
var group = this.getSelectedRows().find(row => row.isGroup());
if (group) {
return asID ? group.ref.id : group.ref;
}
return false;
}
@ -1448,18 +1549,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
////////////////////////////////////////////////////////////////////////////////
onDragStart(event, index) {
const treeRow = this.getRow(index);
super.onDragStart(event, index);
// See note in #setDropEffect()
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = 'copyMove';
}
if (!treeRow.isCollection() && !treeRow.isSearch()) {
let rows = this.getSelectedRows();
// Ignore drag if all selected rows aren't the same type
if (!rows.every(o => o.type == rows[0].type)) {
return;
}
let type = treeRow.isCollection() ? "zotero/collection" : "zotero/search";
event.dataTransfer.setData(type, treeRow.ref.id);
Zotero.debug(`Dragging ${type} ` + treeRow.id);
// Ignore if not all collections or searches
if (!rows.every(o => o.isCollection() || o.isSearch())) {
return;
}
let type = rows[0].isCollection() ? "zotero/collection" : "zotero/search";
let ids = rows.map(o => o.ref.id);
event.dataTransfer.setDragImage(this._dragImageContainer, 0, 0);
event.dataTransfer.setData(type, ids);
Zotero.debug(`Dragging ${type} ` + (ids.length > 1 ? '[' + ids.join(', ') + ']' : ids[0]));
}
onDragOver(event, index) {
@ -1470,6 +1580,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
try {
// Prevent modifier keys from doing their normal things
event.preventDefault();
Zotero.DragDrop.currentDropEffect = null;
var previousOrientation = Zotero.DragDrop.currentOrientation;
Zotero.DragDrop.currentOrientation = getDragTargetOrient(event);
@ -1533,7 +1644,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
let move = (Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey);
// A selection from a multiple-collection view can span libraries. Those items can
// only be copied, never moved (a move can't coherently move some items and copy
// others), so disallow a move rather than silently substituting a copy.
let ids = Zotero.DragDrop.getDataFromDataTransfer(event.dataTransfer).data;
let items = Zotero.Items.get(ids);
if (new Set(items.map(item => item.libraryID)).size > 1) {
this.setDropEffect(event, move ? "none" : "copy");
return false;
}
if (move) {
this.setDropEffect(event, "move");
}
else {
@ -1603,7 +1726,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
onDragEnd = () => {
onDragEnd = (event, index) => {
super.onDragEnd(event, index);
let dropRow = this._dropRow;
this._dropRow = null;
this.tree.invalidateRow(dropRow);
@ -1732,11 +1857,14 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// Intra-library drag
// Don't allow drag onto root of same library
// An item can't be added to the root of its own library, but skip it rather
// than rejecting the whole drag, so a mixed-library selection can still copy
// its out-of-library items here. (If every item is already in this library,
// `skip` stays true and the drag is refused below.)
if (treeRow.isLibrary(true)) {
Zotero.debug("Can't drag into same library root");
return false;
Zotero.debug("Item " + item.id + " already in library " + treeRow.ref.libraryID);
continue;
}
// Make sure there's at least one item that's not already in this destination
@ -1775,19 +1903,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false;
}
let draggedCollectionID = data[0];
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
for (let id of data) {
let draggedCollection = Zotero.Collections.get(id);
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (draggedCollectionID == treeRow.ref.id) {
return false;
}
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (id == treeRow.ref.id) {
return false;
}
// Nor in their children
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
return false;
// Nor in their children
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
return false;
}
}
}
@ -2166,7 +2295,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this._flashingRow = null;
this.tree.invalidateRow(oldFlashing);
if (!dataTransfer.dropEffect || dataTransfer.dropEffect == "none"
// Use the effect set in onDragOver(), which the drop event's dropEffect may not reflect
// (see LibraryTreeView::setDropEffect())
var dropEffect = Zotero.DragDrop.currentDropEffect || dataTransfer.dropEffect;
Zotero.DragDrop.currentDropEffect = null;
if (!dropEffect || dropEffect == "none"
|| !(await this.canDropCheckAsync(row, orient, dataTransfer))) {
return false;
}
@ -2175,7 +2308,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
Zotero.debug("No drag data");
return false;
}
var dropEffect = dragData.dropEffect;
var dataType = dragData.dataType;
var data = dragData.data;
var sourceTreeRow = Zotero.DragDrop.getDragSource(dataTransfer);
@ -2192,16 +2324,21 @@ var CollectionTree = class CollectionTree extends LibraryTree {
// Dropping items, collections, or searches into trash
if (targetTreeRow.isTrash()) {
let objects = [];
let undoAction;
if (dataType == 'zotero/collection') {
objects = await Zotero.Collections.getAsync(data);
undoAction = 'undo-action-trash-collection';
}
else if (dataType == 'zotero/search') {
objects = await Zotero.Searches.getAsync(data);
undoAction = 'undo-action-trash-search';
}
else if (dataType == 'zotero/item') {
objects = await Zotero.Items.getAsync(data);
undoAction = 'undo-action-trash';
}
await Zotero.DB.executeTransaction(async function () {
Zotero.UndoHistory.stageAction(undoAction, { count: objects.length });
for (let obj of objects) {
obj.deleted = true;
await obj.save();
@ -2213,22 +2350,36 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var targetLibraryID = targetTreeRow.ref.libraryID;
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
if (targetCollectionID) {
Zotero.Collections.addToRecent(targetTreeRow.ref);
}
if (dataType == 'zotero/collection') {
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
let droppedCollections = await Zotero.Collections.getAsync(data);
if (droppedCollections.some(c => c.id == targetCollectionID)) {
throw new Error("Can't drop onto source row");
}
// Collection drag between libraries
if (targetLibraryID != droppedCollection.libraryID) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
if (targetLibraryID != droppedCollections[0].libraryID) {
for (let droppedCollection of droppedCollections) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
}
// Collection drag within a library
else {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.saveTx();
await Zotero.DB.executeTransaction(async () => {
for (let droppedCollection of droppedCollections) {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.save({ undoAction: 'undo-action-move-collection' });
}
});
}
}
else if (dataType == 'zotero/item') {
@ -2275,42 +2426,61 @@ var CollectionTree = class CollectionTree extends LibraryTree {
});
}
let newItems = [];
let newIDs = [];
// Route each item by its own library: items already in the target library are added
// directly, while items from other libraries are copied into the target library. A
// selection can span multiple libraries when dragging from a multiple-collection view.
let sameLibraryItems = [];
let otherLibraryItems = [];
let toMove = [];
// TODO: support items coming from different sources?
let sameLibrary = items[0].libraryID == targetLibraryID
for (let item of items) {
if (!item.isTopLevelItem()) {
continue;
}
newItems.push(item);
if (sameLibrary) {
newIDs.push(item.id);
if (item.libraryID == targetLibraryID) {
sameLibraryItems.push(item);
toMove.push(item.id);
}
else {
otherLibraryItems.push(item);
}
}
if (sameLibrary) {
// Add items to target container in the same library.
// Add same-library items to the target container
if (sameLibraryItems.length) {
if (targetCollectionID) {
let ids = newIDs.filter(itemID => Zotero.Items.get(itemID).isTopLevelItem());
let ids = sameLibraryItems.map(item => item.id);
await Zotero.DB.executeTransaction(async function () {
let collection = await Zotero.Collections.getAsync(targetCollectionID);
await collection.addItems(ids);
// If moving, remove from source in the same
// transaction so it's a single undo step
if (dropEffect == 'move' && toMove.length
&& sourceTreeRow && sourceTreeRow.isCollection()) {
await sourceTreeRow.ref.removeItems(toMove);
toMove = [];
Zotero.UndoHistory.stageAction(
'undo-action-move-to-collection', { count: ids.length }
);
}
else {
Zotero.UndoHistory.stageAction(
'undo-action-add-to-collection', { count: ids.length }
);
}
}.bind(this));
}
else if (targetTreeRow.isPublications()) {
await Zotero.Items.addToPublications(newItems, copyOptions);
await Zotero.Items.addToPublications(sameLibraryItems, copyOptions);
}
}
else {
// Copy items from other libraries into the target library
if (otherLibraryItems.length) {
let toReconcile = [];
await Zotero.Utilities.Internal.forEachChunkAsync(
newItems,
otherLibraryItems,
100,
function (chunk) {
return Zotero.DB.executeTransaction(async () => {
@ -2379,11 +2549,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// If moving, remove items from source collection
if (dropEffect == 'move' && toMove.length) {
if (!sameLibrary) {
throw new Error("Cannot move items between libraries");
}
// If moving, remove items from source collection. A move of a mixed-library selection
// is disallowed in onDragOver(), so it shouldn't reach here; guard against a partial
// move just in case, since only the same-library items would be moved.
if (dropEffect == 'move' && toMove.length && !otherLibraryItems.length) {
if (!sourceTreeRow || !sourceTreeRow.isCollection()) {
throw new Error("Drag source must be a collection for move action");
}
@ -2519,9 +2688,72 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return true;
}
isSelectable = index => {
// Cmd/Ctrl-A on the collection tree. Expand the current selection to all rows of
// the same kind within their natural scope: all library roots in the same
// visibility group (so feeds and regular libraries don't mix), all collections
// sharing a parent with a selected collection (so a multi-level or multi-parent
// selection expands each branch it touches), or Recently Read in every library.
// Anything else (saved searches, Unfiled, Trash, etc.) has no useful "select
// all", so the selection is left untouched rather than cleared.
async _handleSelectAll() {
let selectedIndexes = Array.from(this.selection.selected);
if (!selectedIndexes.length) {
return;
}
let selectedRows = selectedIndexes.map(index => this.getRow(index));
let scope;
if (selectedRows.every(row => row.isLibrary(true))) {
// Stay within one visibility group so feeds aren't pulled into an
// all-libraries selection, which couldn't be shown together
let visibilityGroup = selectedRows[0].visibilityGroup;
scope = row => row.isLibrary(true) && row.visibilityGroup === visibilityGroup;
}
else if (selectedRows.every(row => row.isCollection())) {
let parentIndexes = new Set(
selectedIndexes.map(index => this.getParentIndex(index))
);
scope = (row, index) => row.isCollection()
&& parentIndexes.has(this.getParentIndex(index));
}
else if (selectedRows.every(row => row.isRecentlyRead())) {
// Recently Read can appear under any library, so open any collapsed
// libraries that show it -- otherwise its row isn't in the tree to select
let libraryIDs = [];
for (let i = 0; i < this._rows.length; i++) {
let row = this.getRow(i);
if (row.isLibrary(true)
&& this._virtualCollectionLibraries.recentlyRead?.[row.ref.libraryID] !== false) {
libraryIDs.push(row.ref.libraryID);
}
}
for (let libraryID of libraryIDs) {
await this.expandLibrary(libraryID);
}
scope = row => row.isRecentlyRead();
}
else {
return;
}
this._selectAllScope = scope;
try {
this.selection.selectAll();
}
finally {
this._selectAllScope = null;
}
}
isSelectable = (index) => {
let treeRow = this.getRow(index);
return treeRow && !(treeRow.isSeparator() || treeRow.isHeader());
if (!treeRow || treeRow.isSeparator() || treeRow.isHeader()) {
return false;
}
// While a scoped Select All (Cmd/Ctrl-A) is running, restrict the new
// selection to the rows in the scope computed in _handleSelectAll()
if (this._selectAllScope) {
return this._selectAllScope(treeRow, index);
}
return true;
}
_closeContainer(row, skipMap) {
@ -2532,9 +2764,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var level = this.getLevel(row);
var nextRow = row + 1;
// Remove child rows
// Remove child rows, remapping any selected ones to the collapsed container
while ((nextRow < this._rows.length) && (this.getLevel(nextRow) > level)) {
this._removeRow(nextRow, true);
this.selection.adjustForRowRemoval(nextRow, true);
this._removeRow(nextRow, true, true);
}
this.selection.selectEventsSuppressed = false;
@ -2645,7 +2878,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
let collectionTable = document.getElementById("collection-tree").firstElementChild;
let isEmpty = this._isFilterEmpty();
let willBeEmpty = filterText.length == 0;
this._filter = filterText.toLowerCase();
this._filter = Zotero.Utilities.Internal.normalizeForSearch(filterText);
let currentRow = this.getRow(this.selection.focused) || this._hiddenFocusedRow;
let currentRowDisplayed = currentRow && this._includedInTree(currentRow.ref);
let shouldRestoreScrollPosition = willBeEmpty && !isEmpty && !this._treeWasFocused;
@ -2660,6 +2893,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this.selection.clearSelection();
}
await this.reload();
// reload() rebuilds the rows, so any selected indices from before now point at
// different (or out-of-range) rows. Clear them and re-select just the current
// row below, collapsing a multi-selection to the focused row while filtering.
this.selection.clearSelection();
if (currentRow) {
// Special treatment for when there are no filter matches
// Otherwise, selection.focused does not get updated by selectByID, which breaks ZoteroPane.
@ -2892,14 +3129,15 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (this._filterResultsCache[objectID] && !resetCache) {
return this._filterResultsCache[objectID];
}
// Filtering is case insensitive
let objectName = (object.name || "").toLowerCase();
// Filtering is case- and accent-insensitive
let normalize = Zotero.Utilities.Internal.normalizeForSearch;
let objectName = normalize(object.name || "");
// Special treatment to fetch the name for My Library or Feeds
if (objectID[0] == 'L' && object._ObjectType !== "Group") {
objectName = Zotero.getString('pane.collections.library').toLowerCase();
objectName = normalize(Zotero.getString('pane.collections.library'));
}
else if (objectID == 'feeds') {
objectName = Zotero.getString('pane.collections.feedLibraries').toLowerCase();
objectName = normalize(Zotero.getString('pane.collections.feedLibraries'));
}
let filterValue = this._filter;

File diff suppressed because it is too large Load diff

View file

@ -75,10 +75,10 @@ const ProgressQueueTable = ({ onActivate = noop, progressQueue }) => {
for (let column of columns) {
if (column.dataKey === 'success') {
let span = document.createElement('span');
if (!span.ownerGlobal) {
if (!span.documentGlobal) {
// If this script was imported from a non-window context, we'll have a global object that looks like
// a Window and document.createElement() will succeed, but the returned Element object won't have
// an ownerGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
// an documentGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
// let's just abort if we get an invalid Element.
// TODO: Remove once we're using ES modules
return div;

View file

@ -258,6 +258,63 @@ class TreeSelection {
this._updateTree(shouldDebounce);
}
/**
* Adjusts selection indexes after a row is removed. A removed selected row is
* dropped from the selection, or remapped to the previous row when remapToPrevious
* is true (e.g., when collapsing a container remaps removed descendants to the
* container row, which precedes them). If the removal leaves nothing selected, the
* previous row is selected. If the focused or pivot row is removed, it moves to
* the previous row.
*
* @param {Number} index Removed row index
* @param {Boolean} [remapToPrevious=false] Select the previous row in place of a
* removed selected row instead of dropping it from the selection
*/
adjustForRowRemoval(index, remapToPrevious = false) {
let previousIndex = Math.max(index - 1, 0);
let selected = new Set();
for (let selectedIndex of this.selected) {
if (selectedIndex == index) {
if (remapToPrevious) {
selected.add(previousIndex);
}
}
else {
selected.add(selectedIndex > index ? selectedIndex - 1 : selectedIndex);
}
}
if (this.selected.size && !selected.size) {
selected.add(previousIndex);
}
this.selected = selected;
this.focused = this.focused == index
? previousIndex
: this.focused > index ? this.focused - 1 : this.focused;
this.pivot = this.pivot == index
? previousIndex
: this.pivot > index ? this.pivot - 1 : this.pivot;
}
/**
* Adjusts selection indexes after rows are inserted after a given row.
*
* @param {Number} index Row after which rows were inserted
* @param {Number} count Number of inserted rows
*/
adjustForRowInsertion(index, count) {
let selected = new Set();
for (let selectedIndex of this.selected) {
selected.add(selectedIndex > index ? selectedIndex + count : selectedIndex);
}
this.selected = selected;
if (this.focused > index) {
this.focused += count;
}
if (this.pivot > index) {
this.pivot += count;
}
}
/**
* Calls the onSelectionChange prop on the tree
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
@ -379,6 +436,17 @@ class VirtualizedTable extends React.Component {
multiSelect: false,
// When true, the last selected row can't be toggled off, so the selection
// never becomes empty through user action
requireSelection: false,
// When true, the header row of the section currently at the top of the view is
// pinned to the top while scrolling (see isSectionHeader)
stickySectionHeaders: false,
// Returns whether the row at the given index begins a section, i.e., should be
// pinned to the top while its section is scrolled through
isSectionHeader: () => false,
onSelectionChange: noop,
// The below are for arrow-key navigation
@ -443,6 +511,11 @@ class VirtualizedTable extends React.Component {
multiSelect: PropTypes.bool,
requireSelection: PropTypes.bool,
stickySectionHeaders: PropTypes.bool,
isSectionHeader: PropTypes.func,
onSelectionChange: PropTypes.func,
// The below are for arrow-key navigation
@ -553,7 +626,7 @@ class VirtualizedTable extends React.Component {
*
* @param {Event} e
*/
_onKeyDown = (e) => {
_onKeyDown = async (e) => {
if (this.props.onKeyDown && this.props.onKeyDown(e) === false) return;
this._preventKeyboardScrolling(e);
@ -647,6 +720,25 @@ class VirtualizedTable extends React.Component {
}
if (shiftSelect || moveFocused) return;
// If selection count is greater than 1 and the focused row wasn't
// moved out of the selection - toggle open/closed all rows in that selection
// Otherwise if the focused row has moved out of the selection, toggle state
// of the focused row (handled below)
if (this.selection.count > 1
&& this.selection.isSelected(this.selection.focused)
&& [Zotero.arrowPreviousKey, Zotero.arrowNextKey].includes(e.key)) {
let open = e.key == Zotero.arrowNextKey;
let rows = Array.from(this.selection.selected)
.filter(index => this.props.isContainer(index)
&& !this.props.isContainerEmpty(index)
&& (open ? !this.props.isContainerOpen(index) : this.props.isContainerOpen(index)))
.sort((a, b) => b - a);
for (let index of rows) {
await this.toggleOpenState(index);
}
return;
}
switch (e.key) {
case Zotero.arrowPreviousKey:
@ -797,7 +889,16 @@ class VirtualizedTable extends React.Component {
* @param index {Number}
*/
scrollToRow(index) {
this._jsWindow && this._jsWindow.scrollToRow(index);
if (!this._jsWindow) return;
// When a sticky section header is pinned at the top of the view, it overlays the rows
// below it. Reserve a row's worth of space so a row scrolled up into view lands below the
// pinned header rather than behind it.
let topOffset = 0;
if (this.props.stickySectionHeaders && !this.props.isSectionHeader(index)
&& this._getSectionHeaderIndices().some(i => i < index)) {
topOffset = this._rowHeight;
}
this._jsWindow.scrollToRow(index, false, topOffset);
}
/**
@ -823,6 +924,11 @@ class VirtualizedTable extends React.Component {
this.selection.shiftSelect(index, toggleSelection, shouldDebounce);
}
else if (toggleSelection) {
// Don't allow toggling off the last selected row when a selection is required
if (this.props.requireSelection
&& this.selection.count === 1 && this.selection.isSelected(index)) {
return;
}
this.selection.toggleSelect(index, shouldDebounce);
}
else if (moveFocused) {
@ -835,7 +941,9 @@ class VirtualizedTable extends React.Component {
}
// Normal selection
else if (!toggleSelection) {
if (index > 0 && !this.props.isSelectable(index)) {
// Non-selectable rows (e.g. a sticky library header) are a no-op, including no
// scroll-to-row below -- otherwise clicking the header at index 0 jumps to the top
if (!this.props.isSelectable(index)) {
return;
}
this.selection.select(index, shouldDebounce);
@ -1070,12 +1178,24 @@ class VirtualizedTable extends React.Component {
this._setXulTooltip();
this._topDiv.style.setProperty("--first-column-extra-width", `${this.firstColumnExtraWidth}px`);
window.addEventListener("resize", () => {
this._debouncedRerender();
});
this._resizeObserver = new ResizeObserver(() => this.rerender());
this._resizeObserver.observe(this._jsWindow.targetElement);
if (this.props.stickySectionHeaders) {
this._jsWindow.targetElement.addEventListener('scroll', this._updateStickySectionHeader, { passive: true });
// The pinned header overlays the rows, so reject drops on it rather than letting
// them reach the body's drop handler (which would treat them as a list drop)
this._stickyHeader.addEventListener('dragover', this._rejectStickyHeaderDrop);
this._stickyHeader.addEventListener('drop', this._rejectStickyHeaderDrop);
this._updateStickySectionHeader();
}
}
componentWillUnmount() {
this._resizeObserver?.disconnect();
if (this.props.stickySectionHeaders && this._jsWindow) {
this._jsWindow.targetElement.removeEventListener('scroll', this._updateStickySectionHeader);
}
this._jsWindow.destroy();
}
@ -1148,8 +1268,12 @@ class VirtualizedTable extends React.Component {
}
node.style.height = (index in this._customRowHeightMap ? this._customRowHeightMap[index] : this._rowHeight) + 'px';
node.id = this.props.id + "-row-" + index;
node.classList.toggle('odd', index % 2 == 1);
node.classList.toggle('even', index % 2 == 0);
// Row striping restarts at each section header, so every section's first row is
// the same shade (see _sectionRelativeIndex); without section headers this is just
// the row index
let stripeIndex = this._sectionRelativeIndex(index);
node.classList.toggle('odd', stripeIndex % 2 == 1);
node.classList.toggle('even', stripeIndex % 2 == 0);
if (!node.hasAttribute('role')) {
node.setAttribute('role', 'row');
}
@ -1279,7 +1403,12 @@ class VirtualizedTable extends React.Component {
}
let jsWindowProps = {
id: this._jsWindowID,
className: "virtualized-table-body",
// Reserve a scrollbar gutter when there are sticky section headers (see CSS), so the
// scrollbar doesn't float over the content. Without it a macOS overlay scrollbar
// overlaps the rows, and the opaque pinned header -- which must paint above the rows
// to occlude them, and so above the scrollbar -- covers the scrollbar's edge.
className: "virtualized-table-body"
+ (this.props.stickySectionHeaders ? " has-sticky-section-headers" : ""),
onFocus: (e) => {
if (e.target.id == this._jsWindowID) {
// Focus should always remain on the list itself.
@ -1292,7 +1421,23 @@ class VirtualizedTable extends React.Component {
<div {...props}>
{columnDragMarker}
{header}
<div {...jsWindowProps} />
<div {...jsWindowProps}>
{/* Pinned copy of the current section's header. Lives inside the scrolling
body (as its first child, before the windowed-list) and pins with
position: sticky, so its width tracks the body's content box automatically
and it lines up with the rows without any JS geometry. */}
{this.props.stickySectionHeaders
&& <div
className="virtualized-table-sticky-section-header"
ref={ref => this._stickyHeader = ref}
aria-hidden="true"
>
<div
className="virtualized-table-sticky-section-header-content"
ref={ref => this._stickyHeaderContent = ref}
/>
</div>}
</div>
</div>
);
}
@ -1304,6 +1449,7 @@ class VirtualizedTable extends React.Component {
if (!this._jsWindow) return;
this._jsWindow.invalidate();
this._updateWidth();
this._refreshStickySectionHeader();
}
/**
@ -1314,6 +1460,149 @@ class VirtualizedTable extends React.Component {
if (!this._jsWindow) return;
this._jsWindow.render();
this._updateWidth();
this._refreshStickySectionHeader();
}
// ------------------------ Sticky Section Headers ------------------------ //
/**
* Make a drop on the pinned header a no-op. Not preventing the default on dragover leaves
* it an invalid drop target (so no drop fires), and stopping propagation keeps the event
* from the body's handlers, which would otherwise allow the drop and treat it as a drop on
* the list. The header occludes a row, but dropping on it shouldn't act on that row.
*/
_rejectStickyHeaderDrop = (e) => {
e.stopPropagation();
if (e.type == 'dragover' && e.dataTransfer) {
e.dataTransfer.dropEffect = 'none';
}
}
/**
* The set of section-header rows can change whenever the row model changes, so drop
* the cached indices and repin. Called after the list is invalidated/rerendered.
*/
_refreshStickySectionHeader() {
if (!this.props.stickySectionHeaders) return;
this._sectionHeaderIndices = null;
this._stickyHeaderIndex = null;
this._updateStickySectionHeader();
}
/**
* The stripe index of a row, so striping restarts at each section header. The header
* counts as the section's first (unstriped) row, so the data row right below it is
* striped; subsequent rows alternate. Without section headers this is just the row
* index, preserving the normal whole-list striping (first row unstriped).
*/
_sectionRelativeIndex(index) {
let base = -1;
for (let headerIndex of this._getSectionHeaderIndices()) {
if (headerIndex <= index) {
base = headerIndex;
}
else {
break;
}
}
// No header above: stripe from the top (first row unstriped). With a header above,
// the header is the unstriped row 0, so the row below it (index - base == 1) is striped.
return base === -1 ? index : index - base;
}
/**
* Indices of all section-header rows, ascending. Cached until the row model changes.
*/
_getSectionHeaderIndices() {
// Only trees that opt into section headers have them
if (!this.props.stickySectionHeaders) {
return [];
}
if (this._sectionHeaderIndices) {
return this._sectionHeaderIndices;
}
let indices = [];
let count = this.props.getRowCount();
for (let i = 0; i < count; i++) {
if (this.props.isSectionHeader(i)) {
indices.push(i);
}
}
this._sectionHeaderIndices = indices;
return indices;
}
/**
* Pin the header of the section currently at the top of the view, pushing it up as
* the next section's header scrolls into it, and reuse the consumer's renderItem so the
* pinned copy matches the real header row's appearance.
*
* The pinned header is the first child of the scrolling body and stays put via position:
* sticky, taking its width from the body's content box so it lines up with the rows. The
* outer element has zero height (so it adds no space to the flow); the inner (opaque)
* element holds the rendered header, overflows downward over the rows, and is the part that
* translates, so a header pushed up by the next section is clipped at the top of the body
* (by the body's own overflow) rather than spilling over the column header.
*/
_updateStickySectionHeader = () => {
if (!this.props.stickySectionHeaders || !this._stickyHeader || !this._jsWindow) {
return;
}
let clip = this._stickyHeader;
let content = this._stickyHeaderContent;
let headerIndices = this._getSectionHeaderIndices();
let scrollTop = this._jsWindow.targetElement.scrollTop;
// The section in view is the last header at or above the top of the view; the
// next header (if any) is what pushes it up
let currentIndex = -1;
let nextIndex = -1;
for (let index of headerIndices) {
if (this._jsWindow._getItemPosition(index) <= scrollTop) {
currentIndex = index;
}
else {
nextIndex = index;
break;
}
}
// Show the pinned copy only once the header row has scrolled up past the top edge of
// the view
let stuck = currentIndex != -1
&& scrollTop > this._jsWindow._getItemPosition(currentIndex);
if (!stuck) {
clip.style.display = 'none';
this._stickyHeaderIndex = null;
return;
}
clip.style.display = '';
clip.classList.add('stuck');
// Re-render only when the pinned section changes. Use _renderItem (not the raw
// renderItem prop) so the pinned copy gets the same post-processing as a real row
// (e.g. the tree's indent/twisty spacer), then drop its id to avoid duplicating the
// real row's.
if (this._stickyHeaderIndex !== currentIndex) {
this._stickyHeaderIndex = currentIndex;
let node = this._renderItem(currentIndex);
node.removeAttribute('id');
// Strip the focus ring: focus defaults to row 0, which can be a header, but a
// pinned header shouldn't show focus
node.classList.remove('focused');
content.textContent = '';
content.appendChild(node);
}
// Geometry is all CSS now: the clip is the first child of the scrolling body, has zero
// height (so it takes no space in the flow and the rows below aren't shifted down), and
// pins itself with position: sticky. Its content overflows downward over the rows and
// gets its width from the body's content box, so it lines up with the real rows.
// Push the pinned header up as the next section's header approaches the top
let translateY = 0;
if (nextIndex != -1) {
let nextTop = this._jsWindow._getItemPosition(nextIndex) - scrollTop;
if (nextTop < this._rowHeight) {
translateY = nextTop - this._rowHeight;
}
}
content.style.transform = `translateY(${translateY}px)`;
}
updateFontSize = () => {
@ -1376,8 +1665,6 @@ class VirtualizedTable extends React.Component {
return parseFloat(height.split('px')[0]);
}
_debouncedRerender = Zotero.Utilities.debounce(this.rerender, 200);
_updateWidth() {
if (!this.props.showHeader) return;
const jsWindow = document.querySelector(`#${this._jsWindowID} .windowed-list`);
@ -2000,12 +2287,10 @@ function makeRowRenderer(getRowData) {
else {
div.appendChild(renderCell(index, rowData[column.dataKey], column));
}
let columnName = column.label;
if (column.label in Zotero.Intl.strings) {
columnName = Zotero.getString(column.label);
}
if (typeof rowData[column.dataKey] === "string") {
ariaLabel += `${columnName}: ${rowData[column.dataKey]} `;
let columnName = formatColumnName(column);
let value = rowData[column.dataKey];
if (typeof value === "string" || typeof value === "number") {
ariaLabel += `${columnName}: ${value} `;
}
else {
ariaLabel += `${columnName} `;

View file

@ -205,8 +205,11 @@ module.exports = class {
* @param {Integer} index
* @param {Boolean} forceScrollToTop If true, the row will be scrolled to the top of the scrollbox
* even if it is below the current scroll window.
* @param {Integer} topOffset Amount of space reserved at the top of the scrollbox (e.g. for a
* sticky section header that overlays the rows). When scrolling a row into view from above, the
* row is positioned below this offset rather than flush with the top edge.
*/
scrollToRow(index, forceScrollToTop = false) {
scrollToRow(index, forceScrollToTop = false, topOffset = 0) {
const { scrollOffset } = this;
const itemCount = this._getItemCount();
const height = this.getWindowHeight();
@ -221,8 +224,8 @@ module.exports = class {
this.scrollTo(startPosition);
return;
}
if (startPosition < scrollOffset) {
this.scrollTo(startPosition);
if (startPosition - topOffset < scrollOffset) {
this.scrollTo(startPosition - topOffset);
}
else if (endPosition > scrollOffset + height) {
this.scrollTo(endPosition - height - 1);

View file

@ -52,15 +52,21 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
['collection-item', 'item', 'item-tag', 'tag', 'setting'],
'tagSelector'
);
this._prefObserverID = Zotero.Prefs.registerObserver('fontSize', this.handleUIPropertiesChange.bind(this));
this._prefObserverID = Zotero.Prefs.registerObserver('uiDensity', this.handleUIPropertiesChange.bind(this));
this._prefObserverIDs = [
Zotero.Prefs.registerObserver('fontSize', this.handleUIPropertiesChange.bind(this)),
Zotero.Prefs.registerObserver('uiDensity', this.handleUIPropertiesChange.bind(this))
];
this._mediaQueryList = window.matchMedia("(min-resolution: 1.5dppx)");
this._mediaQueryList.addEventListener("change", this.handleUIPropertiesChange.bind(this));
this._boundHandleUIPropertiesChange = this.handleUIPropertiesChange.bind(this);
this._mediaQueryList.addEventListener("change", this._boundHandleUIPropertiesChange);
this.tagListRef = React.createRef();
this.searchBoxRef = React.createRef();
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
// Library IDs of the selected collection tree rows, in collections-list order.
// Usually one, but a cross-library selection scopes the tag selector to several.
this.libraryIDs = [];
// Not stored in state to avoid an unnecessary refresh. Instead, when a tag is selected, we
// trigger the selection handler, which updates the visible items, which triggers
// onItemViewChanged(), which triggers a refresh with the new tags.
@ -101,10 +107,13 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
componentDidUpdate(_prevProps, _prevState) {
Zotero.debug("Tag selector updated");
// If we changed collections, scroll to top
if (this.collectionTreeRow && this.collectionTreeRow.id != this.prevTreeViewID) {
this.tagListRef.current.scrollToTop();
this.prevTreeViewID = this.collectionTreeRow.id;
// If we changed the set of collections, scroll to top
if (this.collectionTreeRows) {
let treeViewIDs = this.collectionTreeRows.map(row => row.id).sort();
if (!Zotero.Utilities.arrayEquals(treeViewIDs, this.prevTreeViewIDs)) {
this.tagListRef.current.scrollToTop();
this.prevTreeViewIDs = treeViewIDs;
}
}
}
@ -115,7 +124,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
}
/**
* Safely fetch tags from the current collection tree row, returning [] on search error.
* Safely fetch tags from the selected collection tree rows, returning [] on search error.
* CollectionTreeRow.getTags() calls getSearchResults() under the hood, which throws
* Zotero.CollectionTreeRow.SearchError if the underlying search query fails (e.g., a
* saved search with invalid conditions). The tag selector should degrade gracefully in
@ -124,7 +133,15 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
*/
async _safeGetTags(...args) {
try {
return await this.collectionTreeRow.getTags(...args);
let rows = this.collectionTreeRows || [];
// A single row uses its own cached temp table directly; multiple rows are
// combined into one temp table and query to avoid a per-row query
let tags = rows.length == 1
? await rows[0].getTags(...args)
: await Zotero.CollectionTreeRow.getTagsAcrossRows(rows, ...args);
// Multiple rows (collections, or collections across libraries) can return
// the same tag, so dedupe by name
return this._dedupeTags(tags);
}
catch (e) {
if (e instanceof Zotero.CollectionTreeRow.SearchError) {
@ -135,24 +152,78 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
}
}
/**
* Whether more than one library is currently in scope (cross-library selection)
*/
get multiLibrary() {
return this.libraryIDs.length > 1;
}
_dedupeTags(tags) {
let seen = new Set();
let result = [];
for (let tag of tags) {
if (!seen.has(tag.tag)) {
seen.add(tag.tag);
result.push(tag);
}
}
return result;
}
/**
* Get all tags across the selected libraries (for "Display All Tags"), deduped by name
*
* @param {Number[]} [tagIDs] - Limit to the given tag IDs
*/
async _getAllTagsInLibraries(tagIDs) {
let results = await Promise.all(
this.libraryIDs.map(libraryID => Zotero.Tags.getAllWithin({ libraryID, tagIDs }))
);
return this._dedupeTags(results.flat());
}
_getSelectedLibraryIDs(collectionTreeRows, fallbackLibraryID) {
let ids = [];
for (let row of collectionTreeRows || []) {
let id = row.ref && row.ref.libraryID;
if (id !== undefined && id !== null && !ids.includes(id)) {
ids.push(id);
}
}
if (!ids.length && fallbackLibraryID) {
ids.push(fallbackLibraryID);
}
return ids;
}
/**
* Colored tags are a per-library synced setting, so a unified cross-library list
* can't coherently show them (the same color can mean different tags in different
* libraries). Only show colored tags when a single library is in scope. The items
* list still shows each item's own library's swatches via Item.getItemsListTags().
*/
_getScopeColors() {
if (this.libraryIDs.length !== 1) {
return new Map();
}
return Zotero.Tags.getColors(this.libraryIDs[0]);
}
// Update trigger #1 (triggered by ZoteroPane)
async onItemViewChanged({ collectionTreeRow, libraryID }) {
async onItemViewChanged({ collectionTreeRows, libraryID }) {
Zotero.debug('Updating tag selector from current view');
var prevLibraryID = this.libraryID;
this.collectionTreeRow = collectionTreeRow;
this.libraryID = libraryID;
var prevLibraryIDs = this.libraryIDs;
this.collectionTreeRows = collectionTreeRows;
this.libraryIDs = this._getSelectedLibraryIDs(collectionTreeRows, libraryID);
this.libraryID = this.libraryIDs[0];
var newState = {
loaded: true
};
if (prevLibraryID != libraryID) {
if (libraryID) {
newState.tagColors = Zotero.Tags.getColors(libraryID);
}
else {
newState.tagColors = new Map();
}
if (!Zotero.Utilities.arrayEquals(prevLibraryIDs, this.libraryIDs)) {
newState.tagColors = this._getScopeColors();
}
var { tags, scope } = await this.getTagsAndScope();
newState.tags = tags;
@ -166,14 +237,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
if (ids.some(val => val.split('/')[1] == 'tagColors')) {
Zotero.debug("Updating tag selector after tag color change");
this.setState({
tagColors: Zotero.Tags.getColors(this.libraryID)
tagColors: this._getScopeColors()
});
}
return;
}
// Ignore anything other than deletes in duplicates view
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
if (this.collectionTreeRows?.[0]?.isDuplicates()) {
switch (event) {
case 'delete':
case 'trash':
@ -215,9 +286,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
for (let [type, tagIDs] of tagsByType) {
changedTagsInScope.push(...await this._safeGetTags([type], tagIDs));
if (this.displayAllTags) {
changedTagsInView.push(
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
);
changedTagsInView.push(...await this._getAllTagsInLibraries(tagIDs));
}
}
if (!this.displayAllTags) {
@ -340,7 +409,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
// The scope is all visible tags, not all tags in the library
var scope = new Set(tags.map(t => t.tag));
if (this.displayAllTags) {
tags = await Zotero.Tags.getAll(this.libraryID);
tags = await this._getAllTagsInLibraries();
}
// If tags haven't changed, return previous array without sorting again
@ -518,10 +587,10 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
});
tags = coloredTags.concat(tags);
// Filter
// Filter (case- and accent-insensitive, matching search conditions)
if (this.state.searchString) {
let lcStr = this.state.searchString.toLowerCase();
tags = tags.filter(tag => tag.tag.toLowerCase().includes(lcStr));
let normalizedStr = Zotero.Utilities.Internal.normalizeForSearch(this.state.searchString);
tags = tags.filter(tag => Zotero.Utilities.Internal.normalizeForSearch(tag.tag).includes(normalizedStr));
}
// Prepare tag objects for list component
@ -584,6 +653,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
for (let i = 0; i < tagContextMenu.childNodes.length; i++) {
tagContextMenu.childNodes[i].disabled = this.state.viewOnly;
}
// In a cross-library selection, disable per-library actions that would only
// affect one of the selected libraries. Deleting still works -- it removes the
// tag from all selected libraries (see openDeletePrompt())
if (this.multiLibrary) {
for (let id of ['assign-color-tag', 'rename-tag', 'split-tag']) {
document.getElementById(id).disabled = true;
}
}
ev.preventDefault();
tagContextMenu.openPopupAtScreen(
@ -665,7 +742,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
ids = ids.split(',');
var items = Zotero.Items.get(ids);
var value = elem.textContent;
Zotero.UndoHistory.stageAction(
remove ? 'undo-action-remove-tag' : 'undo-action-add-tag',
{ count: items.length }
);
for (let i=0; i<items.length; i++) {
let item = items[i];
if (remove) {
@ -741,6 +822,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
if (dataOut.result.op === 'split') {
const itemIDs = await Zotero.Tags.getTagItems(this.libraryID, oldTagID);
await Zotero.DB.executeTransaction(async () => {
Zotero.UndoHistory.stageAction('undo-action-split-tag');
for (const itemID of itemIDs) {
const item = await Zotero.Items.getAsync(itemID);
const tagType = item.getTagType(oldTagName);
@ -804,12 +886,16 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
var tagID = Zotero.Tags.getID(this.contextTag.name);
if (tagID) {
await Zotero.Tags.removeFromLibrary(this.libraryID, tagID);
}
// If only a tag color setting, remove that
else {
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
// Delete from every selected library. removeFromLibrary() only clears the color
// for tags that have items in the library, so also clear any remaining color-only
// setting (e.g. a tag that's a real tag in one library but only colored in another)
for (let libraryID of this.libraryIDs) {
if (tagID) {
await Zotero.Tags.removeFromLibrary(libraryID, tagID);
}
if (Zotero.Tags.getColors(libraryID).has(this.contextTag.name)) {
await Zotero.Tags.setColor(libraryID, this.contextTag.name, false);
}
}
}
@ -897,7 +983,10 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
this._uninitialized = true;
this.props.root.unmount();
Zotero.Notifier.unregisterObserver(this._notifierID);
Zotero.Prefs.unregisterObserver(this._prefObserverID);
for (let id of this._prefObserverIDs) {
Zotero.Prefs.unregisterObserver(id);
}
this._mediaQueryList.removeEventListener("change", this._boundHandleUIPropertiesChange);
}
static propTypes = {

View file

@ -124,7 +124,7 @@ function getContextMenuPosition(targetElement) {
*/
export function createContextMenuEvent(targetElement) {
let { clientX, clientY } = getContextMenuPosition(targetElement);
let win = targetElement.ownerGlobal;
let win = targetElement.documentGlobal;
let screenX = win.mozInnerScreenX + clientX;
let screenY = win.mozInnerScreenY + clientY;
// Need to use initNSMouseEvent() to set inputSource, so just construct a

View file

@ -53,8 +53,8 @@ var ZoteroContextPane = new function () {
return this.splitter.getAttribute('state') === 'collapsed';
},
set: (collapsed) => {
_contextPane.setAttribute('collapsed', !!collapsed);
_contextPaneInner.setAttribute('collapsed', !!collapsed);
_contextPane.toggleAttribute('collapsed', !!collapsed);
_contextPaneInner.toggleAttribute('collapsed', !!collapsed);
_contextPaneSplitter.setAttribute('state', collapsed ? 'collapsed' : 'open');
_contextPaneSplitterStacked.setAttribute('state', collapsed ? 'collapsed' : 'open');
this.update();
@ -141,7 +141,7 @@ var ZoteroContextPane = new function () {
return;
}
if (_isStacked()) {
_contextPaneSplitterStacked.setAttribute('hidden', false);
_contextPaneSplitterStacked.removeAttribute('hidden');
_contextPaneSplitter.setAttribute('hidden', true);
_contextPane.classList.add('stacked');
_contextPane.classList.remove('standard');
@ -159,7 +159,7 @@ var ZoteroContextPane = new function () {
_contextPaneSplitter.setAttribute('state', this.collapsed ? 'collapsed' : 'open');
}
else {
_contextPaneSplitter.setAttribute('hidden', false);
_contextPaneSplitter.removeAttribute('hidden');
_contextPaneSplitterStacked.setAttribute('hidden', true);
_contextPane.classList.add('standard');
_contextPane.classList.remove('stacked');
@ -180,12 +180,12 @@ var ZoteroContextPane = new function () {
var height = null;
if (_isStacked()) {
height = 0;
if (_contextPane.getAttribute('collapsed') != 'true') {
if (!_contextPane.hasAttribute('collapsed')) {
height = _contextPaneInner.getBoundingClientRect().height;
}
}
_contextPaneInner.setAttribute('collapsed', this.collapsed ? 'true' : 'false');
_contextPaneInner.toggleAttribute('collapsed', this.collapsed);
let tabContent = _getTabContent();
if (tabContent) {

View file

@ -33,6 +33,8 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
{
// https://searchfox.org/mozilla-central/rev/8e885f04a0a4ff6d64ea59741c10d9b8e45d9ff8/toolkit/content/customElements.js#826-832
for (let [tag, script] of [
['advanced-search-deck', 'chrome://zotero/content/elements/advancedSearchDeck.js'],
['advanced-search-pane', 'chrome://zotero/content/elements/advancedSearchPane.js'],
['attachment-box', 'chrome://zotero/content/elements/attachmentBox.js'],
['attachment-preview', 'chrome://zotero/content/elements/attachmentPreview.js'],
['attachment-preview-box', 'chrome://zotero/content/elements/attachmentPreviewBox.js'],
@ -49,8 +51,10 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['note-box', 'chrome://zotero/content/elements/noteBox.js'],
['note-editor', 'chrome://zotero/content/elements/noteEditor.js'],
['notes-box', 'chrome://zotero/content/elements/notesBox.js'],
['query-textbox', 'chrome://zotero/content/elements/queryTextbox.js'],
['quick-search-textbox', 'chrome://zotero/content/elements/quickSearchTextbox.js'],
['related-box', 'chrome://zotero/content/elements/relatedBox.js'],
['search-textbox', 'chrome://zotero/content/elements/searchTextbox.js'],
['shadow-autocomplete-input', 'chrome://zotero/content/elements/shadowAutocompleteInput.js'],
['split-menu-button', 'chrome://zotero/content/elements/splitMenuButton.js'],
['tabs-menu-panel', 'chrome://zotero/content/elements/tabsMenuPanel.js'],
@ -344,6 +348,8 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
if (target.tagName !== "menulist") return;
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
if (event.key !== " ") return;
// A find-as-you-type search in progress is typing a name with a space in it
if (Zotero.Utilities.Internal.isMenuFindAsYouTypeActive(target)) return;
if (target.open) {
// Simulate blinking of the selected menuitem on macOS (same as on Return keypress)
@ -438,7 +444,6 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
}
],
mac: [
"wizard",
{
element: "dialog",
// The `attachShadow` are cleared in <dialog>, we need to monkey-patch after `connectedCallback`.

View file

@ -138,7 +138,13 @@
throw new Error('Item has not been added to library');
}
this._item.setField('abstractNote', this._abstractField.value);
await this._item.saveTx();
await this._item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString('abstractNote'),
count: 1
}
});
}
this._forceRenderAll();
}

View file

@ -0,0 +1,83 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2025 Corporation for Digital Scholarship
Vienna, Virginia, USA
https://www.zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
"use strict";
{
class AdvancedSearchDeck extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<deck>
<advanced-search-pane id="zotero-temporary-advanced-search-pane" type="temporary"/>
<advanced-search-pane id="zotero-saved-advanced-search-pane" type="saved"/>
</deck>
`);
init() {
this.hidden = true;
this._state = 'closed';
this.deck = this.firstChild;
}
get state() {
return this._state;
}
set state(state) {
switch (state) {
case 'open':
this.hidden = false;
break;
case 'collapsed':
case 'closed':
this.hidden = true;
break;
default:
throw new Error('Invalid state: ' + state);
}
this._state = state;
}
get selectedSearchType() {
return this.deck.selectedIndex === 0 ? 'temporary' : 'saved';
}
set selectedSearchType(selectedSearchType) {
switch (selectedSearchType) {
case 'temporary':
this.deck.selectedIndex = 0;
break;
case 'saved':
this.deck.selectedIndex = 1;
break;
}
}
get pane() {
return this.deck.selectedPanel;
}
}
customElements.define("advanced-search-deck", AdvancedSearchDeck);
}

View file

@ -0,0 +1,407 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2025 Corporation for Digital Scholarship
Vienna, Virginia, USA
https://www.zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
"use strict";
{
class AdvancedSearchPane extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<hbox class="saved-search-name-row">
<label control="saved-search-name" data-l10n-id="new-collection-name"/>
<html:input type="text" id="saved-search-name"/>
</hbox>
<zoterosearch/>
<hbox class="advanced-search-buttons">
<button class="cancel-button" data-l10n-id="cancel-button"/>
<button class="search-button" data-l10n-id="search-button" default="true"/>
<button class="clear-button" data-l10n-id="clear-button"/>
<button class="save-button" data-l10n-id="save-search-new-button"/>
</hbox>
`);
_active = false;
init() {
this._nameField = this.querySelector('#saved-search-name');
this._searchElem = this.querySelector('zoterosearch');
this._cancelButton = this.querySelector('.cancel-button');
this._searchButton = this.querySelector('.search-button');
this._clearButton = this.querySelector('.clear-button');
this._saveButton = this.querySelector('.save-button');
this._searchElem.addEventListener('input', () => this._searchElem.updateSearch());
this._searchElem.addEventListener('command', () => this._searchElem.updateSearch());
this._cancelButton.addEventListener('command', () => this.cancel());
this._searchButton.addEventListener('command', () => this.submit());
this._clearButton.addEventListener('command', () => this.clear());
this._saveButton.addEventListener('command', () => this.save());
if (!['temporary', 'saved'].includes(this.type)) {
throw new Error(`Invalid type: ${this.type}`);
}
if (this.type === 'saved') {
this._saveButton.setAttribute('default', 'true');
// Editing an existing search saves changes directly, so just "Save"
// (vs. "Save Search…" for creating a new one, which prompts for a name)
this._saveButton.setAttribute('data-l10n-id', 'save-search-edit-button');
}
this.addEventListener('keydown', this._handleKeyDown);
}
_handleKeyDown = async (event) => {
this._searchElem.updateSearch();
// Shift-Enter adds a new condition (handled by the search element), so
// don't run/save the search for it
if (event.key === 'Enter' && !event.shiftKey) {
// Enter on a focused button runs that button (which the platform only does
// for Space), not the pane's default action
let button = event.target.closest && event.target.closest('button, toolbarbutton');
if (button) {
button.click();
return;
}
if (this.type === 'temporary') {
await this.submit();
}
else {
await this.save();
}
}
};
/**
* @returns {'temporary' | 'saved'}
*/
get type() {
return this.getAttribute('type');
}
get search() {
return this._search;
}
/**
* The ID of the saved search being edited, for type "saved"
*/
get editedSearchID() {
return this._searchID;
}
/**
* Whether the search has been submitted and should filter the items list
*/
get active() {
return this._active;
}
set search(search) {
this._active = false;
if (this.type === 'saved') {
if (!search?.id) {
throw new Error('Cannot edit unsaved search');
}
this._searchID = search.id;
this._search = search.clone();
this._nameField.value = search.name;
}
else if (search) {
this._search = search.clone();
}
else {
this._search = new Zotero.Search();
// Default a fresh search to top-level items, so a condition on a child
// (e.g. attachment content) maps up to its item without any grouping
this._search.addCondition('resultLevel', 'item');
this._search.addCondition('title', 'contains', '');
}
this._searchElem.search = this._search;
this._loadedState = this.type === 'saved' ? this._getState() : null;
}
/**
* The editor's current name and conditions, for comparison against the state the
* search was loaded with
*
* The search is serialized through the editor, since rendering an existing search
* can normalize it (e.g., folding a legacy 'noChildren' condition into the result
* level), and the normalized form is what a save would write.
*/
_getState() {
this._searchElem.updateSearch();
return {
name: this._nameField.value,
conditions: this._search.toJSON().conditions
};
}
/**
* Whether the name or conditions have been edited since the search was loaded,
* for type "saved"
*/
get hasChanges() {
if (this.type !== 'saved') {
throw new Error('hasChanges is unsupported for temporary search');
}
if (!this._loadedState) {
return false;
}
let state = this._getState();
return state.name !== this._loadedState.name
|| state.conditions.length !== this._loadedState.conditions.length
|| state.conditions.some((condition, i) => !Zotero.Searches.conditionEquals(
condition, this._loadedState.conditions[i]
));
}
_ensureSearch() {
if (!this._search && this.type === 'temporary') {
this.search = null;
}
}
// Allow saving at an editable library or group root (but not a feed) or with
// collections and/or saved searches selected within a single editable library,
// in which case the selection is added to the saved search as scope conditions
// (see _addScopeConditions())
_canSaveInSelection() {
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
if (!collectionTreeRows.length) {
return false;
}
let libraryID = collectionTreeRows[0].ref?.libraryID;
return collectionTreeRows.every((row) => {
if (row.ref?.libraryID !== libraryID || !row.editable) {
return false;
}
return (row.isLibrary(true) && !row.isFeed())
|| row.isCollection()
|| row.isSearch();
});
}
refresh() {
this._ensureSearch();
let libraryID = ZoteroPane.getSelectedLibraryIDs()[0];
// Keep the previous library when the selected row doesn't have one (e.g., Feeds)
if (libraryID) {
this._search.libraryID = libraryID;
}
// Set the libraries the search applies to: a temporary search spans all
// selected libraries, a saved search just its own. Used to scope value
// autocomplete and, when more than one library is involved, to drop the
// Collection/Saved Search condition, which can only resolve within one library.
if (this.type === 'temporary') {
let libraryIDs = [];
for (let row of ZoteroPane.getCollectionTreeRows()) {
let id = row.ref && row.ref.libraryID;
if (id !== undefined && id !== null && !libraryIDs.includes(id)) {
libraryIDs.push(id);
}
}
this._searchElem.scopeLibraryIDs = libraryIDs;
}
else {
this._searchElem.scopeLibraryIDs = [this._search.libraryID];
}
this._searchElem.search = this._search;
this._saveButton.disabled = this.type === 'temporary' && !this._canSaveInSelection();
}
async cancel() {
await ZoteroPane.setSavedSearchEditorState('closed');
}
async submit() {
if (this.type === 'saved') {
throw new Error('submit() is unsupported for saved search');
}
this._searchElem.updateSearch();
this._active = true;
await ZoteroPane.itemsView.setFilter('advanced-search', this._search);
}
async clear() {
if (this.type === 'saved') {
throw new Error('clear() is unsupported for saved search');
}
this.search = null;
await ZoteroPane.itemsView.setFilter('advanced-search', null);
}
async save() {
this._searchElem.updateSearch();
if (this.type === 'saved') {
let search = Zotero.Searches.get(this._searchID);
if (!search) {
throw new Error('Missing search');
}
search.fromJSON(this._search.toJSON());
search.name = this._nameField.value;
await search.saveTx();
await ZoteroPane.setSavedSearchEditorState('closed');
Zotero_Tabs.rename('zotero-pane', search.name);
return;
}
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
if (!this._canSaveInSelection()) {
throw new Error('Can only save in an editable library, collection, or saved search');
}
this._ensureSearch();
let libraryID = collectionTreeRows[0].ref.libraryID;
let searches = await Zotero.Searches.getAll(libraryID);
let prefix = Zotero.getString('pane.collections.untitled');
let defaultName = Zotero.Utilities.Internal.getNextName(
prefix,
searches.map(s => s.name).filter(n => n.startsWith(prefix))
);
// Prompt for a name, defaulting to the next "Untitled" name
let [title, message] = await document.l10n.formatValues([
'save-search-name-title',
'save-search-name-message'
]);
let nameObj = { value: defaultName };
if (!Services.prompt.prompt(window, title, message, nameObj, null, {})) {
return;
}
let name = nameObj.value.trim() || defaultName;
let search = this._search.clone(libraryID);
search.name = name;
// If saving within collections or saved searches rather than at the library
// root, scope the search to the selection
if (!collectionTreeRows.some(row => row.isLibrary(true))) {
this._addScopeConditions(search, collectionTreeRows);
}
await search.saveTx();
await ZoteroPane.setAdvancedSearchState('closed');
}
// Scope a search to the given collection/saved search rows by adding a top-level
// collection/savedSearch condition for each -- an 'any' group of them when more
// than one row is selected. If the search's own join mode is 'any', its existing
// conditions move into an 'any' group of their own so the scope conditions apply
// to every result.
_addScopeConditions(search, collectionTreeRows) {
let conditions = Object.values(search.getConditions());
// Split the existing conditions into top-level markers/flags, which are
// position-independent and stay at the top level, and the rest, which may get
// wrapped in a group below. The search always comes from updateSearch(), so
// these are the only markers/flags that can appear at the top level.
const FLAGS = ['resultLevel', 'recursive', 'includeParentsAndChildren'];
let joinMode = 'all';
let flags = [];
let rest = [];
let depth = 0;
// Number of top-level conditions and groups -- a single one doesn't need wrapping
let units = 0;
for (let condition of conditions) {
if (condition.condition == 'groupStart') {
if (!depth) {
units++;
}
depth++;
rest.push(condition);
}
else if (condition.condition == 'groupEnd') {
depth--;
rest.push(condition);
}
else if (!depth && condition.condition == 'joinMode') {
joinMode = condition.operator;
}
else if (!depth && FLAGS.includes(condition.condition)) {
flags.push(condition);
}
else {
if (!depth) {
units++;
}
rest.push(condition);
}
}
let scope = collectionTreeRows.map(row => ({
condition: row.isCollection() ? 'collection' : 'savedSearch',
operator: 'is',
value: row.ref.key
}));
// Search subcollections when the collections view does
if (Zotero.Prefs.get('recursiveCollections')
&& scope.some(c => c.condition == 'collection')
&& !flags.some(c => c.condition == 'recursive')) {
flags.push({ condition: 'recursive', operator: 'true', value: null });
}
if (scope.length > 1) {
scope = [
{ condition: 'groupStart', operator: 'true', value: '' },
{ condition: 'joinMode', operator: 'any', value: null },
...scope,
{ condition: 'groupEnd', operator: 'true', value: '' }
];
}
if (joinMode == 'any' && units > 1) {
rest = [
{ condition: 'groupStart', operator: 'true', value: '' },
{ condition: 'joinMode', operator: 'any', value: null },
...rest,
{ condition: 'groupEnd', operator: 'true', value: '' }
];
}
// Rebuild the search's conditions in the new order. removeCondition()
// renumbers the remaining conditions, so 0 is always the next one to remove.
let count = conditions.length;
for (let i = 0; i < count; i++) {
search.removeCondition(0);
}
for (let condition of [...scope, ...rest, ...flags]) {
search.addCondition(
condition.condition + (condition.mode ? '/' + condition.mode : ''),
condition.operator,
condition.value
);
}
}
focus(options) {
let menu = this._searchElem.querySelector('#conditionsmenu');
// focusVisible so the focus ring shows even though we're focusing
// programmatically (in response to a click)
menu.focus({ focusVisible: true, ...options });
}
}
customElements.define("advanced-search-pane", AdvancedSearchPane);
}

View file

@ -593,7 +593,7 @@
}
if (show) {
reindexButton.setAttribute('hidden', false);
reindexButton.removeAttribute('hidden');
}
else {
reindexButton.setAttribute('hidden', true);
@ -765,7 +765,13 @@
_handleTitleBlur = () => {
this.item.setField('title', this._id('title').value);
this.item.saveTx();
this.item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString('title'),
count: 1
}
});
};
_handleFileNameFocus = () => {

View file

@ -39,7 +39,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
<html:span class="icon"/>
<html:span class="label"/>
</html:div>
<toolbarbutton class="zotero-clicky zotero-clicky-minus" tabindex="0" data-l10n-id="section-button-remove" disabled="false">
<toolbarbutton class="zotero-clicky zotero-clicky-minus" tabindex="0" data-l10n-id="section-button-remove">
<image class="toolbarbutton-icon"/>
<label class="toolbarbutton-text" />
</toolbarbutton>

View file

@ -36,6 +36,7 @@
this._body = this.querySelector('.bubble-input.body');
this._body.addEventListener('click', this._onBodyClick.bind(this));
this._lastFocusedInput = null;
this.showJustAddedPlaceholder = false;
Utils.init(this);
DragDropHandler.init(this);
@ -123,11 +124,10 @@
if (isOnlyInput) {
document.l10n.setAttributes(this._body.firstChild, `integration-citationDialog-single-input-${dialogType}`);
}
// otherwise, add a regular aria descriptions and placeholders to all inputs
// otherwise, set default placeholders for all inputs, with special handling
// of the last input after a bubble is added with visible placeholder
else {
for (let input of [...this.querySelectorAll(".input")]) {
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
}
Utils.setupInputPlaceholders(this.showJustAddedPlaceholder, dialogType);
}
// If any two inputs end up next to each other (e.g. after bubble is deleted),
// have them merged
@ -339,10 +339,20 @@
Utils.notifyDialog("handle-input", { query: input.value, eventType: "focus" });
});
input.addEventListener("blur", async (event) => {
// When the window itself loses focus (e.g., Cmd/Alt-Tab to another app),
// the input remains focused, so keep its state unchanged
if (!document.hasFocus()) return;
// record this input as last focused if it's not empty OR if the focus left bubbleInput altogether
if (!Utils.isInputEmpty(input) || !this.contains(event.relatedTarget)) {
this._lastFocusedInput = input;
}
// Collapse a placeholder input back to regular size once focus leaves.
// The placeholder attributes are reset on the next refresh()
if (input.classList.contains("just-added-placeholder")) {
input.classList.remove("just-added-placeholder");
input.removeAttribute("title");
input.style.minWidth = "";
}
});
return input;
}
@ -604,6 +614,87 @@
return spanWidth;
},
getTextWidth(text) {
let span = document.createElement("span");
span.classList = "input";
span.innerText = text;
this.bubbleInput._body.appendChild(span);
let spanWidth = span.getBoundingClientRect().width;
span.remove();
return spanWidth;
},
// Return the longest prefix of `text` such that prefix + "…" fits within maxWidth,
// or the original text if it already fits. Used for placeholder truncation, since
// text-overflow:ellipsis doesn't work on <input> in Firefox chrome.
truncateToWidth(text, maxWidth) {
if (this.getTextWidth(text) <= maxWidth) return text;
let ellipsis = "…";
for (let i = text.length - 1; i > 0; i--) {
let candidate = text.slice(0, i) + ellipsis;
if (this.getTextWidth(candidate) <= maxWidth) {
return candidate;
}
}
return ellipsis;
},
// Set aria-descriptions and placeholders on every input in the bubble-input.
// For the just-added input (the last one, when showJustAddedPlaceholder is set), the
// placeholder is truncated to the space remaining on its line and passed to Fluent as
// a variable.
setupInputPlaceholders(showJustAddedPlaceholder, dialogType) {
let allInputs = [...this.bubbleInput.querySelectorAll(".input")];
let lastInput = allInputs[allInputs.length - 1];
let bodyRight = this.bubbleInput._body.getBoundingClientRect().right;
for (let input of allInputs) {
let isJustAdded = showJustAddedPlaceholder && input === lastInput;
if (!isJustAdded) {
// If the just-added placeholder was dismissed (e.g. by right-arrow at the
// end of the input) while the input is still focused and empty, keep the
// visible placeholder but fall back to the default search prompt, since
// searching still works. It collapses when the input loses focus (see the
// blur handler in _createInputElem())
if (input.classList.contains("just-added-placeholder")
&& document.activeElement == input && !input.value) {
let placeholder = this.truncateToWidth(
" " + Zotero.getString("integration-citationDialog-search-for-items"),
parseFloat(input.style.minWidth) || Infinity);
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title: "" });
continue;
}
input.classList.remove("just-added-placeholder");
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
// Clear any stale title and min-width left over from a previous just-added state
input.removeAttribute("title");
input.style.minWidth = "";
}
if (isJustAdded && !input.classList.contains("just-added-placeholder")) {
input.classList.add("just-added-placeholder");
// Leading NBSP gives a small visual gap between the cursor and the placeholder
// text (CSS padding/text-indent on input or ::placeholder both move the cursor too)
let fullPlaceholder = " " + Zotero.getString("integration-citationDialog-just-added-input-placeholder");
let availableWidth = bodyRight - input.getBoundingClientRect().left - 20;
let placeholderWidth = this.getTextWidth(fullPlaceholder);
let placeholder, title, minWidth;
if (availableWidth >= placeholderWidth) {
placeholder = fullPlaceholder;
title = "";
minWidth = placeholderWidth;
}
else {
placeholder = this.truncateToWidth(fullPlaceholder, availableWidth);
title = fullPlaceholder;
minWidth = availableWidth;
}
// min-width keeps the placeholder visible when the input is empty, but
// lets the input grow to fit content the user types beyond the placeholder.
input.style.minWidth = minWidth + 'px';
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title });
}
}
},
// If a bubble is removed between two inputs we need to combine them
combineNeighboringInputs(startingNode) {
let node = startingNode;

View file

@ -43,7 +43,8 @@
set open(newOpen) {
newOpen = !!newOpen;
let oldOpen = this.open;
if (oldOpen === newOpen || this.empty || !this.collapsible) return;
if (oldOpen === newOpen || this.empty) return;
if (!newOpen && !this.collapsible) return;
this.render();
// Force open before getting scrollHeight, so we get the right value
@ -118,9 +119,22 @@
this.setAttribute('no-collapse', val);
}
}
get showContextMenu() {
return !this.getAttribute("no-context-menu");
}
set showContextMenu(val) {
if (val) {
this.removeAttribute('no-context-menu');
}
else {
this.setAttribute('no-context-menu', val);
}
}
static get observedAttributes() {
return ['open', 'empty', 'label', 'summary', 'extra-buttons'];
return ['open', 'empty', 'label', 'summary', 'extra-buttons', 'no-collapse'];
}
attributeChangedCallback(name) {
@ -348,7 +362,7 @@
}
_saveOpenState() {
if (this._disableSavingOpenState) return;
if (this._disableSavingOpenState || this._skipSaveOpenState) return;
Zotero.Prefs.set(`panes.${this.dataset.pane}.open`, this.open);
}
@ -383,11 +397,11 @@
}
get _disableContextMenu() {
return !this._getSidenav() || !!this.closest('annotation-items-pane');
return !this._getSidenav() || !this.showContextMenu || !!this.closest('annotation-items-pane');
}
_handleClick = (event) => {
if (this._disableCollapsing) return;
if (this._disableCollapsing || !this.collapsible) return;
if (event.target.closest('.section-custom-button, menupopup')) return;
this.open = !this.open;
};
@ -417,7 +431,7 @@
}
// Space/Enter toggle section open/closed.
// ArrowLeft/ArrowRight on actual header will close/open (depending on locale direction)
if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key) && !this._disableCollapsing) {
if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key) && !this._disableCollapsing && this.collapsible) {
stopEvent();
this.open = ([" ", "Enter"].includes(event.key)) ? !this.open : (event.key == Zotero.arrowNextKey);
event.target.focus();
@ -480,7 +494,7 @@
this._title.textContent = this.label;
this._summary.textContent = this.summary;
let twisty = this._head.querySelector('.twisty');
twisty.hidden = this._disableCollapsing;
twisty.hidden = this._disableCollapsing || !this.collapsible;
document.l10n.setAttributes(twisty, `section-button-${this.open ? "collapse" : "expand"}`, { section: this._paneName || "" });
}
}

View file

@ -224,9 +224,9 @@
else {
this._disableNotesContext();
}
_contextPaneSplitter.setAttribute('hidden', false);
_contextPaneSplitter.removeAttribute('hidden');
_contextPane.setAttribute('collapsed', !(_contextPaneSplitter.getAttribute('state') != 'collapsed'));
_contextPane.toggleAttribute('collapsed', _contextPaneSplitter.getAttribute('state') == 'collapsed');
this._sidenav.hidden = false;

View file

@ -38,9 +38,7 @@
class EditableText extends XULElementBase {
_input;
_resizeObserver;
_ignoredWindowInactiveBlur = false;
_focusMousedownEvent = false;
@ -76,7 +74,30 @@
});
return span;
}
/**
* A single ResizeObserver shared by every EditableText in the document.
* When inputs resize, recompute the 'overflowing' state for all affected
* fields at once, batching layout reads before writes:
* toggling 'overflowing' makes the layout dirty, and measuring the width
* requires a clean layout, so interleaving the two would force a reflow
* per measurement, which adds up to multiple seconds of layout
* calculations on outlier items with thousands of creators.
*
* See {@link #batchSizeToContent()}.
*/
static _resizeObserver = new ResizeObserver((entries) => {
let editableTexts = entries
.map(entry => entry.target.closest('editable-text'))
.filter(editableText => editableText?._input);
// Read phase: measure everything first
let overflowing = editableTexts.map(editableText => editableText._isOverflowing());
// Write phase: apply the class changes in one batch
for (let i = 0; i < editableTexts.length; i++) {
editableTexts[i].classList.toggle('overflowing', overflowing[i]);
}
});
get noWrap() {
return this.hasAttribute('nowrap');
}
@ -137,6 +158,14 @@
this.setAttribute('value', value || '');
}
get values() {
return this._values ? this._values : [this.value];
}
set values(values) {
this._values = values;
}
get initialValue() {
return this._input?.dataset.initialValue ?? '';
}
@ -180,6 +209,23 @@
return this._input;
}
get multipleValues() {
return this.hasAttribute('multiple-values');
}
set multipleValues(multipleValues) {
this.toggleAttribute('multiple-values', !!multipleValues);
}
// true if the value is unchanged since the last blur event. Relevant for batch editing.
get cancelled() {
return this.hasAttribute('cancelled');
}
set cancelled(cancelled) {
this.toggleAttribute('cancelled', !!cancelled);
}
_resetTextDirection() {
this._input?.removeAttribute('dir');
}
@ -199,6 +245,27 @@
sizeToContent = () => {
this.style.maxWidth = this._getContentWidth() + 'px';
};
/**
* Size multiple elements to their content in a single batch.
*
* sizeToContent() reads layout and then updates layout styles.
* Calling it once per element interleaves those reads and writes,
* forcing a full synchronous reflow on every call. That's extremely
* slow, especially if it occurs in a loop that also adds new elements
* to the DOM on each iteration, as in the InfoBox.
* Measuring everything first and only then applying the widths collapses
* that to a single reflow regardless of how many elements are sized.
*
* @param {EditableText[]} elements
*/
static batchSizeToContent(elements) {
elements = [...elements];
let widths = elements.map(el => el._getContentWidth());
for (let i = 0; i < elements.length; i++) {
elements[i].style.maxWidth = widths[i] + 'px';
}
}
attributeChangedCallback(name) {
if (name === 'value' || name === 'dir') {
@ -211,6 +278,13 @@
this.render();
}
destroy() {
// Stop the shared observer from holding a reference to our input
if (this._input) {
EditableText._resizeObserver.unobserve(this._input);
}
}
render() {
let autocompleteParams = this.autocomplete;
let autocompleteEnabled = !this.multiline && !!autocompleteParams;
@ -245,6 +319,7 @@
this.removeEventListener('keydown', this._captureAutocompleteKeydown, true);
}
let oldInput = this._input;
let focused = this.focused;
let selectionStart = this._input?.selectionStart;
let selectionEnd = this._input?.selectionEnd;
@ -268,14 +343,22 @@
this._input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
}
this._resizeObserver?.disconnect();
if (oldInput) {
EditableText._resizeObserver.unobserve(oldInput);
}
// Only nowrap fields can overflow horizontally; textareas wrap
if (this.noWrap) {
this._resizeObserver = new ResizeObserver(this._handleInputResize);
this._resizeObserver.observe(this._input);
EditableText._resizeObserver.observe(this._input);
}
}
this._input.readOnly = this.readOnly;
this._input.placeholder = this.placeholder;
this._input.removeAttribute('tabindex');
// Read-only multiple-values fields are focusable (for keyboard access) but
// can't be edited, so keep their "Multiple" placeholder visible while focused.
// Editable fields clear it on focus (see _handleFocus), so don't reset it here.
if (!(this.multipleValues && this.focused && !this.readOnly)) {
this._input.placeholder = this.placeholder;
}
if (this._input.tagName == "textarea") {
// Reset to initial state
@ -381,6 +464,9 @@
this._ignoredWindowInactiveBlur = false;
return;
}
this.cancelled = false;
this._clearValue = false;
let valueBeforeFocus = this.value;
this.dispatchEvent(new CustomEvent('focus'));
@ -400,7 +486,17 @@
}
if (!('initialValue' in this._input.dataset)) {
this._input.dataset.initialValue = this._input.value;
this._input.dataset.initialValue = this.value;
}
if (this.multipleValues) {
if (!this.readOnly) {
this._input.placeholder = '';
}
this._input.value = '';
if (this._input.mController) {
this._input.mController.startSearch("");
}
}
};
@ -410,12 +506,22 @@
this._ignoredWindowInactiveBlur = true;
return;
}
if (this.multipleValues) {
if (this.cancelled || (this._input.value === '' && !this._clearValue)) {
this.value = '';
this.placeholder = Zotero.getString('item-pane-batch-editing-multiple-values-placeholder');
this.cancelled = true;
}
}
this.dispatchEvent(new Event('blur'));
this._resetStateAfterBlur();
};
_resetStateAfterBlur() {
this._ignoredWindowInactiveBlur = false;
this._clearValue = false;
this._focusMousedownEvent = null;
this.classList.remove('focused');
this._input.scrollLeft = 0;
@ -436,9 +542,14 @@
}
}
else if (event.key === 'Escape') {
let initialValue = this._input.dataset.initialValue ?? '';
this.setAttribute('value', initialValue);
this._input.value = initialValue;
if (this.multipleValues) {
this.cancelled = true;
}
else {
let initialValue = this._input.dataset.initialValue ?? '';
this.setAttribute('value', initialValue);
this._input.value = initialValue;
}
this._input.blur();
}
};
@ -497,10 +608,10 @@
}
};
_handleInputResize = () => {
_isOverflowing() {
// Very small floating-point-error allowance
const EPSILON = 0.001;
this.classList.toggle('overflowing',
return (
// We're overflowing if the field can scroll at least a pixel
this._input.scrollLeftMax > 0
// But sometimes it can scroll a *sub*pixel, and every single
@ -513,7 +624,7 @@
&& this._getContentWidth() > this._input.getBoundingClientRect().width + EPSILON
)
);
};
}
focus(options) {
// If the window isn't active, the focus event won't fire yet,

View file

@ -25,6 +25,7 @@
{
const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
const { isTemplateValid } = ChromeUtils.importESModule("chrome://zotero/content/modules/templates.mjs");
const DEFAULT_EXT = 'pdf';
class FileRenameSettings extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
@ -102,14 +103,21 @@
id="file-renaming-format-template"
rows="8"
/>
<html:label id="file-renaming-format-preview-label">
<html:h2
data-l10n-id="file-renaming-format-preview"
<html:div id="file-renaming-format-preview-section">
<html:label id="file-renaming-format-preview-label">
<html:h2
data-l10n-id="file-renaming-format-preview"
/>
</html:label>
<html:label
aria-labelledby="file-renaming-format-preview-label"
id="file-renaming-format-preview"
/>
</html:label>
<html:label
aria-labelledby="file-renaming-format-preview-label"
id="file-renaming-format-preview"
</html:div>
<label
id="file-renaming-template-invalid"
class="is-hidden"
data-l10n-id="file-renaming-template-invalid"
/>
</groupbox>
</vbox>
@ -176,6 +184,27 @@
this.formatTemplateTextarea.value = val;
}
get templateValid() {
return this._isTemplateValid(this.formatTemplate);
}
// Validate `template`, memoizing the last result. Normalize first so the dialog judges the
// same string the rename engine validates and renders (see Zotero.Attachments.normalizeRenameTemplate)
_isTemplateValid(template) {
let normalized = Zotero.Attachments.normalizeRenameTemplate(template);
if (normalized !== this._validatedTemplate) {
this._validatedTemplate = normalized;
this._validatedResult = this._validateTemplate(normalized);
}
return this._validatedResult;
}
// Engine parse behind an instance method, so tests can count parses (a frozen ESM
// namespace export cannot be spied directly)
_validateTemplate(normalized) {
return isTemplateValid(normalized);
}
handleChange = () => {
let autoRenameEnabled = this.autoRenameEnabled;
let enabledFileTypes = this.enabledFileTypes;
@ -195,11 +224,17 @@
handleTemplateInput = () => {
let formatString = this.formatTemplateTextarea.value;
// Ignore the empty value, which we'll reset in handleInputBlur() if necessary
// A whitespace-only value will be reset to the default on blur; preview the default
// (so any stale syntax-error message clears) but don't propagate the change yet
if (formatString.replace(/\s/g, '') === '') {
this._renderPreview(DEFAULT_ATTACHMENT_RENAME_TEMPLATE);
return;
}
this.updatePreview();
// Keep showing the previous preview while the user types through an invalid state.
// If the template is still invalid on blur, handleTemplateBlur() will show the error.
if (this.templateValid) {
this.updatePreview();
}
this.handleChange();
};
@ -210,6 +245,9 @@
this.updatePreview();
this.handleChange();
}
else {
this.updatePreview();
}
};
handleRenameToggle = () => {
@ -227,9 +265,20 @@
this.formatTemplateTextarea.readOnly = readonly;
};
// Note: also used as an onSelect listener, so it must take no positional arguments
updatePreview = () => {
this._renderPreview(this.formatTemplate);
};
_renderPreview = (formatString) => {
let valid = this._isTemplateValid(formatString);
this.previewSection.classList.toggle('is-hidden', !valid);
this.invalidMessage.classList.toggle('is-hidden', valid);
if (!valid) {
return;
}
let [item, ext, attachmentTitle] = this.getActiveItem() ?? [this.mockItem ?? this.makeMockItem(), DEFAULT_EXT, ''];
let formatString = this.formatTemplate;
let preview = Zotero.Attachments.getFileBaseNameFromItem(item, { formatString, attachmentTitle });
this.querySelector('#file-renaming-format-preview').innerText = `${preview}.${ext}`;
};
@ -242,6 +291,8 @@
this.fileTypesCheckboxes = this.querySelector('#file-renaming-file-types-box');
this.renameLinkedCheckbox = this.querySelector('#rename-linked-files');
this.formatTemplateTextarea = this.querySelector('#file-renaming-format-template');
this.previewSection = this.querySelector('#file-renaming-format-preview-section');
this.invalidMessage = this.querySelector('#file-renaming-template-invalid');
this.enabledFileTypes = this.getAttribute('file-types') ?? '';
this.autoRenameEnabled = this.getAttribute('auto-rename-enabled') === 'true';
this.renameLinkedCheckbox.checked = this.getAttribute('rename-linked-enabled') === 'true';

View file

@ -161,7 +161,7 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
// If the anchor element is inside another frame, create a proxy
// element in the parent document at the anchor's visual location
if (forEl.ownerDocument !== document) {
let iframe = forEl.ownerGlobal.frameElement;
let iframe = forEl.documentGlobal.frameElement;
if (iframe) {
let iframeRect = iframe.getBoundingClientRect();
let forElRect = forEl.getBoundingClientRect();

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,18 @@
this._item = item;
}
get extraItems() {
return this._extraItems ?? [];
}
set extraItems(val) {
if (!Array.isArray(val)) {
return;
}
this._extraItems = val.filter(item => item instanceof Zotero.Item && item.isRegularItem());
}
/*
* For contextPane update
*/
@ -96,12 +108,12 @@
this._tabID = tabID;
}
get collectionTreeRow() {
return this._collectionTreeRow;
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRow(collectionTreeRow) {
this._collectionTreeRow = collectionTreeRow;
set collectionTreeRows(collectionTreeRows) {
this._collectionTreeRows = collectionTreeRows;
}
get supportsPinning() {
@ -238,7 +250,12 @@
box.tabID = this.tabID;
box.tabType = this.tabType;
box.item = item;
box.collectionTreeRow = this.collectionTreeRow;
box.extraItems = this.extraItems;
box.collectionTreeRows = this.collectionTreeRows;
if (this.extraItems.length > 0) {
// mark everything, except the header and the info pane, as hidden
box.hidden = box.dataset.pane !== 'info' && box !== this._header;
}
// Discard hidden panes
if (box.hidden && box.discard) {
box.discard();

View file

@ -40,8 +40,11 @@
previousfocus="zotero-items-tree" />
<duplicates-merge-pane id="zotero-duplicates-merge-pane" />
<annotation-items-pane id="zotero-annotations-pane" />
<groupbox id="batch-edit-prompt" pack="center" align="center" data-l10n-id="item-pane-batch-editing-prompt">
<description id="batch-edit-prompt-message" />
<button id="batch-edit-prompt-enable" data-l10n-id="item-pane-batch-editing-enable" />
</groupbox>
</deck>
<item-pane-sidenav id="zotero-view-item-sidenav" no-context-notes="true" class="zotero-view-item-sidenav"/>
`);
@ -52,6 +55,8 @@
this._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
this._messagePane = this.querySelector("#zotero-item-message");
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
this._deck = this.querySelector("#zotero-item-pane-content");
@ -59,6 +64,14 @@
this._notifierID = Zotero.Notifier.registerObserver(this, ['item']);
this._batchEditEnableBtn.addEventListener("command", () => {
this._isBatchEditEnabled = true;
this._setBatchEditCollapsible(true);
this.render();
this.updateItemPaneButtons();
});
this._isBatchEditEnabled = false;
this._translationTarget = null;
}
@ -74,12 +87,12 @@
this._data = data;
}
get collectionTreeRow() {
return this._collectionTreeRow;
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRow(val) {
this._collectionTreeRow = val;
set collectionTreeRows(val) {
this._collectionTreeRows = val;
}
get itemsView() {
@ -100,12 +113,12 @@
}
get mode() {
return ["message", "item", "note", "duplicates"][this._deck.selectedIndex];
return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt"][this._deck.selectedIndex];
}
/**
* Set mode of item pane
* @param {"message" | "item" | "note" | "duplicates"} type view type
* @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt"} type view type
*/
set mode(type) {
this.setAttribute("view-type", type);
@ -126,10 +139,26 @@
if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
return renderStatus = this.renderAnnotations(this.data);
}
// reset the batch editing flag
let IDs = this.data.map(item => item.id);
if (!(IDs.length === this._prevIDs?.length && IDs.every((id, i) => id === this._prevIDs?.[i]))) {
if (this._isBatchEditEnabled) {
this._setBatchEditCollapsible(false);
}
this._isBatchEditEnabled = false;
this._prevIDs = IDs;
}
// Multiple items selected (not duplicates)
if (!this.collectionTreeRows[0].isDuplicates() && this.data.length > 1 && this.data.every(item => item.isRegularItem() && !item.isFeedItem)) {
// Hide the batch editing UI until the user opts-in
renderStatus = this._isBatchEditEnabled ? this.renderItemPane(this.data) : this.renderBatchEditorPrompt();
}
// Single item selected
if (this.data.length == 1) {
else if (this.data.length === 1) {
let item = this.data[0];
// If a collection or search is selected, it must be in the trash.
if (item instanceof Zotero.Collection || item instanceof Zotero.Search) {
renderStatus = this.renderMessage();
@ -141,7 +170,7 @@
renderStatus = this.renderItemPane(item);
}
}
// Zero or multiple items selected
// No items selected or multiple, but includes some irregular items
else {
renderStatus = this.renderMessage();
}
@ -150,7 +179,7 @@
notify(action, type) {
if (type == 'item' && action == 'modify') {
if (this.collectionTreeRow && this.collectionTreeRow.isFeedsOrFeed()) {
if (this.collectionTreeRows?.[0]?.isFeedsOrFeed()) {
this.updateReadLabel();
}
}
@ -175,9 +204,12 @@
return true;
}
async renderItemPane(item) {
async renderItemPane(items) {
let previousMode = this.mode;
this.mode = "item";
if (!Array.isArray(items)) {
items = [items];
}
// Fix https://forums.zotero.org/discussion/115450/zotero-7-beta-wrong-vertical-position-in-the-item-pane-after-switching-from-a-note
if (previousMode === "note") {
@ -186,20 +218,21 @@
requestIdleCallback(resolve, { timeout: 50 });
});
}
this._itemDetails.editable = this.editable;
this._itemDetails.tabID = "zotero-pane";
this._itemDetails.tabType = "library";
this._itemDetails.item = item;
this._itemDetails.collectionTreeRow = this.collectionTreeRow;
this._itemDetails.item = items[0];
this._itemDetails.extraItems = items.slice(1);
this._itemDetails.collectionTreeRows = this.collectionTreeRows;
this._itemDetails.render();
if (this.getAttribute("collapsed") == "true") {
if (this.hasAttribute("collapsed")) {
return true;
}
if (item.isFeedItem) {
if (items[0].isFeedItem) {
let lastTranslationTarget = Zotero.Prefs.get('feeds.lastTranslationTarget');
if (lastTranslationTarget) {
let id = parseInt(lastTranslationTarget.substr(1));
@ -218,7 +251,7 @@
// if (!item.isTranslated) {
// item.translate();
// }
ZoteroPane.startItemReadTimeout(item.id);
ZoteroPane.startItemReadTimeout(items[0].id);
}
return true;
}
@ -229,7 +262,7 @@
let count = this.data.length;
// Display duplicates merge interface in item pane
if (this.collectionTreeRow.isDuplicates()) {
if (this.collectionTreeRows[0].isDuplicates()) {
if (!this.editable) {
if (count) {
msg = Zotero.getString('pane.item.duplicates.writeAccessRequired');
@ -244,7 +277,7 @@
// On a Select All of more than a few items, display a row
// count instead of the usual item type mismatch error
let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.rowCount;
let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.objectRowCount;
// Initialize the merge pane with the selected items
this._duplicatesPane.setItems(this.data, displayNumItemsOnTypeError);
@ -259,7 +292,7 @@
if (count) {
let key;
// In the trash, we have to check the object type
if (this.collectionTreeRow.isTrash()) {
if (this.collectionTreeRows[0].isTrash()) {
if (this.data.every(x => x instanceof Zotero.Collection)) {
key = 'item-pane-message-collections-selected';
}
@ -279,8 +312,8 @@
msg = { l10nId: key, l10nArgs: { count } };
}
else {
let count = this.itemsView.rowCount;
if (this.collectionTreeRow.isTrash()
let count = this.itemsView.objectRowCount;
if (this.collectionTreeRows[0].isTrash()
&& this.itemsView._rows?.some(
x => x.ref instanceof Zotero.Collection || x.ref instanceof Zotero.Search
)) {
@ -298,6 +331,16 @@
return true;
}
renderBatchEditorPrompt() {
this.mode = 'batch-edit-prompt';
document.l10n.setAttributes(
this._batchEditPromptMessage,
'item-pane-message-items-selected',
{ count: this.data.length }
);
return true;
}
setItemPaneMessage(msg) {
this.mode = "message";
this._messagePane.render(msg);
@ -315,7 +358,7 @@
}
// My Publications buttons
var isPublications = this.collectionTreeRow.isPublications();
var isPublications = this.collectionTreeRows[0].isPublications();
// Show in My Publications view if selected items are all notes or non-linked-file attachments
var showMyPublicationsButtons = isPublications
&& this.data.every((item) => {
@ -331,13 +374,13 @@
// Trash button
let nonDeletedItemsSelected = this.data.some(item => !item.deleted);
if (this.collectionTreeRow.isTrash() && !nonDeletedItemsSelected) {
if (this.collectionTreeRows[0].isTrash() && !nonDeletedItemsSelected) {
container.renderCustomHead(this.renderTrashHead.bind(this));
return;
}
// Feed buttons
if (this.collectionTreeRow.isFeedsOrFeed()) {
if (this.collectionTreeRows[0].isFeedsOrFeed()) {
container.renderCustomHead(this.renderFeedHead.bind(this));
this.updateReadLabel();
return;
@ -348,6 +391,11 @@
return;
}
if (this._isBatchEditEnabled && this.data.length > 1) {
container.renderCustomHead(this.renderBatchEditHead.bind(this));
return;
}
container.renderCustomHead();
}
@ -413,7 +461,7 @@
renderAnnotationsHead(data) {
let { doc, append } = data;
let button = doc.createXULElement("button");
button.disabled = !this.collectionTreeRow.editable;
button.disabled = !this.collectionTreeRows.every(o => o.editable);
button.id = 'zotero-item-pane-note-from-annotations';
if (Zotero.Items.getTopLevel(this.data).length == 1) {
button.label = Zotero.getString('pane.items.menu.addNoteFromAnnotations');
@ -426,6 +474,28 @@
append(button);
}
renderBatchEditHead(data) {
let { doc, append } = data;
let description = doc.createXULElement("description");
document.l10n.setAttributes(
description,
'item-pane-batch-editing-header',
{ count: this.data.length }
);
let icon = doc.createElement("span");
icon.className = "batch-edit-head-icon";
let doneButton = doc.createXULElement("button");
doneButton.setAttribute("default", "true");
document.l10n.setAttributes(doneButton, 'item-pane-batch-editing-done');
doneButton.addEventListener("command", () => {
this._setBatchEditCollapsible(false);
this._isBatchEditEnabled = false;
this.render();
this.updateItemPaneButtons();
});
append(icon, description, doneButton);
}
updateReadLabel() {
var items = this.data;
var isUnread = false;
@ -535,6 +605,25 @@
this.setTranslateButton();
}
_setBatchEditCollapsible(enabled) {
let section = this._itemDetails.querySelector('collapsible-section[data-pane="info"]');
if (!section) return;
if (enabled) {
// Force open without saving to prefs, so the previous state is preserved
section._skipSaveOpenState = true;
section.open = true;
section._skipSaveOpenState = false;
section.collapsible = false;
section.showContextMenu = false;
}
else {
section.collapsible = true;
section.showContextMenu = true;
// Restore the pref-saved open state
section._restoreOpenState();
}
}
getCurrentPane(mode = undefined) {
if (!mode) {
// Guess a mode from the current data
@ -543,7 +632,7 @@
mode = "annotations";
}
// No/multiple objects are selected OR selected object is a trashed collection/search
else if (!this.data.length || this.data.length > 1
else if (!this.data.length || (this.data.length > 1 && !this._isBatchEditEnabled)
|| this.data[0] instanceof Zotero.Collection || this.data[0] instanceof Zotero.Search) {
mode = "message";
}
@ -595,7 +684,7 @@
}
handleResize() {
if (this.getAttribute("collapsed")) {
if (this.hasAttribute("collapsed")) {
this.removeAttribute("width");
this.removeAttribute("height");
}
@ -643,6 +732,10 @@
this._deck.selectedIndex = 4;
break;
}
case "batch-edit-prompt": {
this._deck.selectedIndex = 5;
break;
}
}
let isViewingItem = type == "item";
let isViewingDuplicates = type == "duplicates";

View file

@ -70,6 +70,10 @@
_editable = true;
get _renderDependencies() {
return [this._tabID, this._item?.id, this.extraItems?.length ?? 0];
}
get item() {
return this._item;
}
@ -150,8 +154,8 @@
event.preventDefault();
let menupopup = ZoteroPane.buildFieldTransformMenu({
target: this.titleField,
onTransform: (newValue) => {
this._setTransformedValue(newValue);
onTransform: (newValues) => {
this._setTransformedValue(newValues[0]);
},
});
@ -197,9 +201,15 @@
if (newValue.toLowerCase().startsWith(shortTitleVal.toLowerCase())) {
this._item.setField('shortTitle', newValue.substring(0, shortTitleVal.length));
}
await this._item.saveTx();
await this._item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
count: 1
}
});
}
async save() {
if (!this.editable) {
return;
@ -209,7 +219,13 @@
throw new Error('Item has not been added to library');
}
this._item.setField(this._titleFieldID, this.titleField.value);
await this._item.saveTx();
await this._item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
count: 1
}
});
}
this._forceRenderAll();
}
@ -231,6 +247,12 @@
if (this._item.isAttachment()) {
headerMode = 'title';
}
if (this.extraItems?.length) {
headerMode = 'none';
}
this.classList.toggle('batch-edit', !!this.extraItems?.length);
this.title.hidden = true;
this.creatorYear.hidden = true;
@ -416,7 +438,7 @@
let menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('data-l10n-id', 'item-pane-header-' + headerMode);
menuitem.setAttribute('type', 'radio');
menuitem.setAttribute('checked', headerMode === selectedMode);
menuitem.toggleAttribute('checked', headerMode === selectedMode);
menuitem.addEventListener('command', () => {
Zotero.Prefs.set(PREF_HEADER_MODE, headerMode);
});

View file

@ -59,12 +59,12 @@ export class ItemPaneSectionElementBase extends XULElementBase {
this.setAttribute('tabType', tabType);
}
get collectionTreeRow() {
return this._collectionTreeRow;
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRow(collectionTreeRow) {
this._collectionTreeRow = collectionTreeRow;
set collectionTreeRows(collectionTreeRows) {
this._collectionTreeRows = collectionTreeRows;
}
_syncRenderPending = false;

View file

@ -66,7 +66,7 @@ import { getCSSIcon } from 'components/icons';
}
get _renderDependencies() {
return [...super._renderDependencies, this.collectionTreeRow?.id];
return [...super._renderDependencies, this.collectionTreeRows?.map(o => o.id).join(',')];
}
init() {
@ -148,14 +148,17 @@ import { getCSSIcon } from 'components/icons';
Zotero.getString('pane.items.removeFromOther', [obj.name])
)) {
contextItem.removeFromCollection(obj.id);
contextItem.saveTx();
contextItem.saveTx({
undoAction: 'undo-action-remove-from-collection',
undoActionArgs: { count: 1 }
});
}
});
row.append(remove);
}
let isCurrent = this.tabType === 'library'
&& this.collectionTreeRow?.id == obj.treeViewID;
&& this.collectionTreeRows.map(o => o.id).includes(obj.treeViewID);
box.classList.toggle('current', isCurrent);
// Disable clicky if this is a context row or we're already in the library/collection it points to

View file

@ -192,7 +192,7 @@
let { id, screenX, screenY } = event.detail;
let item = Zotero.Items.get(id);
if (item) {
this.querySelector('.context-pane-list-move-to-trash').setAttribute('disabled', !this.editable);
this.querySelector('.context-pane-list-move-to-trash').toggleAttribute('disabled', !this.editable);
let popup = this.querySelector('.context-pane-list-popup');
popup.dataset.itemId = id;
popup.openPopupAtScreen(screenX, screenY, true);
@ -203,8 +203,8 @@
this._handleAddChildNotePopupClick(event);
});
this.notesList.addEventListener('add-child', (event) => {
this.querySelector('.context-pane-add-child-note').setAttribute('disabled', !this.editable);
this.querySelector('.context-pane-add-child-note-from-annotations').setAttribute('disabled', !this.editable);
this.querySelector('.context-pane-add-child-note').toggleAttribute('disabled', !this.editable);
this.querySelector('.context-pane-add-child-note-from-annotations').toggleAttribute('disabled', !this.editable);
Zotero.MenuManager.updateMenuPopup(addChildNotePopup, "notesPane/addItemNote", {
event: undefined,
getContext: () => {
@ -227,8 +227,8 @@
this._handleAddStandaloneNotePopupClick(event);
});
this.notesList.addEventListener('add-standalone', (event) => {
this.querySelector('.context-pane-add-standalone-note').setAttribute('disabled', !this.editable);
this.querySelector('.context-pane-add-standalone-note-from-annotations').setAttribute('disabled', !this.editable);
this.querySelector('.context-pane-add-standalone-note').toggleAttribute('disabled', !this.editable);
this.querySelector('.context-pane-add-standalone-note-from-annotations').toggleAttribute('disabled', !this.editable);
Zotero.MenuManager.updateMenuPopup(addStandaloneNotePopup, "notesPane/addStandaloneNote", {
event: undefined,
getContext: () => {

View file

@ -0,0 +1,385 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2026 Corporation for Digital Scholarship
Vienna, Virginia, USA
https://www.zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
"use strict";
{
// Enough of the library's tags or creators to scroll through without holding
// the list open on a one-letter prefix
const MAX_LOOKUP_VALUES = 100;
// The element scripts load on first use of their tag, so create one to get
// search-textbox defined before extending it
document.createXULElement("search-textbox");
/**
* A search box that shows the parts of a search query (see
* Zotero.SearchQuery) as they're typed: the field, operator, and value of
* each condition are colored, and everything else reads as plain text.
*
* An <input> can't render styled text, so the colored copy is a layer
* behind it, drawn from the same tokens the parser produces and kept in
* step with the input's own scrolling. The input's text is transparent, so
* what's on screen is the layer, and everything the input does otherwise --
* selection, IME, accessibility -- is untouched.
*/
class QueryTextbox extends customElements.get("search-textbox") {
connectedCallback() {
if (this.delayConnectedCallback() || this.connected) {
return;
}
super.connectedCallback();
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = "chrome://zotero/skin/query-textbox.css";
this.shadowRoot.prepend(stylesheet);
this._highlightLayer = document.createElement("div");
this._highlightLayer.className = "query-highlight";
this._highlightLayer.setAttribute("aria-hidden", "true");
this.inputField.before(this._highlightLayer);
// The layer is positioned over the input rather than laid out with
// it, so it has to follow the input's box
this._resizeObserver = new ResizeObserver(() => this._syncGeometry());
this._resizeObserver.observe(this.inputField);
this.inputField.addEventListener("input", () => {
this.updateHighlighting();
this.updateCompletions();
});
this.inputField.addEventListener("scroll", () => this._syncScroll());
// The caret moving is as much a reason to offer something else as
// the text changing
this.inputField.addEventListener("keyup", (event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
this.updateCompletions();
}
});
this.inputField.addEventListener("click", () => this.updateCompletions());
this.inputField.addEventListener("blur", () => this.hideCompletions());
// Before the base class's own Enter and Escape handling, which the
// list takes over while it's open
this.addEventListener("keydown", event => this._handleCompletionKey(event), true);
// Rendering mid-composition would replace the text being composed
this.inputField.addEventListener("compositionstart", () => {
this._composing = true;
});
this.inputField.addEventListener("compositionend", () => {
this._composing = false;
this.updateHighlighting();
this.updateCompletions();
});
this.updateHighlighting();
}
set value(val) {
super.value = val;
// Setting the value doesn't fire input
this.updateHighlighting();
this.hideCompletions();
}
get value() {
return this.inputField.value;
}
/**
* Redraw the layer from the current value
*/
updateHighlighting() {
if (!this._highlightLayer || this._composing) {
return;
}
let value = this.inputField.value;
let tokens = value ? Zotero.SearchQuery.tokenize(value) : [];
// Recognized conditions are what the coloring is for, so a query
// without any leaves the input to draw its own text
let hasCondition = tokens.some(token => token.type === 'field');
this.toggleAttribute("highlighted", hasCondition);
this._syncGeometry();
this._highlightLayer.replaceChildren();
if (hasCondition) {
for (let token of tokens) {
let span = document.createElement("span");
span.className = "query-token-" + token.type;
span.textContent = value.slice(token.start, token.end);
this._highlightLayer.append(span);
}
}
this._syncScroll();
}
/**
* Show completions for what's being typed at the caret, or hide the
* list if there's nothing to offer (see
* Zotero.SearchQuery.getCompletions())
*/
async updateCompletions() {
if (this._composing) {
return;
}
let value = this.inputField.value;
let completions = value
? Zotero.SearchQuery.getCompletions(value, this.inputField.selectionStart)
: null;
// A query that changes while a lookup is in flight makes its results
// obsolete
let generation = this._completionGeneration = (this._completionGeneration || 0) + 1;
if (completions && completions.lookup) {
completions = {
...completions,
completions: await this._lookupValues(completions)
};
if (generation !== this._completionGeneration) {
return;
}
}
if (!completions || !completions.completions.length) {
this.hideCompletions();
return;
}
this._completions = completions;
// The getter builds the list along with the popup that holds it
let popup = this._completionPopup;
let list = this._completionList;
list.replaceChildren();
for (let completion of completions.completions) {
let row = document.createXULElement("richlistitem");
let label = document.createElement("span");
label.className = "completion-label";
if (completion.color) {
let swatch = document.createElement("span");
swatch.className = "completion-swatch";
swatch.style.backgroundColor = completion.color;
label.append(swatch);
label.classList.add("colored");
}
label.append(completion.label);
row.append(label);
// The description explains what a name maps to ("by:" is the
// Creator condition), so a name that already says it doesn't
// need it: "attachment tag:" is self-evidently about tags
if (completion.description && !completion.label.replace(/:$/, '').toLowerCase()
.includes(completion.description.toLowerCase())) {
let description = document.createElement("span");
description.className = "completion-description";
description.textContent = completion.description;
row.append(description);
}
row.completion = completion;
list.append(row);
}
// Nothing is selected until the user moves into the list, so Enter
// runs the search it would have run
list.clearSelection();
if (popup.state === "closed") {
popup.openPopup(this.inputField, "after_start");
}
}
// Values a condition takes that come from the library, from the same
// search the Advanced Search fields use
_lookupValues({ lookup, prefix }) {
let params = { ...lookup };
// Values from every library the search covers
let libraryIDs = Zotero.getActiveZoteroPane()?.getSelectedLibraryIDs() || [];
if (libraryIDs.length) {
params.libraryIDs = libraryIDs;
}
// A tag with an assigned color shows it
let colors = new Map();
if (lookup.fieldName === 'tag') {
for (let id of libraryIDs.length ? libraryIDs : [Zotero.Libraries.userLibraryID]) {
for (let [name, data] of Zotero.Tags.getColors(id)) {
if (!colors.has(name)) {
colors.set(name, data.color);
}
}
}
}
let search = Cc["@mozilla.org/autocomplete/search;1?name=zotero"]
.createInstance(Ci.nsIAutoCompleteSearch);
return new Promise((resolve) => {
search.startSearch(prefix, JSON.stringify(params), null, {
onSearchResult: (_, result) => {
// Results arrive in batches as the query runs
if (result.searchResult === Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING
|| result.searchResult
=== Ci.nsIAutoCompleteResult.RESULT_NOMATCH_ONGOING) {
return;
}
let values = [];
for (let i = 0; i < result.matchCount && i < MAX_LOOKUP_VALUES; i++) {
values.push(result.getValueAt(i));
}
resolve(values.map(value => ({
text: Zotero.SearchQuery.formatValue(value),
label: value,
color: colors.get(value)
})));
}
});
});
}
hideCompletions() {
// Results from a lookup still in flight would reopen the list
this._completionGeneration = (this._completionGeneration || 0) + 1;
if (this._popup && this._popup.state !== "closed") {
this._popup.hidePopup();
}
}
get _completionPopup() {
if (!this._popup) {
this._popup = document.createXULElement("panel");
this._popup.className = "query-completions";
this._popup.setAttribute("noautofocus", "true");
this._popup.setAttribute("ignorekeys", "true");
this._popup.setAttribute("consumeoutsideclicks", "never");
this._completionList = document.createXULElement("richlistbox");
// On mousedown rather than click: focus never leaves the
// input, and the blur that clicking would cause won't close
// the list before the click can land
this._completionList.addEventListener("mousedown", (event) => {
let row = event.button === 0 && event.target.closest("richlistitem");
if (row) {
event.preventDefault();
this._acceptCompletion(row);
}
});
this._popup.append(this._completionList);
// A popup in a shadow root gets none of the window's styles, so
// it goes in the document alongside the other popups
let popupset = document.querySelector("popupset");
(popupset || document.documentElement).append(this._popup);
}
return this._popup;
}
// Replace what's being typed with the given or selected completion,
// leaving the caret after it so the next part can be typed or
// completed in turn
_acceptCompletion(row) {
row = row || this._completionList.selectedItem || this._completionList.firstChild;
if (!row || !this._completions) {
return false;
}
let { type, start, end } = this._completions;
let value = this.inputField.value;
this.inputField.value = value.slice(0, start) + row.completion.text + value.slice(end);
let caret = start + row.completion.text.length;
this.inputField.setSelectionRange(caret, caret);
this.hideCompletions();
// Search and redraw as though it had been typed, and offer what
// comes next -- the values of the condition just completed
this.inputField.dispatchEvent(new Event("input", { bubbles: true }));
// A completed value finishes the clause: there's nothing to offer
// next, so don't reopen the list with the value itself
if (type === 'value') {
this.hideCompletions();
}
return true;
}
_handleCompletionKey(event) {
if (!this._popup || this._popup.state === "closed" || event.altKey) {
return;
}
let list = this._completionList;
switch (event.key) {
case "ArrowDown":
case "ArrowUp": {
let rows = list.itemCount;
let index = list.selectedIndex;
list.selectedIndex = event.key === "ArrowDown"
? (index + 1) % rows
: (index <= 0 ? rows - 1 : index - 1);
// Moving the selection with the list unfocused doesn't
// scroll it into view
list.ensureIndexIsVisible(list.selectedIndex);
break;
}
case "Enter":
// Only once the user has moved into the list -- otherwise
// Enter is the search it has always been, with the list
// out of its way
if (list.selectedIndex === -1 || !this._acceptCompletion()) {
this.hideCompletions();
return;
}
break;
case "Tab":
if (!this._acceptCompletion()) {
return;
}
break;
case "Escape":
this.hideCompletions();
break;
default:
return;
}
event.preventDefault();
event.stopPropagation();
}
disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
this._popup?.remove();
}
_syncScroll() {
if (this._highlightLayer) {
this._highlightLayer.scrollLeft = this.inputField.scrollLeft;
}
}
// Match the input's box and text metrics, so the two render in the
// same place
_syncGeometry() {
if (!this._highlightLayer) {
return;
}
let input = this.inputField;
let style = window.getComputedStyle(input);
let layer = this._highlightLayer.style;
layer.left = input.offsetLeft + "px";
layer.top = input.offsetTop + "px";
layer.width = input.offsetWidth + "px";
layer.height = input.offsetHeight + "px";
for (let property of ['paddingInlineStart', 'paddingInlineEnd', 'paddingTop',
'paddingBottom', 'font', 'letterSpacing', 'textIndent']) {
layer[property] = style[property];
}
this._syncScroll();
}
}
customElements.define("query-textbox", QueryTextbox);
}

View file

@ -33,8 +33,15 @@
this.searchTextbox = null;
MozXULElement.insertFTLIfNeeded("zotero.ftl");
this.content = MozXULElement.parseXULToFragment(`
<hbox id="search-wrapper" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
</hbox>
<deck id="search-deck">
<hbox id="search-wrapper">
</hbox>
<hbox id="advanced-search-indicator">
<label id="advanced-search-label" crop="end"/>
<toolbarbutton class="zotero-clicky advanced-collapse-button" tabindex="0"/>
<toolbarbutton class="zotero-clicky advanced-close-button" data-l10n-id="advanced-search-close" tabindex="0"/>
</hbox>
</deck>
`, ['chrome://zotero/locale/zotero.dtd']);
}
@ -86,7 +93,7 @@
dropmarkerShadow.append(s1, s2, dropmarker);
let searchBox = document.createXULElement("search-textbox");
let searchBox = document.createXULElement("query-textbox");
searchBox.id = "zotero-tb-search-textbox";
// Enable applying styles to the input field
searchBox.inputField.setAttribute("part", "search-input");
@ -95,6 +102,43 @@
wrapper.appendChild(dropmarkerHost);
wrapper.appendChild(searchBox);
// Add Advanced Search button at the end of the field in main window
if (document.documentElement.getAttribute('windowtype') === 'navigator:browser') {
let advancedButton = document.createXULElement('toolbarbutton');
advancedButton.id = 'zotero-tb-search-advanced-button';
advancedButton.tabIndex = -1;
document.l10n.setAttributes(advancedButton, 'quicksearch-advanced-search-button');
advancedButton.addEventListener('command', (event) => {
// Don't trigger a quick search via the oncommand handler
event.stopPropagation();
// If there's text in the field, seed the Advanced Search with it,
// reproducing the current quick search mode as editable conditions.
let mode = Zotero.Prefs.get('search.quicksearch-mode');
if (this.value) {
ZoteroPane.openAdvancedSearchFromQuickSearch(this.value, mode);
}
else {
ZoteroPane.toggleAdvancedSearchState('open');
}
});
wrapper.appendChild(advancedButton);
}
this.deck = this.firstElementChild;
this.querySelector('.advanced-collapse-button').addEventListener('command', (event) => {
event.stopPropagation();
ZoteroPane.toggleAdvancedSearchState('collapsed');
});
this.querySelector('.advanced-close-button').addEventListener('command', (event) => {
event.stopPropagation();
// if activated via the keyboard, move focus to the Advanced Search button
let triggeredByKeyboard = [MouseEvent.MOZ_SOURCE_KEYBOARD, MouseEvent.MOZ_SOURCE_UNKNOWN].includes(event.inputSource);
ZoteroPane.toggleAdvancedSearchState('closed');
if (triggeredByKeyboard) {
this.querySelector('#zotero-tb-search-advanced-button').focus();
}
});
// If Alt-Up/Down, show popup
this.addEventListener('keypress', (event) => {
@ -132,20 +176,13 @@
popup.append(item);
}
// Add Advanced Search menu item in main window
if (document.documentElement.getAttribute('windowtype') === 'navigator:browser') {
let separator = document.createXULElement('menuseparator');
popup.append(separator);
let advancedSearchOption = document.createXULElement('menuitem');
advancedSearchOption.label = Zotero.getString("zotero.toolbar.advancedSearch");
advancedSearchOption.addEventListener("command", () => {
ZoteroPane.openAdvancedSearchWindow();
});
popup.append(advancedSearchOption);
}
return this._searchModePopup = popup;
}
onCollectionSelected() {
this.searchTextbox.value = '';
this.updateMode();
}
updateMode() {
let mode = Zotero.Prefs.get("search.quicksearch-mode");
@ -158,6 +195,34 @@
this.searchModePopup.querySelector(`menuitem[value="${mode}"]`)
.setAttribute('checked', 'true');
document.l10n.setAttributes(this.searchTextbox.inputField, "quicksearch-input", { placeholder: this._searchModes[mode] });
let advancedSearchDeck = document.getElementById('zotero-advanced-search-pane-deck');
if (advancedSearchDeck) {
let state = advancedSearchDeck.state;
let selectedSearchType = advancedSearchDeck.selectedSearchType;
document.l10n.setAttributes(
this.querySelector('#advanced-search-label'),
selectedSearchType === 'temporary' ? 'advanced-search' : 'edit-saved-search',
);
this.deck.selectedIndex = state === 'closed' ? 0 : 1;
this.toggleAttribute('advanced-search-open', state !== 'closed');
this.querySelector('#advanced-search-indicator').dataset.collapsed = state === 'collapsed';
this.querySelector('.advanced-collapse-button').hidden = selectedSearchType === 'saved';
document.l10n.setAttributes(
this.querySelector('.advanced-collapse-button'),
state === 'collapsed' ? 'advanced-search-expand' : 'advanced-search-collapse'
);
}
}
focus(options) {
if (this.deck.selectedIndex === 0) {
this._searchModePopup.flattenedTreeParentNode.focus(options);
}
else {
Services.focus.moveFocus(window, this.deck.selectedPanel, Services.focus.MOVEFOCUS_FORWARD, 0);
}
}
_id(id) {

View file

@ -178,6 +178,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
return;
}
await Zotero.DB.executeTransaction(async () => {
Zotero.UndoHistory.stageAction('undo-action-add-related');
for (let relItem of relItems) {
if (this._item.addRelatedItem(relItem)) {
await this._item.save({
@ -197,6 +198,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
let item = await Zotero.Items.getAsync(id);
if (item) {
await Zotero.DB.executeTransaction(async () => {
Zotero.UndoHistory.stageAction('undo-action-remove-related');
if (this._item.removeRelatedItem(item)) {
await this._item.save({
skipDateModifiedUpdate: true

View file

@ -37,7 +37,7 @@
connectedCallback() {
this._checkbox = document.createXULElement('checkbox');
this._checkbox.setAttribute('native', 'true');
this._checkbox.setAttribute('checked', this.checked);
this._checkbox.toggleAttribute('checked', this.checked);
this._checkbox.addEventListener('focus', () => this.control.focus());
this._label = document.createElement('label');
this._label.textContent = this.label;
@ -75,12 +75,12 @@
}
get checked() {
return JSON.parse(this.getAttribute('checked'));
return this.hasAttribute('checked');
}
set checked(val) {
this._checkbox.setAttribute('checked', !!val);
this.setAttribute('checked', !!val);
this._checkbox.toggleAttribute('checked', !!val);
this.toggleAttribute('checked', !!val);
}
}

View file

@ -0,0 +1,264 @@
// Adapted from Mozilla toolkit/content/widgets/search-textbox.js, removed in Firefox 153
// Modified to not set aria-autocomplete, which confuses screen readers
// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
{
class MozSearchTextbox extends MozXULElement {
constructor() {
super();
MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl");
this.inputField = document.createElement("input");
const METHODS = [
"focus",
"blur",
"select",
"setUserInput",
"setSelectionRange",
];
for (const method of METHODS) {
this[method] = (...args) => this.inputField[method](...args);
}
const READ_WRITE_PROPERTIES = [
"defaultValue",
"placeholder",
"readOnly",
"size",
"selectionStart",
"selectionEnd",
];
for (const property of READ_WRITE_PROPERTIES) {
Object.defineProperty(this, property, {
enumerable: true,
get() {
return this.inputField[property];
},
set(val) {
this.inputField[property] = val;
},
});
}
this.attachShadow({ mode: "open" });
this.addEventListener("input", this);
this.addEventListener("keypress", this);
this.addEventListener("mousedown", this);
}
static get inheritedAttributes() {
return {
input:
"value,maxlength,disabled,size,readonly,placeholder,tabindex,accesskey,inputmode,spellcheck",
".textbox-search-icon": "label=searchbuttonlabel,disabled",
".textbox-search-clear": "disabled",
};
}
connectedCallback() {
if (this.delayConnectedCallback() || this.connected) {
return;
}
document.l10n.connectRoot(this.shadowRoot);
this.connected = true;
this.textContent = "";
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = "chrome://zotero/skin/search-textbox.css";
const textboxSign = document.createXULElement("image");
textboxSign.className = "textbox-search-sign";
textboxSign.part = "search-sign";
const input = this.inputField;
input.setAttribute("inputmode", "search");
input.autocomplete = "off"; // not applicable in XUL docs and confuses aria.
input.addEventListener("focus", this);
input.addEventListener("blur", this);
const searchBtn = (this._searchButtonIcon =
document.createXULElement("image"));
searchBtn.className = "textbox-search-icon";
searchBtn.addEventListener("click", e => this._iconClick(e));
const clearBtn = document.createXULElement("image");
clearBtn.className = "textbox-search-clear";
clearBtn.part = "clear-icon";
clearBtn.setAttribute("role", "button");
document.l10n.setAttributes(
clearBtn,
"text-action-search-text-box-clear"
);
clearBtn.addEventListener("click", () => this._clearSearch());
const deck = (this._searchIcons = document.createXULElement("deck"));
deck.className = "textbox-search-icons";
deck.append(searchBtn, clearBtn);
this.shadowRoot.append(stylesheet, textboxSign, input, deck);
this._timer = null;
// Ensure the button state is up to date:
// eslint-disable-next-line no-self-assign
this.searchButton = this.searchButton;
this.initializeAttributeInheritance();
}
disconnectedCallback() {
document.l10n.disconnectRoot(this.shadowRoot);
}
set timeout(val) {
this.setAttribute("timeout", val);
}
get timeout() {
return parseInt(this.getAttribute("timeout")) || 500;
}
set searchButton(val) {
if (val) {
this.setAttribute("searchbutton", "true");
this.inputField.removeAttribute("aria-autocomplete");
this._searchButtonIcon.setAttribute("role", "button");
} else {
this.removeAttribute("searchbutton");
this._searchButtonIcon.setAttribute("role", "none");
}
}
get searchButton() {
return this.getAttribute("searchbutton") == "true";
}
set value(val) {
this.inputField.value = val;
if (val) {
this._searchIcons.selectedIndex = this.searchButton ? 0 : 1;
} else {
this._searchIcons.selectedIndex = 0;
}
if (this._timer) {
clearTimeout(this._timer);
}
}
get value() {
return this.inputField.value;
}
get editor() {
return this.inputField.editor;
}
set disabled(val) {
this.inputField.disabled = val;
if (val) {
this.setAttribute("disabled", "true");
} else {
this.removeAttribute("disabled");
}
}
get disabled() {
return this.inputField.disabled;
}
on_blur() {
this.removeAttribute("focused");
}
on_focus() {
this.setAttribute("focused", "true");
}
on_input() {
if (this.searchButton) {
this._searchIcons.selectedIndex = 0;
return;
}
if (this._timer) {
clearTimeout(this._timer);
}
this._timer =
this.timeout && setTimeout(this._fireCommand, this.timeout, this);
this._searchIcons.selectedIndex = this.value ? 1 : 0;
}
on_keypress(event) {
switch (event.keyCode) {
case KeyEvent.DOM_VK_ESCAPE:
if (this._clearSearch()) {
event.preventDefault();
event.stopPropagation();
}
break;
case KeyEvent.DOM_VK_RETURN:
this._enterSearch();
event.preventDefault();
event.stopPropagation();
break;
}
}
on_mousedown() {
if (!this.hasAttribute("focused")) {
this.setSelectionRange(0, 0);
this.focus();
}
}
_fireCommand(me) {
if (me._timer) {
clearTimeout(me._timer);
}
me._timer = null;
me.doCommand();
}
_iconClick() {
if (this.searchButton) {
this._enterSearch();
} else {
this.focus();
}
}
_enterSearch() {
if (this.disabled) {
return;
}
if (this.searchButton && this.value && !this.readOnly) {
this._searchIcons.selectedIndex = 1;
}
this._fireCommand(this);
}
_clearSearch() {
if (!this.disabled && !this.readOnly && this.value) {
this.value = "";
this._fireCommand(this);
this._searchIcons.selectedIndex = 0;
return true;
}
return false;
}
}
customElements.define("search-textbox", MozSearchTextbox);
}

View file

@ -144,9 +144,12 @@
val = '';
}
val = Zotero.Styles.resolveLocale(val);
this._value = val;
const styleData = this._style ? Zotero.Styles.get(this._style) : null;
this.localeListEl.value = styleData && styleData.effectiveLocale || this._value;
this.localeListEl.value = Zotero.Styles.resolveLocale(
styleData && styleData.effectiveLocale || this._value
);
}
get style() {
@ -157,7 +160,9 @@
this._style = style;
const styleData = style ? Zotero.Styles.get(style) : null;
this.localeListEl.disabled = !style || !!styleData.effectiveLocale;
this.localeListEl.value = styleData && styleData.effectiveLocale || this._value || this.fallbackLocale;
this.localeListEl.value = Zotero.Styles.resolveLocale(
styleData && styleData.effectiveLocale || this._value || this.fallbackLocale
);
}
connectedCallback() {
@ -171,7 +176,7 @@
this._value = this.getAttribute('value');
await Zotero.Styles.init();
this.fallbackLocale = Zotero.Styles?.primaryDialects[Zotero.locale] || Zotero.locale;
this.fallbackLocale = Zotero.Styles.resolveLocale(Zotero.locale);
const menuLocales = Zotero.Utilities.deepCopy(Zotero.Styles.locales);
const menuLocalesKeys = Object.keys(menuLocales).sort();

View file

@ -239,13 +239,14 @@
// "-" button
if (this.editable) {
remove.setAttribute('disabled', false);
remove.removeAttribute('disabled');
remove.addEventListener('click', async (_event) => {
if (tagData) {
let item = this.item;
this.remove(tagName);
try {
item.removeTag(tagName);
this._pendingRemovalCount++;
// Save item after a debounce to avoid triggering multiple
// save operations. If there are many tags in the library,
// db transaction may not keep up with UI changes, and cause
@ -466,7 +467,7 @@
this.add(value);
try {
this.item.replaceTag(oldValue, value);
await this.item.saveTx();
await this.item.saveTx({ undoAction: 'undo-action-change-tag' });
}
catch (e) {
this._forceRenderAll();
@ -483,7 +484,10 @@
nextRowElem?.focus();
}
this.item.removeTag(oldValue);
await this.item.saveTx();
await this.item.saveTx({
undoAction: 'undo-action-remove-tag',
undoActionArgs: { count: 1 }
});
}
catch (e) {
this._forceRenderAll();
@ -503,7 +507,10 @@
}
tags.forEach(tag => this.item.addTag(tag));
await this.item.saveTx();
await this.item.saveTx({
undoAction: 'undo-action-add-tag',
undoActionArgs: { count: 1 }
});
}
// Single tag at end
else {
@ -518,7 +525,10 @@
this.add(value);
this.item.addTag(value);
try {
await this.item.saveTx();
await this.item.saveTx({
undoAction: 'undo-action-add-tag',
undoActionArgs: { count: 1 }
});
}
catch (e) {
this._forceRenderAll();
@ -602,7 +612,9 @@
removeAll = () => {
if (Services.prompt.confirm(null, "", Zotero.getString('pane.item.tags.removeAll'))) {
this.item.setTags([]);
this.item.saveTx();
this.item.saveTx({
undoAction: 'undo-action-remove-all-tags'
});
}
};
@ -652,8 +664,15 @@
}
}
_pendingRemovalCount = 0;
_saveItemDebounced = Zotero.Utilities.debounce(async (item) => {
await item.saveTx();
let count = this._pendingRemovalCount;
this._pendingRemovalCount = 0;
await item.saveTx({
undoAction: 'undo-action-remove-tags-from-item',
undoActionArgs: { count }
});
});
_id(id) {

View file

@ -30,11 +30,11 @@
* @returns {boolean}
*/
export function isPaneCollapsed(pane) {
let collapsibleParent = pane.closest('splitter:not([hidden="true"]) + *');
let collapsibleParent = pane.closest('splitter:not([hidden]) + *');
if (!collapsibleParent) {
return false;
}
return collapsibleParent.getAttribute('collapsed') === 'true';
return collapsibleParent.hasAttribute('collapsed');
}
/**
@ -44,7 +44,7 @@ export function isPaneCollapsed(pane) {
* @param {boolean} collapsed
*/
export function setPaneCollapsed(pane, collapsed) {
let collapsibleParent = pane.closest('splitter:not([hidden="true"]) + *');
let collapsibleParent = pane.closest('splitter:not([hidden]) + *');
if (!collapsibleParent) {
return;
}

File diff suppressed because it is too large Load diff

View file

@ -40,7 +40,7 @@ ChromeUtils.defineESModuleGetters(globalThis, {
**/
var Zotero_File_Exporter = function () {
this.name = Zotero.getString("fileInterface.exportedItems");
this.collection = false;
this.collections = false;
this.items = false;
}
@ -131,9 +131,10 @@ Zotero_File_Exporter.prototype.save = async function () {
return;
}
if(this.collection) {
translation.setCollection(this.collection);
} else if(this.items) {
if (this.collections) {
translation.setCollections(this.collections);
}
else if (this.items) {
translation.setItems(this.items);
} else if(this.libraryID === undefined) {
throw new Error('No export configured');
@ -190,8 +191,8 @@ var Zotero_File_Interface = new function () {
*/
this.exportFile = async function () {
var exporter = new Zotero_File_Exporter();
exporter.libraryID = ZoteroPane_Local.getSelectedLibraryID();
if (exporter.libraryID === false) {
exporter.libraryID = ZoteroPane_Local.getSelectedLibraryIDs()[0];
if (exporter.libraryID === undefined) {
throw new Error('No library selected');
}
exporter.name = Zotero.Libraries.getName(exporter.libraryID);
@ -201,22 +202,31 @@ var Zotero_File_Interface = new function () {
/*
* exports a collection or saved search
*/
function exportCollection() {
async function exportCollection() {
var exporter = new Zotero_File_Exporter();
var collection = ZoteroPane_Local.getSelectedCollection();
if(collection) {
exporter.name = collection.getName();
exporter.collection = collection;
} else {
var collections = ZoteroPane_Local.getSelectedCollections();
var searches = ZoteroPane_Local.getSelectedSavedSearches();
if (collections.length && !searches.length) {
exporter.name = collections.map(c => c.getName()).join(', ');
exporter.collections = collections;
}
else if (collections.length) {
// Saved searches can't be exported as collections, so export all the items shown
exporter.name = collections.map(c => c.getName())
.concat(searches.map(s => s.name))
.join(', ');
exporter.items = await ZoteroPane.getUnfilteredItems();
if (!exporter.items.length) throw ("No items to save");
}
else {
// find sorted items
exporter.items = ZoteroPane_Local.getSortedItems();
if(!exporter.items) throw ("No items to save");
if (!exporter.items) throw ("No items to save");
// find name
var search = ZoteroPane_Local.getSelectedSavedSearch();
if(search) {
exporter.name = search.name;
if (searches.length) {
exporter.name = searches.map(s => s.name).join(', ');
}
}
exporter.save();
@ -378,7 +388,7 @@ var Zotero_File_Interface = new function () {
var libraryID = Zotero.Libraries.userLibraryID;
try {
let zp = Zotero.getActiveZoteroPane();
libraryID = zp.getSelectedLibraryID();
libraryID = zp.getSelectedLibraryIDs()[0];
}
catch (e) {
Zotero.logError(e);
@ -405,6 +415,7 @@ var Zotero_File_Interface = new function () {
* @param {Function} [options.onBeforeImport] - Callback to receive translation object, useful
* for displaying progress in a different way. This also causes an error to be throw
* instead of shown in the main window.
* @param {AbortSignal} [options.signal] - Signal to cancel the import before it begins
*/
this.importFile = async function (options = {}) {
if (!options) {
@ -423,6 +434,7 @@ var Zotero_File_Interface = new function () {
var addToLibraryRoot = options.addToLibraryRoot;
var linkFiles = options.linkFiles;
var onBeforeImport = options.onBeforeImport;
var signal = options.signal;
if (createNewCollection === undefined && !addToLibraryRoot) {
createNewCollection = true;
@ -442,6 +454,7 @@ var Zotero_File_Interface = new function () {
var defaultNewCollectionPrefix = Zotero.getString("fileInterface.imported");
var translation;
var tmpDirectory;
if (options.mendeleyAuth || options.mendeleyCode) {
translation = await _getMendeleyTranslation();
@ -477,8 +490,16 @@ var Zotero_File_Interface = new function () {
}
else if (file.path.endsWith('@www.mendeley.com.sqlite')
|| file.path.endsWith('online.sqlite')) {
// Keep in sync with importWizard.js
throw new Error('Encrypted Mendeley database');
let decrypted = await _decryptMendeleyDatabase(file.path);
tmpDirectory = decrypted.tmpDirectory;
translation = await _getMendeleyTranslation();
translation.createNewCollection = createNewCollection;
defaultNewCollectionPrefix = Zotero.getString(
'fileInterface.appImportCollection', 'Mendeley'
);
// Attachments are looked for beside the selected database, not the copy
translation.sourceDirectory = PathUtils.parent(file.path);
file = Zotero.File.pathToFile(decrypted.path);
}
if (!translation) {
@ -487,14 +508,33 @@ var Zotero_File_Interface = new function () {
translation.setLocation(file);
}
return _finishImport({
translation,
createNewCollection,
addToLibraryRoot,
linkFiles,
defaultNewCollectionPrefix,
onBeforeImport
});
try {
// Cancelled while the file was being prepared, before there was a
// translation to interrupt
if (signal?.aborted) {
return false;
}
return await _finishImport({
translation,
createNewCollection,
addToLibraryRoot,
linkFiles,
defaultNewCollectionPrefix,
onBeforeImport
});
}
finally {
if (tmpDirectory) {
try {
Zotero.debug(`Removing decrypted Mendeley database in ${tmpDirectory}`);
await IOUtils.remove(tmpDirectory, { recursive: true, ignoreAbsent: true });
}
catch (e) {
Zotero.logError(e);
}
}
}
};
@ -581,15 +621,15 @@ var Zotero_File_Interface = new function () {
}
var libraryID = Zotero.Libraries.userLibraryID;
var importCollection = null;
var importCollections = [];
try {
let zp = Zotero.getActiveZoteroPane();
libraryID = zp.getSelectedLibraryID();
libraryID = zp.getSelectedLibraryIDs()[0];
if (addToLibraryRoot) {
await zp.collectionsView.selectLibrary(libraryID);
}
else if (!createNewCollection) {
importCollection = zp.getSelectedCollection();
importCollections = zp.getSelectedCollections();
}
}
catch (e) {
@ -614,10 +654,11 @@ var Zotero_File_Interface = new function () {
else {
collectionName = defaultNewCollectionPrefix + " " + (new Date()).toLocaleString();
}
importCollection = new Zotero.Collection;
let importCollection = new Zotero.Collection;
importCollection.libraryID = libraryID;
importCollection.name = collectionName;
await importCollection.saveTx();
importCollections = [importCollection];
}
translation.setTranslator(translators[0]);
@ -650,7 +691,8 @@ var Zotero_File_Interface = new function () {
try {
await translation.translate({
libraryID,
collections: importCollection ? [importCollection.id] : null,
collections: importCollections.length ? importCollections.map(c => c.id) : null,
autoCreatedCollectionID: createNewCollection ? importCollections[0].id : null,
linkFiles,
saveOptions: {
notifierQueue
@ -702,22 +744,46 @@ var Zotero_File_Interface = new function () {
};
/**
* Decrypt a Mendeley database into a temporary directory
*
* importFile() removes the directory once the import has ended, so that an
* unencrypted copy of the user's library isn't left behind.
*
* @param {String} path - Encrypted Mendeley database
* @return {Promise<Object>} - Path of the decrypted database and of the
* temporary directory containing it
*/
var _decryptMendeleyDatabase = async function (path) {
let { decryptDatabase, isDatabaseInUse } = ChromeUtils.importESModule(
"chrome://zotero/content/import/mendeley/mendeleyDecrypt.mjs"
);
// The database can only be read once Mendeley has written out its
// write-ahead log, which it does when it closes
if (await isDatabaseInUse(path)) {
// Keep in sync with importWizard.js
throw new Error('Mendeley database in use');
}
let tmpDirectory = PathUtils.join(
Zotero.getTempDirectory().path, 'mendeley-' + Zotero.Utilities.randomString()
);
// The decrypted database is readable without a password, so on Unix keep it
// out of reach of other users on the machine
await Zotero.File.createDirectoryIfMissingAsync(tmpDirectory, { unixMode: 0o700 });
try {
let decryptedPath = PathUtils.join(tmpDirectory, PathUtils.filename(path));
await decryptDatabase(path, decryptedPath);
return { path: decryptedPath, tmpDirectory };
}
catch (e) {
await IOUtils.remove(tmpDirectory, { recursive: true, ignoreAbsent: true });
throw e;
}
};
var _getMendeleyTranslation = async function () {
let Zotero_Import_Mendeley;
if (true) {
({ Zotero_Import_Mendeley } = ChromeUtils.importESModule("chrome://zotero/content/import/mendeley/mendeleyImport.mjs"));
}
// TEMP: Load uncached from ~/zotero-client for development
else {
const { FileUtils } = ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs");
let file = FileUtils.getDir("Home", []);
file = OS.Path.join(
file.path,
'zotero-client', 'chrome', 'content', 'zotero', 'import', 'mendeley', 'mendeleyImport.mjs'
);
let fileURI = OS.Path.toFileURI(file);
({ Zotero_Import_Mendeley } = ChromeUtils.importESModule(fileURI));
}
let { Zotero_Import_Mendeley } = ChromeUtils.importESModule("chrome://zotero/content/import/mendeley/mendeleyImport.mjs");
return new Zotero_Import_Mendeley();
};
@ -726,18 +792,18 @@ var Zotero_File_Interface = new function () {
* Creates a bibliography from a collection or saved search
*/
this.bibliographyFromCollection = async function () {
var items = ZoteroPane.getSortedItems();
var items = await ZoteroPane.getUnfilteredItems();
// Find collection name
var name = false;
var collection = ZoteroPane.getSelectedCollection();
if (collection) {
name = collection.name;
var collections = ZoteroPane.getSelectedCollections();
if (collections.length) {
name = collections.map(c => c.name).join(', ');
}
else {
let search = ZoteroPane.getSelectedSavedSearch();
if (search) {
name = search.name;
let searches = ZoteroPane.getSelectedSavedSearches();
if (searches.length) {
name = searches.map(s => s.name).join(', ');
}
}
@ -805,7 +871,6 @@ var Zotero_File_Interface = new function () {
output = Zotero.Cite.makeFormattedBibliographyOrCitationList(cslEngine, items, 'text');
}
}
cslEngine.free();
var str = Components.classes["@mozilla.org/supports-string;1"].
createInstance(Components.interfaces.nsISupportsString);

View file

@ -82,7 +82,7 @@ var FileRenamingDialog = { // eslint-disable-line no-unused-vars
let library = Zotero.Libraries.get(this._currentLibraryID);
let isAdmin = this.isUserLibrary || library.isAdmin;
this.renameFilesBtn.hidden = !isAdmin;
this.renameFilesBtn.disabled = !autoRenameEnabled || this._isRenameFilesBtnDisabled();
this.renameFilesBtn.disabled = !autoRenameEnabled || !this.settingsEl.templateValid || this._isRenameFilesBtnDisabled();
},
_isRenameFilesBtnDisabled: function () {
@ -103,7 +103,14 @@ var FileRenamingDialog = { // eslint-disable-line no-unused-vars
},
_handleDoneClick: async function () {
// "Done" button -- prompt if dirty, then close
if (this._shouldPromptInvalidTemplate()) {
let wantsReset = await this._promptInvalidTemplate(this._currentLibraryID);
if (!wantsReset) {
this.settingsEl.formatTemplateTextarea.focus();
return;
}
await this._resetTemplateToDefault(this._currentLibraryID);
}
if (this._shouldPromptRename(this._currentLibraryID)) {
let wantsRename = await this._promptRename(this._currentLibraryID);
if (wantsRename) {
@ -170,16 +177,28 @@ var FileRenamingDialog = { // eslint-disable-line no-unused-vars
if (newLibraryID === previousLibraryID) {
return;
}
if (this._shouldPromptInvalidTemplate()) {
// Revert picker so the previous library is visible behind the prompt
this.libraryPicker.value = String(previousLibraryID);
let wantsReset = await this._promptInvalidTemplate(previousLibraryID);
if (!wantsReset) {
// User chose to fix the template -- stay on the current library
this.settingsEl.formatTemplateTextarea.focus();
return;
}
await this._resetTemplateToDefault(previousLibraryID);
}
if (this._shouldPromptRename(previousLibraryID)) {
// Revert picker so the previous library is visible behind the prompt
this.libraryPicker.value = String(previousLibraryID);
let wantsRename = await this._promptRename(previousLibraryID);
if (wantsRename && !this._openRenameFilesPreview(previousLibraryID)) {
// User cancelled the preview -- stay on the current library
this._updateButtons();
return;
}
this.libraryPicker.value = String(newLibraryID);
}
this.libraryPicker.value = String(newLibraryID);
this._currentLibraryID = newLibraryID;
this.loadSettingsForLibrary(newLibraryID);
this._updateButtons();
@ -198,11 +217,11 @@ var FileRenamingDialog = { // eslint-disable-line no-unused-vars
Zotero.Prefs.set('autoRenameFiles.fileTypes', enabledFileTypes);
Zotero.Prefs.set('autoRenameFiles.linked', renameLinkedEnabled);
// Handle template changes
// Handle template changes; never push an invalid template to sync
if (formatTemplate.replace(/\s/g, '') === '') {
Zotero.SyncedSettings.clear(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate');
}
else {
else if (this.settingsEl.templateValid) {
Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate', formatTemplate);
}
@ -210,13 +229,23 @@ var FileRenamingDialog = { // eslint-disable-line no-unused-vars
&& enabledFileTypes === base.fileTypes
&& formatTemplate === base.formatTemplate
&& renameLinkedEnabled === base.renameLinked;
Zotero.Prefs.set('autoRenameFiles.done', settingsMatch && this._baselineDone);
// Only flip the pending-rename state for a valid template
if (this.settingsEl.templateValid) {
Zotero.Prefs.set('autoRenameFiles.done', settingsMatch && this._baselineDone);
}
this._settingsChanged = !settingsMatch;
}
else {
Zotero.SyncedSettings.set(this.libraryID, 'autoRenameFiles', autoRenameEnabled);
Zotero.SyncedSettings.set(this.libraryID, 'autoRenameFilesFileTypes', enabledFileTypes);
Zotero.SyncedSettings.set(this.libraryID, 'attachmentRenameTemplate', formatTemplate);
// Handle template changes; never push an empty or invalid template to sync
if (formatTemplate.replace(/\s/g, '') === '') {
Zotero.SyncedSettings.clear(this.libraryID, 'attachmentRenameTemplate');
}
else if (this.settingsEl.templateValid) {
Zotero.SyncedSettings.set(this.libraryID, 'attachmentRenameTemplate', formatTemplate);
}
this._settingsChanged = autoRenameEnabled !== base.autoRenameEnabled
|| enabledFileTypes !== base.fileTypes
@ -230,17 +259,27 @@ var FileRenamingDialog = { // eslint-disable-line no-unused-vars
if (this._forceClose) {
return true;
}
if (!this._shouldPromptInvalidTemplate() && !this._shouldPromptRename(this._currentLibraryID)) {
return true;
}
event.preventDefault();
if (this._shouldPromptInvalidTemplate()) {
let wantsReset = await this._promptInvalidTemplate(this._currentLibraryID);
if (!wantsReset) {
this.settingsEl.formatTemplateTextarea.focus();
return false;
}
await this._resetTemplateToDefault(this._currentLibraryID);
}
if (this._shouldPromptRename(this._currentLibraryID)) {
event.preventDefault();
let wantsRename = await this._promptRename(this._currentLibraryID);
if (wantsRename) {
this._openRenameFilesPreview(this._currentLibraryID);
}
this._forceClose = true;
window.close();
return false;
}
return true;
this._forceClose = true;
window.close();
return false;
},
_handleDonePrefChange: function () {
@ -255,12 +294,58 @@ var FileRenamingDialog = { // eslint-disable-line no-unused-vars
if (!this._settingsChanged) {
return false;
}
if (!this.settingsEl.templateValid) {
return false;
}
if (libraryID === Zotero.Libraries.userLibraryID) {
return Zotero.Prefs.get('autoRenameFiles') && !Zotero.Prefs.get('autoRenameFiles.done');
}
return Zotero.Attachments.isAutoRenameFilesEnabledForLibrary(libraryID);
},
_shouldPromptInvalidTemplate: function () {
return this._settingsChanged && !this.settingsEl.templateValid;
},
_promptInvalidTemplate: async function (libraryID) {
let isUserLib = libraryID === Zotero.Libraries.userLibraryID;
let bodyID = isUserLib
? { id: 'file-renaming-invalid-template-prompt-body' }
: { id: 'file-renaming-invalid-template-prompt-body-library', args: { library: Zotero.Libraries.get(libraryID).name } };
let [title, description, reset, cancel] = await document.l10n.formatValues([
'file-renaming-invalid-template-prompt-title',
bodyID,
'general-reset-to-default',
'general-go-back'
]);
let index = Zotero.Prompt.confirm({
title,
text: description,
button0: reset,
button1: cancel,
defaultButton: 1
});
return index === 0;
},
_resetTemplateToDefault: async function (libraryID) {
await Zotero.SyncedSettings.clear(libraryID, 'attachmentRenameTemplate');
// Recompute the pending-changes state against the baseline, since handleSettingsChange()
// skips the update while the template is invalid
let isUserLib = libraryID === Zotero.Libraries.userLibraryID;
let base = this._baselineSettings;
let settingsMatch = this.settingsEl.autoRenameEnabled === base.autoRenameEnabled
&& this.settingsEl.enabledFileTypes === base.fileTypes
&& this.DEFAULT_ATTACHMENT_RENAME_TEMPLATE === base.formatTemplate
&& (!isUserLib || this.settingsEl.renameLinkedEnabled === base.renameLinked);
if (isUserLib) {
Zotero.Prefs.set('autoRenameFiles.done', settingsMatch && this._baselineDone);
}
// Show the default template in place of the invalid one
this.settingsEl.setAttribute('format-template', this.DEFAULT_ATTACHMENT_RENAME_TEMPLATE);
this._settingsChanged = !settingsMatch;
},
_promptRename: async function (libraryID) {
let isUserLib = libraryID === Zotero.Libraries.userLibraryID;
let bodyID = isUserLib

View file

@ -37,6 +37,7 @@ const { directAuth } = mendeleyAPIUtils;
const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
file: null,
folder: null,
importAbortController: null,
isZotfileInstalled: false,
libraryID: null,
mendeleyAuth: null,
@ -91,12 +92,8 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
.addEventListener('keyup', (ev) => {
document.getElementById('import-other').checked = ev.currentTarget.value.length > 0;
});
document
.querySelector('a')
.addEventListener('click', this.onURLInteract.bind(this));
document
.querySelector('a')
.addEventListener('keydown', this.onURLInteract.bind(this));
document.addEventListener('click', this.onURLInteract.bind(this));
document.addEventListener('keydown', this.onURLInteract.bind(this));
document
.querySelector('#page-done-error > button')
.addEventListener('click', this.onReportErrorInteract.bind(this));
@ -154,20 +151,23 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
}
},
skipToDonePage(label, description, showReportErrorButton = false, isMendeleyError = false) {
// Mendeley errors are shown in their own element, which links to the relevant
// documentation, so they're passed as an l10n id rather than as a description
skipToDonePage(label, description, showReportErrorButton = false, mendeleyErrorId = null) {
this.wizard.getPageById('page-done').dataset.headerLabelId = label;
if (!isMendeleyError) {
if (Array.isArray(description)) {
document.getElementById('page-done-description').dataset.l10nId = description[0];
document.getElementById('page-done-description').dataset.l10nArgs = JSON.stringify(description[1]);
}
else {
document.getElementById('page-done-description').dataset.l10nId = description;
}
if (mendeleyErrorId) {
document.getElementById('page-done-error-mendeley').dataset.l10nId = mendeleyErrorId;
}
else if (Array.isArray(description)) {
document.getElementById('page-done-description').dataset.l10nId = description[0];
document.getElementById('page-done-description').dataset.l10nArgs = JSON.stringify(description[1]);
}
else {
document.getElementById('page-done-description').dataset.l10nId = description;
}
document.getElementById('page-done-error-mendeley').style.display = isMendeleyError ? 'block' : 'none';
document.getElementById('page-done-error-mendeley').style.display = mendeleyErrorId ? 'block' : 'none';
document.getElementById('page-done-error').style.display = showReportErrorButton ? 'block' : 'none';
const doneQueueContainer = document.getElementById('done-queue-container');
@ -215,6 +215,8 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
const rv = await fp.show();
if (rv !== fp.returnOK && rv !== fp.returnReplace) {
// Re-enable "continue", disabled while the file picker was up
this.wizard.canAdvance = true;
return;
}
@ -231,6 +233,8 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
const rv = await fp.show();
if (rv !== fp.returnOK && rv !== fp.returnReplace) {
// Re-enable "continue", disabled while the file picker was up
this.wizard.canAdvance = true;
return;
}
@ -408,9 +412,16 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
},
onURLInteract(ev) {
let link = ev.target.closest?.('a[href]');
if (!link) {
return;
}
if (ev.type === 'click' || (ev.type === 'keydown' && ev.key === ' ')) {
Zotero.launchURL(ev.currentTarget.getAttribute('href'));
window.close();
Zotero.launchURL(link.getAttribute('href'));
// Closing mid-wizard would lose progress, so only close once done
if (link.closest('wizardpage')?.getAttribute('pageid') == 'page-done') {
window.close();
}
ev.preventDefault();
}
},
@ -423,6 +434,10 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
},
onCancel() {
// The file can still be getting prepared, e.g. an encrypted Mendeley database
// being decrypted, in which case there's no translation to interrupt yet
this.importAbortController?.abort();
if (this.translation && this.translation.interrupt) {
this.translation.interrupt();
}
@ -435,6 +450,7 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
async startImport() {
this.wizard.canAdvance = false;
this.wizard.canRewind = false;
this.importAbortController = new AbortController();
const fileHandling = document.getElementById('file-handling').value;
const linkFiles = fileHandling === 'link';
@ -465,7 +481,8 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
newItemsOnly,
onBeforeImport: this.onBeforeImport.bind(this),
recreateStructure,
relinkOnly
relinkOnly,
signal: this.importAbortController.signal
});
// Cancelled by user or due to error
@ -484,8 +501,14 @@ const Zotero_Import_Wizard = { // eslint-disable-line no-unused-vars
);
}
catch (e) {
if (e.message == 'Encrypted Mendeley database') {
this.skipToDonePage('general-error', [], false, true);
if (e.message == 'Mendeley database in use') {
this.skipToDonePage('general-error', 'import-mendeley-db-in-use');
}
else if (e.message == 'Cannot derive Mendeley database key') {
this.skipToDonePage('general-error', null, false, 'import-mendeley-cannot-decrypt');
}
else if (e.message == 'Unsupported Mendeley database encryption') {
this.skipToDonePage('general-error', null, false, 'import-mendeley-unsupported');
}
else {
const translatorLabel = this.translation?.translator?.[0]?.label;

View file

@ -103,7 +103,7 @@
</wizardpage>
<wizardpage pageid="page-done">
<div id="page-done-description"></div>
<div id="page-done-error-mendeley" data-l10n-id="import-mendeley-encrypted">
<div id="page-done-error-mendeley">
<html:a data-l10n-name="mendeley-import-kb" href="https://www.zotero.org/support/kb/mendeley_import" />
</div>
<div id="page-done-error">

View file

@ -0,0 +1,246 @@
/**
* Decryption for encrypted Mendeley Desktop library databases.
*
* Mendeley encrypts the local library with the SQLite Encryption Extension,
* which Zotero's SQLite cannot read. The 256-bit key is a constant compiled
* into Mendeley Desktop, put through a fixed obfuscation and then XOR-folded
* with the account's profile UUID, which is the database filename stem.
*
* Pages are encrypted with AES-256-OFB. Each page carries its own nonce in the
* reserved space at the end of the page, which is left in the clear, and the
* initialization vector is that nonce prefixed with the page number.
*/
const OBFUSCATED_KEY = 'e5431aeb503dcf0ab92d3e936c0bbb40fc7e0a9448699a77dac9cb6276d288bd';
const DEOBFUSCATION_XOR = 0x62;
const KEY_SIZE = 32;
const UUID_SIZE = 16;
const BLOCK_SIZE = 16;
const RESERVED_SIZE = 12;
const MIN_PAGE_SIZE = 512;
const MAX_PAGE_SIZE = 65536;
// Null-terminated, so this plus its terminator is the first 16 bytes of the file
const SQLITE_MAGIC = 'SQLite format 3';
// Page size and reserved-space fields, which are never encrypted so that the
// page layout stays readable
const CLEAR_HEADER_START = 16;
const CLEAR_HEADER_END = 24;
function hexToBytes(hex) {
let bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
function reverseBytes(hex) {
let reversed = '';
for (let i = hex.length - 2; i >= 0; i -= 2) {
reversed += hex.slice(i, i + 2);
}
return reversed;
}
function getBaseKey() {
let obfuscated = hexToBytes(OBFUSCATED_KEY);
let key = new Uint8Array(KEY_SIZE);
for (let i = 0; i < KEY_SIZE; i++) {
let byte = obfuscated[i] ^ DEOBFUSCATION_XOR;
key[i] = ((byte << 4) | (byte >> 4)) & 0xFF;
}
return key;
}
/**
* Mendeley stores the profile UUID in the Windows GUID layout, where the first
* three fields are little-endian rather than big-endian
*/
function uuidToBytes(uuid) {
let parts = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(uuid);
if (!parts) {
throw new Error(`Invalid Mendeley profile UUID: ${uuid}`);
}
let [, timeLow, timeMid, timeHigh, clockSeq, node] = parts;
return hexToBytes(
reverseBytes(timeLow) + reverseBytes(timeMid) + reverseBytes(timeHigh) + clockSeq + node
);
}
function getLibraryKey(profileUUID) {
let key = getBaseKey();
let uuid = uuidToBytes(profileUUID);
for (let i = 0; i < UUID_SIZE; i++) {
key[i] ^= uuid[i];
key[KEY_SIZE - 1 - i] ^= uuid[i];
}
return key;
}
function getPageSize(header) {
let pageSize = (header[CLEAR_HEADER_START] << 8) | header[CLEAR_HEADER_START + 1];
// The maximum page size doesn't fit in the two-byte field and is stored as 1
return pageSize == 1 ? MAX_PAGE_SIZE : pageSize;
}
function hasSQLiteMagic(page) {
return String.fromCharCode(...page.subarray(0, SQLITE_MAGIC.length)) == SQLITE_MAGIC
&& page[SQLITE_MAGIC.length] == 0;
}
/**
* AES-256-OFB keystream.
*
* WebCrypto has no OFB mode, but CBC-encrypting a zero-filled buffer produces
* the same sequence: with an all-zero plaintext, each ciphertext block is
* E(key, previousBlock XOR 0), which is the OFB feedback relation. That gets
* the whole page's keystream from a single call instead of one per block.
*/
async function getKeystream(key, iv, length) {
let blocks = Math.ceil(length / BLOCK_SIZE);
let keystream = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv }, key, new Uint8Array(blocks * BLOCK_SIZE)
);
return new Uint8Array(keystream, 0, length);
}
/**
* Decrypt a single page in place
*/
async function decryptPage(key, pageNumber, page) {
let encryptedSize = page.length - RESERVED_SIZE;
let iv = new Uint8Array(BLOCK_SIZE);
new DataView(iv.buffer).setUint32(0, pageNumber, true);
iv.set(page.subarray(encryptedSize), 4);
let clearHeader = pageNumber == 1
? page.slice(CLEAR_HEADER_START, CLEAR_HEADER_END)
: null;
let keystream = await getKeystream(key, iv, encryptedSize);
for (let i = 0; i < encryptedSize; i++) {
page[i] ^= keystream[i];
}
// The keystream advances over the unencrypted header fields, so they have to
// be put back rather than skipped
if (clearHeader) {
page.set(clearHeader, CLEAR_HEADER_START);
}
}
async function importKey(rawKey) {
return crypto.subtle.importKey('raw', rawKey, { name: 'AES-CBC' }, false, ['encrypt']);
}
/**
* Find the key that decrypts the database, or null if none of them do
*/
async function findKey(data, pageSize, profileUUID) {
// online.sqlite, the database Mendeley uses before you sign in, has no
// profile UUID to fold in and uses the unfolded key
let candidates = profileUUID
? [getLibraryKey(profileUUID), getBaseKey()]
: [getBaseKey()];
for (let candidate of candidates) {
let key = await importKey(candidate);
let firstPage = data.slice(0, pageSize);
await decryptPage(key, 1, firstPage);
if (hasSQLiteMagic(firstPage)) {
return key;
}
}
return null;
}
/**
* Get the Mendeley profile UUID a database is keyed to, from its filename
*
* @param {String} path
* @return {String|null}
*/
export function getProfileUUID(path) {
let match = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})@www\.mendeley\.com\.sqlite$/i
.exec(PathUtils.filename(path));
return match ? match[1] : null;
}
/**
* Check whether a file is an encrypted Mendeley database
*
* @param {String} path
* @return {Promise<Boolean>}
*/
export async function isEncryptedDatabase(path) {
let header;
try {
header = await IOUtils.read(path, { maxBytes: CLEAR_HEADER_END });
}
catch {
return false;
}
if (header.length < CLEAR_HEADER_END || hasSQLiteMagic(header)) {
return false;
}
let pageSize = getPageSize(header);
return header[20] == RESERVED_SIZE
&& pageSize >= MIN_PAGE_SIZE
&& pageSize <= MAX_PAGE_SIZE
&& (pageSize & (pageSize - 1)) == 0;
}
/**
* Check whether Mendeley still has the database open
*
* SQLite removes both files when the last connection closes cleanly, so either
* one means Mendeley is still running, or crashed leaving changes that exist
* only in the write-ahead log.
*
* @param {String} path
* @return {Promise<Boolean>}
*/
export async function isDatabaseInUse(path) {
if (await IOUtils.exists(path + '-shm')) {
return true;
}
if (await IOUtils.exists(path + '-wal')) {
let { size } = await IOUtils.stat(path + '-wal');
return size > 0;
}
return false;
}
/**
* Decrypt a Mendeley database to a new file
*
* The decrypted copy keeps the reserved space the encryption used for its
* nonces.
*
* @param {String} sourcePath - Encrypted database
* @param {String} targetPath - Path to write the decrypted database to
*/
export async function decryptDatabase(sourcePath, targetPath) {
let data = await IOUtils.read(sourcePath);
let pageSize = getPageSize(data);
if (data.length < CLEAR_HEADER_END
|| data[20] != RESERVED_SIZE
|| pageSize < MIN_PAGE_SIZE
|| data.length % pageSize) {
// Keep in sync with importWizard.js
throw new Error('Unsupported Mendeley database encryption');
}
let key = await findKey(data, pageSize, getProfileUUID(sourcePath));
if (!key) {
// Keep in sync with importWizard.js
throw new Error('Cannot derive Mendeley database key');
}
for (let pageNumber = 1; pageNumber <= data.length / pageSize; pageNumber++) {
await decryptPage(
key, pageNumber, data.subarray((pageNumber - 1) * pageSize, pageNumber * pageSize)
);
}
await IOUtils.write(targetPath, data);
}

View file

@ -32,6 +32,9 @@ export var Zotero_Import_Mendeley = function () {
this.relinkOnly = false;
this.numRelinked = 0;
this.skipNotebooks = false;
// Directory the database was selected from, if it isn't the one the database is read
// from -- i.e. when an encrypted database is read from a decrypted copy
this.sourceDirectory = null;
this._tokens = null;
this._credentials = null;
@ -1698,7 +1701,7 @@ Zotero_Import_Mendeley.prototype._isTempDownloadedFile = function (path) {
* Get the path to use for a file that exists, or false if none
*
* This can be either the original path or, for a file in the Downloaded directory, in a directory
* relative to the database.
* relative to where the database was selected from.
*
* @return {String|false}
*/
@ -1712,7 +1715,7 @@ Zotero_Import_Mendeley.prototype._getRealFilePath = async function (path) {
}
// For file paths in Downloaded folder, try relative to database if not found at the
// absolute location, in case this is a DB backup
var dataDir = PathUtils.parent(this._file);
var dataDir = this.sourceDirectory || PathUtils.parent(this._file);
var altPath = OS.Path.join(dataDir, 'Downloaded', PathUtils.filename(path));
if (altPath != path && await OS.File.exists(altPath)) {
return altPath;

View file

@ -131,6 +131,8 @@ async function onLoad() {
await IOManager.toggleDialogMode(initialMode);
// most of IO handling relies on currentLayout being defined so it must follow setInitialDialogMode
IOManager.init();
// set the text of the citation preview
CitationPreview.update();
// explicitly focus bubble input so one can begin typing right away
_id("bubble-input").refocusInput();
// wait to call functions that rely on io.getItems() or io.sort() till all cited data is loaded
@ -258,8 +260,9 @@ async function setDialogType(type) {
_id("keepSorted").disabled = !io.sortable || !DIALOG_STATE.isCitingItems();
_id("keepSorted").checked = !_id("keepSorted").disabled && !io.citation.properties.unsorted;
if (DIALOG_STATE.isCitingItems()) {
_id("settings-button").hidden = !io.sortable;
_id("keepSorted").disabled = !io.sortable;
_id("keepSorted").parentElement.hidden = !io.sortable;
CitationPreview.update();
if (!DIALOG_STATE.loaded) {
_id("keepSorted").checked = io.sortable && !io.citation.properties.unsorted;
}
@ -451,7 +454,7 @@ class Layout {
else {
// Make sure the collectionTreeRow is defined to
// avoid errors thrown when filter is set on first load
while (!this.itemsView.collectionTreeRow) {
while (!this.itemsView.collectionTreeRows) {
await Zotero.Promise.delay(10);
}
await this.refreshItemsList();
@ -596,14 +599,16 @@ class LibraryLayout extends Layout {
IOManager.updateBubbleInput();
}
// Resolves once the resize animation has fully completed
async resizeWindow() {
await Helpers.smoothResizingPromise;
let bubbleInputHeight = Helpers.getSearchRowHeight();
let suggestedItemsHeight = _id("library-other-items").getBoundingClientRect().height;
let minTableHeight = 400;
let citationPreview = _id("citation-preview").getBoundingClientRect().height;
let bottomHeight = _id("bottom-area-wrapper").getBoundingClientRect().height;
let minHeight = bubbleInputHeight + suggestedItemsHeight + bottomHeight + minTableHeight;
let minHeight = bubbleInputHeight + suggestedItemsHeight + citationPreview + bottomHeight + minTableHeight;
let targetWidth = Math.max(window.innerWidth, this.MIN_WIDTH);
let targetHeight = Math.max(minHeight, lastSetWindowHeight);
@ -612,13 +617,16 @@ class LibraryLayout extends Layout {
if (needsResize) {
doc.documentElement.style.removeProperty('min-height');
ignoreWindowResizing = true;
Helpers.smoothResize(targetWidth, targetHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
},
await new Promise((resolve) => {
Helpers.smoothResize(targetWidth, targetHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
resolve();
},
});
});
}
// ensure dialog-layout and min-height is set even if window does not need resizing
@ -673,13 +681,19 @@ class LibraryLayout extends Layout {
let icon = getCSSIcon('plus-circle');
iconWrapper.append(icon);
// add aria-label for screen readers to announce if this item is added
let count = this._getItemsViewIconClickItems(index).length;
if (inCitation) {
doc.l10n.setAttributes(cell, "integration-citationDialog-items-table-added");
doc.l10n.setAttributes(cell, "integration-citationDialog-items-table-added", { count });
}
else {
doc.l10n.setAttributes(cell, "integration-citationDialog-items-table");
doc.l10n.setAttributes(cell, "integration-citationDialog-items-table", { count });
}
iconWrapper.append(icon);
// refresh the tooltip's item count, since the selection can change
// without this row re-rendering
cell.addEventListener("mouseenter", () => {
doc.l10n.setArgs(cell, { count: this._getItemsViewIconClickItems(index).length });
});
iconWrapper.addEventListener("click", () => {
this._handleItemsViewIconClick(index);
});
@ -776,7 +790,8 @@ class LibraryLayout extends Layout {
hideSources: ['duplicates', 'trash', 'feeds'],
initialFolder: Zotero.Prefs.get("integration.citationDialogCollectionLastSelected"),
onActivate: () => {},
filterLibraryIDs: io.filterLibraryIDs
filterLibraryIDs: io.filterLibraryIDs,
multiSelect: true
});
// Add aria-description with instructions on what this collection tree is for
// Voiceover announces the description placed on the actual tree when focus enters it
@ -869,10 +884,18 @@ class LibraryLayout extends Layout {
}
async _onCollectionSelection() {
var collectionTreeRow = this.collectionsView.getRow(this.collectionsView.selection.focused);
if (!this.collectionsView.selection.count) return;
// Collection not changed
if (this.itemsView && this.itemsView.collectionTreeRow && this.itemsView.collectionTreeRow.id == collectionTreeRow.id) {
// Show the union of all selected collections (cross-library selections are
// grouped by library in the items view, as in List mode)
let selectedRows = [...this.collectionsView.selection.selected]
.sort((a, b) => a - b)
.map(index => this.collectionsView.getRow(index));
// Collection selection not changed
if (this.itemsView
&& Zotero.Utilities.arrayEquals(
selectedRows.map(row => row.id).sort(),
this.itemsView.collectionTreeRows.map(row => row.id).sort()
)) {
return;
}
// _onCollectionSelection will be called during initiation. It can take a while
@ -880,41 +903,45 @@ class LibraryLayout extends Layout {
if (currentLayout?.type !== "library") return;
this.itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
// Load library data if necessary
var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID);
if (!library.getDataLoaded('item')) {
Zotero.debug("Waiting for items to load for library " + library.libraryID);
await library.waitForDataLoad('item');
// Load item data for each selected library if necessary
for (let libraryID of new Set(selectedRows.map(row => row.ref.libraryID))) {
let library = Zotero.Libraries.get(libraryID);
if (!library.getDataLoaded('item')) {
Zotero.debug("Waiting for items to load for library " + library.libraryID);
await library.waitForDataLoad('item');
}
}
await this.itemsView.changeCollectionTreeRow({
// Restrict each collection's items to those relevant to the current citation mode
let filterItemsForMode = async (items) => {
// In add-note mode, note parent checks call item.getNotes(), which requires childItems
if (DIALOG_STATE.isAddingNote()) {
let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item));
if (regularItems.length) {
await Zotero.Items.loadDataTypes(regularItems, ['childItems']);
}
// when citing notes, only keep notes or note parents
items = items.filter(item => item.isNote() || item.getNotes().length);
}
// when adding annotations, only keep annotations, their attachments, and their top-level items
if (DIALOG_STATE.isAddingAnnotations()) {
return SearchHandler.keepItemsWithAnnotations(items);
}
return items;
};
await this.itemsView.changeCollectionTreeRows(selectedRows.map(collectionTreeRow => ({
id: collectionTreeRow.id,
getItems: async () => {
let items = await collectionTreeRow.getItems();
// In add-note mode, note parent checks call item.getNotes(), which requires childItems
if (DIALOG_STATE.isAddingNote()) {
let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item));
if (regularItems.length) {
await Zotero.Items.loadDataTypes(regularItems, ['childItems']);
}
// when citing notes, only keep notes or note parents
items = items.filter(item => item.isNote() || item.getNotes().length);
}
// when adding annotations, only keep annotations, their attachments, and their top-level items
if (DIALOG_STATE.isAddingAnnotations()) {
return SearchHandler.keepItemsWithAnnotations(items);
}
return items;
},
getItems: async () => filterItemsForMode(await collectionTreeRow.getItems()),
isSearch: () => true,
isSearchMode: () => true,
setSearch: (searchText, mode) => collectionTreeRow.setSearch(searchText, mode),
clearCache: () => collectionTreeRow.clearCache(),
ref: collectionTreeRow.ref
});
})));
await this.itemsView.setFilter('citation-search', SearchHandler.searchValue);
this.itemsView.clearItemsPaneMessage();
}
@ -931,18 +958,35 @@ class LibraryLayout extends Layout {
}
}
// click on + icon will add the item to the citation
// click on + icon will add the item(s) to the citation
_handleItemsViewIconClick(index) {
let rowNode = doc.getElementById(`${this.itemsView.id}-row-${index}`);
let rowTopBeforeRefresh = rowNode.getBoundingClientRect().top;
let items = this._getItemsViewIconClickItems(index);
this.itemsView.selection.clearSelection();
let row = this.itemsView.getRow(index);
// after adding the item, try to keep the mouse over it even if the bubble-input gets taller
IOManager.addItemsToCitation([row.ref]).then(() => {
// after adding the items, try to keep the mouse over the clicked row
// even if the bubble-input gets taller
IOManager.addItemsToCitation(items).then(() => {
this._scrollItemTreeToRow(rowNode.id, rowTopBeforeRefresh);
});
}
// items that a click on the + icon of the given row will add: all selected
// items if the row is part of the current selection, otherwise just this row's item
_getItemsViewIconClickItems(index) {
let items;
if (this.itemsView.selection.isSelected(index)) {
items = this.itemsView.getSelectedItems();
}
else {
items = [this.itemsView.getRow(index).ref];
}
if (DIALOG_STATE.isAddingAnnotations()) {
items = items.filter(item => item.isAnnotation());
}
return items;
}
_handleSelectionChangeWithAnnotation() {
let selectedItems = this.itemsView.getSelectedItems().filter(item => item.isAnnotation() || item.isFileAttachment() || item.isRegularItem());
let selectedAnnotations = selectedItems.flatMap(item => SearchHandler.getAllAnnotations(item));
@ -997,7 +1041,9 @@ class LibraryLayout extends Layout {
// If the itemTree is still loading, wait for it to finish
await this.itemsView.waitForLoad();
// Scroll to the first cited item
let firstCitedRow = this.itemsView._rows.findIndex(row => CitationDataManager.itemAddedCache.has(row.ref.id));
let firstCitedRow = this.itemsView._rows.findIndex(
row => row.isObjectRow && CitationDataManager.itemAddedCache.has(row.ref.id)
);
if (firstCitedRow == -1) return;
this.itemsView.ensureRowsAreVisible([firstCitedRow]);
}
@ -1006,10 +1052,11 @@ class LibraryLayout extends Layout {
// scroll it back up so that the mouse remains over the same row as before click
// do not do it on click of the first row, since then the mouse will be on a header
_scrollItemTreeToRow(rowID, rowTopBeforeRefresh) {
let rowIndex = rowID.split("-")[4];
let rowIndex = parseInt(rowID.split("-").at(-1));
if (rowIndex === 0) return;
this.itemsView.ensureRowIsVisible(rowIndex);
let rowAfterRefresh = doc.querySelector(`#zotero-items-tree #${rowID}`);
if (!rowAfterRefresh) return;
let rowTopAfterRefresh = rowAfterRefresh.getBoundingClientRect().top;
let delta = rowTopAfterRefresh - rowTopBeforeRefresh;
if (delta > 0.1) {
@ -1092,6 +1139,7 @@ class ListLayout extends Layout {
IOManager.updateBubbleInput();
}
// Resolves only once the resize animation has fully completed
async resizeWindow() {
await Helpers.smoothResizingPromise;
let bubbleInputHeight = Helpers.getSearchRowHeight();
@ -1112,11 +1160,12 @@ class ListLayout extends Layout {
marginOfError = Zotero.isWin ? 6 : 2;
}
// height of the bottom section
// height of citation preview (0 when hidden) and the bottom section
let citationPreview = _id("citation-preview").getBoundingClientRect().height;
let bottomHeight = _id("bottom-area-wrapper").getBoundingClientRect().height;
// set min height and resize the window
let autoHeight = bubbleInputHeight + sectionsHeight + sectionsWrapperPadding + bottomHeight + marginOfError;
let autoHeight = bubbleInputHeight + sectionsHeight + sectionsWrapperPadding + citationPreview + bottomHeight + marginOfError;
// window.resizeTo(X,Y) resizes the window so that it's outerHeight == Y. On mac and windows,
// innerHeight and outerHeight are the same. On linux, the outerHeight > innerHeight, perhaps
// outerHeight there includes chrome, borders, etc. This difference is accounted for below, so that the dialog
@ -1124,24 +1173,39 @@ class ListLayout extends Layout {
if (Zotero.isLinux) {
autoHeight += (window.outerHeight - window.innerHeight);
}
let minHeight = bubbleInputHeight + bottomHeight;
doc.documentElement.style.minHeight = `${minHeight}px`;
let minHeight = bubbleInputHeight + citationPreview + bottomHeight;
// cap window height at the height last set by the user
autoHeight = Math.min(autoHeight, lastSetWindowHeight);
let targetWidth = Math.min(window.innerWidth, this.MIN_WIDTH);
// Skip the resize animation if the window is already at the target size.
let needsResize = Math.round(window.innerWidth) !== Math.round(targetWidth) || Math.round(window.innerHeight) !== Math.round(autoHeight);
if (!needsResize) {
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
return;
}
// Clear the min-height floor so the window can animate freely (including shrinking);
// it's restored to the new value in onComplete below.
doc.documentElement.style.removeProperty("min-height");
ignoreWindowResizing = true;
// Timeout is required likely to allow minHeight update to settle
setTimeout(() => {
Helpers.smoothResize(targetWidth, autoHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
},
});
}, 10);
// Timeout is required likely to allow the min-height removal to settle
await new Promise((resolve) => {
setTimeout(() => {
Helpers.smoothResize(targetWidth, autoHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
resolve();
},
});
}, 10);
});
}
_markRoundedCorners() {
@ -1181,6 +1245,7 @@ class ListLayout extends Layout {
const IOManager = {
sectionExpandedStatus: {},
_skipInputAcceptOnEnterUntil: 0,
_timesItemsAdded: 0,
// most essential IO functionality that is added immediately on load
preInit() {
@ -1224,6 +1289,7 @@ const IOManager = {
});
_id("includeComments").addEventListener("click", () => this._toggleIncludeComments());
_id("display-preview-button").addEventListener("click", () => this._toggleDisplayPreview());
// open settings popup on btn click
_id("settings-button").addEventListener("click", event => _id("settings-popup").openPopup(event.target, "before_end"));
@ -1267,10 +1333,12 @@ const IOManager = {
let isInitialModeSetting = currentLayout === undefined;
currentLayout = newMode === "library" ? libraryLayout : listLayout;
// Reflect visibility of the citation preview after the layout switch.
CitationPreview.update();
// Wait for window resize before running search to avoid stutter with large libraries
if (!isInitialModeSetting) {
await currentLayout.resizeWindow();
await Helpers.smoothResizingPromise;
}
// do not show View menubar with itemTree-specific options in list mode
@ -1317,6 +1385,7 @@ const IOManager = {
};
}), DIALOG_STATE.type);
_id("accept-button").disabled = !CitationDataManager.items.length;
CitationPreview.update();
},
async addItemsToCitation(items, { noInputRefocus, index } = { index: null }) {
@ -1373,11 +1442,21 @@ const IOManager = {
// Do not record just-added bubbles if locator is already provided
this._clearJustAddedBubbles();
}
else {
// If no locator is provided, record which bubbles were just added.
// If a locator is typed next, these bubbles will receive it.
else if (bubbleItems.length == 1) {
// If no locator is provided, record the just-added bubble.
// If a locator is typed next, that bubble will receive it.
this._justAddedBubbles = bubbleItems;
// Only show the placeholder guidance on the first add -- after
// that, the user presumably knows about the shortcut
_id("bubble-input").showJustAddedPlaceholder = DIALOG_STATE.isCitingItems()
&& this._timesItemsAdded < 1;
}
else {
// A multi-item add doesn't enter locator-typing mode, so typed text
// starts a new search instead of setting a page number on every added item
this._clearJustAddedBubbles();
}
this._timesItemsAdded++;
await CitationDataManager.addItems({ bubbleItems, index });
// Refresh the itemTree if in library mode
if (currentLayout.type == "library") {
@ -1394,6 +1473,9 @@ const IOManager = {
doc.querySelector("guidance-panel").setAttribute("x", Math.round(width / 2));
IOManager.showFirstRunDialog();
}
// Render the preview before refreshing the list so resizeWindow measures its real height;
// otherwise the debounced render lands after the resize and overflows the window.
await CitationPreview.render();
// Always refresh items list to make sure the opened and selected items are up to date
await currentLayout.refreshItemsList();
if (!noInputRefocus) {
@ -1663,6 +1745,9 @@ const IOManager = {
input.value = "";
input.dispatchEvent(new Event('input', { bubbles: true }));
this.updateBubbleInput();
// The typed-locator shortcut has been used, so stop showing the tip
// about it in the item details popup
Zotero.Prefs.set("integration.citationDialogShowLocatorTip", false);
return;
}
}
@ -1703,9 +1788,14 @@ const IOManager = {
_deleteItem(dialogReferenceID) {
CitationDataManager.deleteItem({ dialogReferenceID });
// If the citation is emptied, show the placeholder guidance again on the next add
if (!CitationDataManager.items.length) {
this._timesItemsAdded = 0;
}
if (currentLayout.type == "library") {
libraryLayout.refreshItemsView();
}
this._clearJustAddedBubbles();
this.updateBubbleInput();
// Always refresh items list to make sure the opened and selected items are up to date
currentLayout.refreshItemsList();
@ -1803,9 +1893,14 @@ const IOManager = {
bubbleItem.label = "page";
}
IOManager._hideLoadingSpinner();
// Clear the input and update bubbles
// Clear the input and update bubbles. The placeholder stays, since both of its
// suggestions still apply -- typed digits keep appending to the locator, and
// any other input starts a search
input.value = "";
IOManager.updateBubbleInput();
// The typed-locator shortcut has been used, so stop showing the tip
// about it in the item details popup
Zotero.Prefs.set("integration.citationDialogShowLocatorTip", false);
// Disable Enter on input from accepting the dialog for the next 500ms;
// If one intends to confirmed the numeric locator by pressing Enter (via _handleInputEnter),
// we ensure that the Enter keypress won't happen right after when the locator is added to
@ -1819,11 +1914,18 @@ const IOManager = {
// and Enter is presses, just-added bubbles get that locator.
_clearJustAddedBubbles(event) {
if (!this._justAddedBubbles) return;
// on keydown, only proceed if it's an arrow key
let navigationKeys = ["ArrowUp", "ArrowDown", "ArrowRight", "ArrowLeft"];
if (event && event.type == "keydown" && !navigationKeys.includes(event.key)) return;
// On keydown, only proceed for left/right arrows, which move to another
// reference (e.g. to explicitly search for a year). Up/down arrows just move
// the list selection while focus remains in the input, so locator entry
// stays active.
if (event && event.type == "keydown" && !["ArrowLeft", "ArrowRight"].includes(event.key)) return;
// On focusout, only proceed if focus moved elsewhere within the dialog. When
// the window itself loses focus (e.g., on Cmd-Tab to another app to check a
// page number), the input remains focused and locator entry stays active.
if (event && event.type == "focusout" && !doc.hasFocus()) return;
// clear just added bubbles and update bubble input to reflect that
this._justAddedBubbles = null;
_id("bubble-input").showJustAddedPlaceholder = false;
this.updateBubbleInput();
},
@ -1862,6 +1964,31 @@ const IOManager = {
Zotero.Prefs.set("integration.annotationDialogIncludeComments", includeComments);
},
async _toggleDisplayPreview() {
let newShown = !Zotero.Prefs.get("integration.citationPreviewShown");
Zotero.Prefs.set("integration.citationPreviewShown", newShown);
// Reflect the pressed state right away, since revealing the preview is deferred until resize
_id("display-preview-button").setAttribute("aria-pressed", newShown ? "true" : "false");
let preview = _id("citation-preview");
if (newShown) {
// Lay the preview out off-flow via .measuring (real height, but invisible and not
// pushing the list around) so resizeWindow grows the window to fit it; then drop it
// into view once there's room, avoiding a momentary squeeze of the list.
await CitationPreview.render();
preview.classList.add("measuring");
preview.hidden = false;
await currentLayout.resizeWindow();
preview.classList.remove("measuring");
CitationPreview.update();
}
else {
// Hide first, then shrink -- freeing the space before the window contracts looks clean.
preview.classList.remove("measuring");
CitationPreview.update();
currentLayout.resizeWindow();
}
},
// Return focus to where it was before click moved focus.
// If it's not possible, refocus the last input in bubble-input so that
// focus is not just lost.
@ -1939,6 +2066,64 @@ const IOManager = {
}
};
// Manages the citation preview shown in the bottom area of both layouts.
const CitationPreview = {
// Lazily create _renderDebounced on first use
get _renderDebounced() {
delete CitationPreview._renderDebounced;
CitationPreview._renderDebounced = Zotero.Utilities.debounce(() => CitationPreview.render(), 250);
return CitationPreview._renderDebounced;
},
// The rendered text is kept in sync with the cited items even while the preview is hidden,
// so it can be measured and revealed instantly when toggled on.
update() {
let prefShown = Zotero.Prefs.get("integration.citationPreviewShown");
let isCitingItems = DIALOG_STATE.isCitingItems();
let hasPreview = !!io.preview;
let isEmpty = !CitationDataManager.items.length;
// The preview pane appears when citing at least one item and the caller has
// provided a preview function
let isRelevant = isCitingItems && hasPreview && !isEmpty;
_id("citation-preview").hidden = !(isRelevant && prefShown);
if (isEmpty) {
_id("citation-preview-content").innerHTML = "";
_id("citation-preview-error").hidden = true;
}
// The toggle button is hidden when there's no preview and is restored to its last
// state when the preview pane appears
let toggleBtn = _id("display-preview-button");
toggleBtn.hidden = !isRelevant;
toggleBtn.setAttribute("aria-pressed", prefShown ? "true" : "false");
if (!isEmpty) {
CitationPreview._renderDebounced();
}
},
async render() {
if (!DIALOG_STATE.isCitingItems()) return;
if (!CitationDataManager.items.length) return;
if (!io.preview) return;
CitationDataManager.updateCitationObject();
let html;
try {
html = await io.preview("html");
}
catch (e) {
// A preview failure usually means the citation itself won't process, but that
// error belongs to the insertion step -- just flag the preview as unavailable
Zotero.logError(e);
}
// Re-check after the await in case the user cleared items
if (!CitationDataManager.items.length) return;
let errored = html === undefined;
_id("citation-preview-content").hidden = errored;
_id("citation-preview-error").hidden = !errored;
_id("citation-preview-content").innerHTML = errored ? "" : html;
},
};
// Representation of a single entry in the citation.
class BubbleItem {
// Can be created from either Zotero.Item or citation item from io.citation.citationItems
@ -2125,7 +2310,16 @@ const CitationDataManager = {
if (!ioIsReady) return;
Zotero.debug("Citation Dialog: sorting items");
this.updateCitationObject();
await io.sort();
try {
await io.sort();
}
catch (e) {
// Sorting runs the citation through the processor, so it can fail like the
// preview does. Keep the current order -- if the citation can't be processed,
// the error will surface when it's inserted into the document.
Zotero.logError(e);
return;
}
// sync the order of this.items with io.citation.sortedItems
let sortedIOItems = io.citation.sortedItems.map(entry => entry[1]);
let sortedItems = sortedIOItems.map((sortedItem) => {

View file

@ -31,7 +31,9 @@
xmlns="http://www.w3.org/1999/xhtml"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
drawintitlebar-platforms="mac,win"
resizable="false">
resizable="false"
windowtype="zotero:citation-dialog"
>
<head>
<title data-l10n-id="integration-citationDialog"></title>
<link rel="localization" href="zotero.ftl"/>
@ -105,6 +107,14 @@
</div>
<div id="bottom-area" class="layout">
<div class="divider"></div>
<div id="citation-preview" class="vbox" data-dialog-type="citation">
<div id="citation-preview-wrapper" class="vbox">
<div id="citation-preview-content"></div>
<div id="citation-preview-error" data-l10n-id="integration-citationDialog-preview-error" hidden="true"></div>
</div>
<div class="divider subtle"></div>
</div>
<div id="bottom-area-wrapper" class="hbox">
<div id="dialog-type-setting" class="segmented-switch" data-arrow-nav="horizontal" data-tabindex="70" role="radiogroup" aria-orientation="horizontal">
<div id="dialog-type-citation" class="option keyboard-clickable" value="citation" role="radio" aria-checked="false" tabindex="-1" data-arrow-nav-enabled="true" data-l10n-id="integration-citationDialog-btn-type-citation">
@ -119,7 +129,9 @@
</div>
<div class="hbox" style="flex:1"></div>
<div id="bottom-btn-group" class="hbox">
<div id="dialog-mode-setting" data-dialog-type="citation,add-note" class="segmented-switch" role="radiogroup" data-arrow-nav="horizontal" data-tabindex="80" aria-orientation="horizontal">
<button id="display-preview-button" class="btn-icon" data-dialog-type="citation" data-l10n-id="integration-citationDialog-btn-displayPreview" aria-pressed="false" tabindex="-1" data-tabindex="80"></button>
<button id="settings-button" class="btn-icon icon-citation-dialog-settings" data-l10n-id="integration-citationDialog-btn-settings" tabindex="-1" data-tabindex="81"></button>
<div id="dialog-mode-setting" data-dialog-type="citation,add-note" class="segmented-switch" role="radiogroup" data-arrow-nav="horizontal" data-tabindex="82" aria-orientation="horizontal">
<div id="dialog-mode-list" class="option keyboard-clickable" value="list" role="radio" aria-checked="false" tabindex="-1" data-arrow-nav-enabled="true">
<span data-l10n-id="integration-citationDialog-mode-list"></span>
</div>
@ -127,13 +139,12 @@
<span data-l10n-id="integration-citationDialog-mode-library"></span>
</div>
</div>
<button id="settings-button" class="btn-icon icon-citation-dialog-settings" data-l10n-id="integration-citationDialog-btn-settings" tabindex="-1" data-tabindex="81"></button>
</div>
</div>
</div>
<div id="popups">
<xul:panel id="itemDetails">
<xul:panel id="itemDetails" consumeoutsideclicks="false">
<div class="vbox popup">
<div class="details-header hbox">
<span class="icon icon-item-type"></span>
@ -145,6 +156,9 @@
<!--fx128: size="0" forces select have default native style -->
<select name="locator" id="label" class="details-label" size="0"></select>
<input id="locator" class="details-data" aria-labelledby="label" aria-describedby="itemDetails-combinedInfo"/>
<div class="details-locator-info" data-l10n-id="integration-citationDialog-details-locator-info">
<a data-l10n-name="docs-link" class="link" tabindex="0" href="https://www.zotero.org/support/word_processor_plugin_usage#page_and_other_locators" onclick="Zotero.launchURL(this.href)"/>
</div>
<label class="details-label" for="prefix" data-l10n-id="integration-citationDialog-details-prefix"></label>
<input id="prefix" class="details-data"/>

View file

@ -49,8 +49,8 @@ export class CitationDialogKeyboardHandler {
// lower-level components
captureKeydown(event) {
let cmdOrCtrl = Zotero.isMac ? event.metaKey : event.ctrlKey;
// Cmd/Ctrl-Enter will always accept the dialog regardless of the target (unless within a panel)
if (event.key == "Enter" && cmdOrCtrl && !event.target.closest("panel")) {
// Cmd/Ctrl-Enter will always accept the dialog regardless of the target
if (event.key == "Enter" && cmdOrCtrl) {
this.doc.dispatchEvent(new CustomEvent("dialog-accepted"));
event.stopPropagation();
event.preventDefault();

View file

@ -99,6 +99,9 @@ export class CitationDialogPopupsHandler {
popup.style.top = `${bubbleRect.bottom + 10}px`;
this._getNode("#itemDetails .show").hidden = !this.bubbleItem.item.id;
// Hide the typed-locator tip once the shortcut has been used
this._getNode("#itemDetails .details-locator-info").hidden
= !Zotero.Prefs.get("integration.citationDialogShowLocatorTip");
let topLevelItem = this.bubbleItem.item.topLevelItem;
// Add header and fill inputs with their values

View file

@ -155,7 +155,7 @@ export class CitationDialogSearchHandler {
// for the library search to complete to show these results.
async refreshSelectedAndOpenItems() {
if (this.openItems === null) {
this.openItems = await this._getReaderOpenItems();
this.openItems = await this._getOpenTabItems();
}
if (this.selectedItems === null) {
this.selectedItems = this._getSelectedLibraryItems();
@ -291,15 +291,18 @@ export class CitationDialogSearchHandler {
}
// make sure that each item appears only in one group.
// Items that are selected are removed from opened.
// Items that are selected or opened are removed from cited.
// Items that are selected or opened or cited are removed from library results.
// Items that are opened are removed from selected.
// Items that are opened or selected are removed from cited.
// Items that are opened or selected or cited are removed from library results.
_deduplicate() {
let selectedIDs = new Set(this.results.selected.map(item => item.id));
let openIDs = new Set(this.results.open.map(item => item.id));
let citedIDs = new Set(this.results.cited.map(item => item.id));
this.results.open = this.results.open.filter(item => !selectedIDs.has(item.id));
this.results.selected = this.results.selected.filter(item => !openIDs.has(item.id));
if (this.results.selectedItems) {
this.results.selectedItems = this.results.selectedItems.filter(item => !openIDs.has(item.id));
}
this.results.cited = this.results.cited.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id));
this.results.found = this.results.found.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id) && !citedIDs.has(item.id));
}
@ -346,8 +349,10 @@ export class CitationDialogSearchHandler {
return citedItems;
}
async _getReaderOpenItems() {
if (this.dialogState.isAddingNote()) return [];
async _getOpenTabItems() {
// When adding a note, suggest notes open in note tabs. Otherwise, suggest
// items open in reader tabs.
let tabType = this.dialogState.isAddingNote() ? 'note' : 'reader';
let tabs = [];
let win = Zotero.getMainWindow();
// If the main window is open, use it to get open tabs
@ -360,34 +365,56 @@ export class CitationDialogSearchHandler {
if (!mainWindowLastState) return [];
tabs = mainWindowLastState.tabs;
}
let itemIDs = tabs.filter(t => t.type === 'reader').sort((a, b) => {
// Sort selected tab first
if (a.selected) return -1;
else if (b.selected) return 1;
// Then in reverse chronological select order
else if (a.timeUnselected && b.timeUnselected) return b.timeUnselected - a.timeUnselected;
// Then in reverse order for tabs that never got loaded in this session
else if (a.timeUnselected) return -1;
return 1;
}).map(t => t.data.itemID);
if (!itemIDs.length) return [];
tabs = tabs.filter(t => t.type === tabType);
if (!tabs.length) return [];
// Fetch top-most items and load necessary data, in case tabs belong to an unloaded library
let items = [];
for (let itemID of itemIDs) {
let item = await Zotero.Items.getAsync(itemID);
if (item && item.parentItemID) {
// Fetch each tab's top-most item and load necessary data, in case tabs belong
// to an unloaded library
let tabItems = [];
for (let tab of tabs) {
let item = await Zotero.Items.getAsync(tab.data.itemID);
if (item && item.parentItemID && tabType === 'reader') {
item = await Zotero.Items.getAsync(item.parentItemID);
}
items.push(item);
if (item) {
tabItems.push({ tab, item });
}
}
if (this.dialogState.isAddingAnnotations()) {
if (this.selectedItems === null) {
this.selectedItems = this._getSelectedLibraryItems();
}
let selectedItemIDs = new Set(this.selectedItems.map(item => item.id));
tabItems.sort((a, b) => {
// Sort selected tab first
if (a.tab.selected) return -1;
else if (b.tab.selected) return 1;
// Then tabs whose items are selected in the item tree, since they get
// deduplicated out of the Selected Items group
let aItemSelected = selectedItemIDs.has(a.item.id);
let bItemSelected = selectedItemIDs.has(b.item.id);
if (aItemSelected !== bItemSelected) return aItemSelected ? -1 : 1;
// Then in reverse chronological select order
if (a.tab.timeUnselected && b.tab.timeUnselected) return b.tab.timeUnselected - a.tab.timeUnselected;
// Then in reverse order for tabs that never got loaded in this session
else if (a.tab.timeUnselected) return -1;
return 1;
});
let items = tabItems.map(tabItem => tabItem.item);
if (this.dialogState.isAddingNote()) {
await Zotero.Items.loadDataTypes(items);
items = items.filter(i => i.isNote());
}
else if (this.dialogState.isAddingAnnotations()) {
// Make sure annotations are loaded on unloaded tabs from unloaded libraries
await this._ensureRelevantItemsAreLoaded(items);
items = this.keepItemsWithAnnotations(items);
}
else {
await Zotero.Items.loadDataTypes(items);
// Exclude non-citeable items (e.g., a standalone attachment open in a tab)
items = items.filter(i => i.isRegularItem());
}
// Return deduplicated items since there may be multiple tabs opened for the same
// top-level item (duplicate tabs or a multiple attachments belonging to the same item)
@ -395,7 +422,7 @@ export class CitationDialogSearchHandler {
}
_getSelectedLibraryItems() {
let selected = Zotero.getActiveZoteroPane()?.getSelectedItems() || [];
let selected = Zotero.getActiveZoteroPane()?.getSelectedItems(false, { libraryTabOnly: true }) || [];
if (this.dialogState.isAddingNote()) {
return selected.filter(i => i.isNote()) || [];
}

View file

@ -219,8 +219,6 @@ class ItemTreeRowProvider {
return true;
}
return row.isContainerEmpty({
searchMode: this._searchMode,
searchItemIDs: this._searchItemIDs,
includeTrashed: this._includeTrashed,
});
}
@ -258,6 +256,7 @@ class ItemTreeRowProvider {
let level = this.getLevel(index);
// Remove child rows
while ((index + 1 < this._rows.length) && (this.getLevel(index + 1) > level)) {
this.itemTree.selection.adjustForRowRemoval(index + 1, true);
this._removeRow(index + 1, true);
count++;
}
@ -283,6 +282,7 @@ class ItemTreeRowProvider {
count++;
this._addRow(childRows[i], index + i + 1, true);
}
this.itemTree.selection.adjustForRowInsertion(index, count);
this._rows[index].isOpen = true;
}
@ -292,14 +292,26 @@ class ItemTreeRowProvider {
}
toggleOpenState(index, skipRowMapRefresh = false) {
let selection = this.itemTree.selection;
let preserveDetachedFocus = !selection.isSelected(selection.focused);
this.itemTree._cacheState();
let selectedBefore = this.itemTree._cachedSelection;
this._toggleOpenState(index, skipRowMapRefresh);
// Preserve viewport when toggling a container instead of jumping to the current selection.
this.runListeners('update', true, {
restoreSelection: true,
restoreSelection: !preserveDetachedFocus,
expandCollapsedParents: false,
restoreScroll: true,
});
if (preserveDetachedFocus) {
// Collapsing a container with selected descendants moves their selection to the
// container row, and listeners have to be notified of the new selection
let selectedAfter = this.itemTree.getSelectedObjects();
if (selectedAfter.length != selectedBefore.length
|| selectedAfter.some(ref => !selectedBefore.includes(ref))) {
selection._updateTree();
}
}
}
/**
@ -1046,11 +1058,23 @@ var ItemTree = class ItemTree extends LibraryTree {
get visibilityGroup() {
return 'default';
}
get viewType() {
return 'library';
}
/**
* Trees not backed by a collection tree selection (e.g. the citation
* explorer's) have no rows and no special view behavior
*/
get collectionTreeRows() {
return [];
}
get viewMode() {
return 'default';
}
get isSortable() {
return true;
}
@ -1121,6 +1145,7 @@ var ItemTree = class ItemTree extends LibraryTree {
for (let id of this._prefsObserverIDs) {
Zotero.Prefs.unregisterObserver(id);
}
this.clearEventListeners();
this._writeColumnPrefsToFile(true);
}
@ -1333,6 +1358,12 @@ var ItemTree = class ItemTree extends LibraryTree {
}
handleActivate(event, indices) {
// Skip non-selectable rows (e.g. library headers and spacers), which aren't real items
// and can be double-clicked via the opaque sticky header
indices = indices.filter(index => this.isSelectable(index));
if (!indices.length) {
return;
}
let items = indices.map(index => this.getRow(index).ref);
this.props.onActivate(event, items);
}
@ -1346,9 +1377,13 @@ var ItemTree = class ItemTree extends LibraryTree {
return false;
}
// Handle arrow keys specially on multiple selection, since
// otherwise the tree just applies it to the last-selected row
if (this.selection.count > 1 && ["ArrowLeft", "ArrowRight"].includes(event.key)) {
// If selection count is greater than 1 and the focused row wasn't
// moved out of the selection - toggle open/closed all rows in that selection
// Otherwise if the focused row has moved out of the selection, toggle state
// of the focused row (handled in virtualized-table and this conditional should match that)
if (this.selection.count > 1
&& this.selection.isSelected(this.selection.focused)
&& [Zotero.arrowPreviousKey, Zotero.arrowNextKey].includes(event.key)) {
if (event.key == Zotero.arrowNextKey) {
this.expandSelectedRows();
}
@ -1424,6 +1459,9 @@ var ItemTree = class ItemTree extends LibraryTree {
multiSelect: this.props.multiSelect,
stickySectionHeaders: true,
isSectionHeader: index => this.getRow(index)?.type == 'library-header',
onSelectionChange: this._handleSelectionChange.bind(this),
isSelectable: this.isSelectable.bind(this),
getParentIndex: this.getParentIndex.bind(this),
@ -1492,23 +1530,31 @@ var ItemTree = class ItemTree extends LibraryTree {
await this.expandToItem(id);
if (!this._rowMap[id]) {
// The row is still not found
// Clear the quick search and tag selection and try again (once)
// Clear the quick search, tag selection, and advanced search
// and try again (once)
if (!noRecurse && window.ZoteroPane) {
let hasQuickSearch = !!this.collectionTreeRow.searchText;
let hasTagFilters = this.collectionTreeRow.tags?.size > 0;
if (hasQuickSearch || hasTagFilters) {
// Clear all searches set on the collection tree row directly on
// collectionTreeRow (vs using ZoteroPane functions) to avoid
let hasQuickSearch = !!this.collectionTreeRows[0].searchText;
let hasTagFilters = this.collectionTreeRows[0].tags?.size > 0;
let hasAdvancedSearch = !!this.collectionTreeRows[0].advancedSearch;
if (hasQuickSearch || hasTagFilters || hasAdvancedSearch) {
// Clear all searches set on the collection tree rows directly on
// the rows (vs using ZoteroPane functions) to avoid
// refreshing the itemTree multiple times at the same time, which can lead
// to tag selector not showing all tags after quickSearch is cleared
this.collectionTreeRow.setTags(new Set());
this.collectionTreeRow.setSearch('');
// Clear quickSearch text field and tag selection without
// rerunning search
for (let collectionTreeRow of this.collectionTreeRows) {
collectionTreeRow.setTags(new Set());
collectionTreeRow.setSearch('');
collectionTreeRow.setAdvancedSearch(null);
}
// Clear tag selection, quicksearch text field, and advanced search
// without rerunning search
if (window.ZoteroPane.tagSelector) {
window.ZoteroPane.tagSelector.clearTagSelection();
}
window.ZoteroPane.clearQuicksearch(true);
if (hasAdvancedSearch) {
await window.ZoteroPane.setAdvancedSearchState('closed', { skipRefresh: true });
}
// Rerun search and refresh the itemTree
await this.refreshAndMaintainSelection();
// Try to select the item(s) again
@ -1782,7 +1828,19 @@ var ItemTree = class ItemTree extends LibraryTree {
* @return {Zotero.Item[]|Integer[]} - An array of Zotero.Item objects or itemIDs
*/
getSortedItems(asIDs) {
return this._rows.map(row => asIDs ? row.ref.id : row.ref);
return this._rows
.filter(row => row.isObjectRow)
.map(row => asIDs ? row.ref.id : row.ref);
}
/**
* Number of visible rows representing objects, excluding library headers
* and spacers
*
* @return {Integer}
*/
get objectRowCount() {
return this._rows.reduce((count, row) => count + (row.isObjectRow ? 1 : 0), 0);
}
/**
@ -2131,7 +2189,7 @@ var ItemTree = class ItemTree extends LibraryTree {
.forEach((column, i) => {
let menuItem = document.createXULElement('menuitem');
menuItem.setAttribute('type', 'checkbox');
menuItem.setAttribute('checked', this.getSortField() == column.dataKey);
menuItem.toggleAttribute('checked', this.getSortField() == column.dataKey);
menuItem.setAttribute('label', formatColumnName(column));
menuItem.addEventListener('command', () => {
this.toggleSort(i, true);
@ -2186,10 +2244,23 @@ var ItemTree = class ItemTree extends LibraryTree {
div.classList.toggle('first-highlighted', this._highlightedRows.has(rowData.id) && !this._highlightedRows.has(prevRowID));
div.classList.toggle('last-highlighted', this._highlightedRows.has(rowData.id) && !this._highlightedRows.has(nextRowID));
div.classList.toggle('annotation-row', row.type === 'annotation');
div.classList.toggle('library-header-row', row.type === 'library-header');
div.classList.toggle('spacer-row', row.type === 'spacer');
if (row.type !== 'annotation') {
div.classList.remove('tight');
}
let { firstColumn } = columns.reduce((acc, column) => {
return !column.hidden && column.ordinal < acc.lowestOrdinal
? { lowestOrdinal: column.ordinal, firstColumn: column }
: acc;
}, { lowestOrdinal: Infinity, firstColumn: null });
this._renderCtx.firstColumn = firstColumn;
this._renderCtx.includeTrashed = this.rowProvider.includeTrashed;
row.renderRow(div, index, columns, rowData, this._renderCtx);
if (this._dropRow == index) {
let span;
if (Zotero.DragDrop.currentOrientation != 0) {
@ -2202,17 +2273,6 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
let { firstColumn } = columns.reduce((acc, column) => {
return !column.hidden && column.ordinal < acc.lowestOrdinal
? { lowestOrdinal: column.ordinal, firstColumn: column }
: acc;
}, { lowestOrdinal: Infinity, firstColumn: null });
this._renderCtx.firstColumn = firstColumn;
this._renderCtx.includeTrashed = this.rowProvider.includeTrashed;
row.renderRow(div, index, columns, rowData, this._renderCtx);
if (!oldDiv) {
if (this.props.dragAndDrop && row.isDraggable) {
div.setAttribute('draggable', true);
@ -2344,8 +2404,8 @@ var ItemTree = class ItemTree extends LibraryTree {
row.numNotes = treeRow.numNotes() || "";
row.feed = (treeRow.ref.isFeedItem && Zotero.Feeds.get(treeRow.ref.libraryID).name) || "";
row.lastRead = row.isItem ? treeRow.ref.getItemLastRead() : "";
row.addedBy = row.isItem && treeRow.getAddedBy();
row.lastModifiedBy = row.isItem && treeRow.getLastModifiedBy();
row.addedBy = row.isItem ? treeRow.getAddedBy() : "";
row.lastModifiedBy = row.isItem ? treeRow.getLastModifiedBy() : "";
row.title = treeRow.getDisplayTitle();
const columns = this.getColumns();
@ -2559,7 +2619,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
// Initial hidden value
else if (!("hidden" in column)) {
if (hasDefaultIn && this.collectionTreeRow) {
if (hasDefaultIn && this.collectionTreeRows.length) {
column.hidden = !(column.defaultIn && this._matchesViewType(column.defaultIn));
}
else {
@ -2585,7 +2645,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
// Force sort indicator for views with a fixed sort order
if (this.collectionTreeRow?.isRecentlyRead()) {
if (this.viewMode == 'recentlyRead') {
let col = this._columns.find(c => c.dataKey === 'lastRead');
if (col) {
col.sortDirection = -1;
@ -2593,7 +2653,24 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
return this._columns.sort((a, b) => a.ordinal - b.ordinal);
let sortedColumns = this._columns.sort((a, b) => a.ordinal - b.ordinal);
// If no column has an explicit sort direction (e.g., a fresh profile that
// has never had a column header clicked), mark the default sort column --
// the first visible column, matching getSortField() -- as the sorted
// column. The view is already sorted ascending by this column (see
// getSortDirection()'s fallback), but without an explicit sortDirection
// the first click on the header would just set the direction to its
// default instead of reversing the sort.
if (!this._sortedColumn) {
let defaultColumn = sortedColumns.find(column => !column.hidden);
if (defaultColumn) {
defaultColumn.sortDirection = 1;
this._sortedColumn = defaultColumn;
}
}
return sortedColumns;
}
_getColumn(index) {
@ -2762,7 +2839,7 @@ var ItemTree = class ItemTree extends LibraryTree {
else {
if (this._sortedColumn) {
delete this._sortedColumn.sortDirection;
if (columnSettings[column.dataKey]) {
if (columnSettings[this._sortedColumn.dataKey]) {
delete columnSettings[this._sortedColumn.dataKey].sortDirection;
}
}

View file

@ -33,6 +33,16 @@ class ItemTreeRow {
return 'item';
}
/**
* Whether the row represents an object in the view, as opposed to a
* library header or spacer. Callers that operate on the view's contents
* (counting rows, collecting items, walking top-level rows) should skip
* rows where this is false.
*/
get isObjectRow() {
return true;
}
get isDraggable() {
return false;
}
@ -451,17 +461,21 @@ class FileItemTreeRow extends ZoteroItemTreeRow {
return true;
}
isContainerEmpty({ searchMode, searchItemIDs } = {}) {
if (Zotero.Prefs.get("hideContextAnnotationRows") && searchMode) {
return !this.ref.getAnnotations().some(annotation => searchItemIDs.has(annotation.id));
}
isContainerEmpty() {
return this.ref.numAnnotations() == 0;
}
getChildItems({ searchMode, searchItemIDs } = {}) {
let annotations = this.ref.getAnnotations();
if (Zotero.Prefs.get("hideContextAnnotationRows") && searchMode) {
annotations = annotations.filter(annotation => searchItemIDs.has(annotation.id));
// With "Hide Non-Matching Annotations" enabled, if any of the attachment's
// annotations match a search, show only those and hide the rest. If none match,
// show them all, since otherwise the attachment couldn't be expanded to browse its
// annotations at all.
if (searchMode && Zotero.Prefs.get("hideContextAnnotationRows")) {
let matches = annotations.filter(annotation => searchItemIDs.has(annotation.id));
if (matches.length) {
annotations = matches;
}
}
return annotations;
}
@ -631,6 +645,100 @@ class SearchItemTreeRow extends ItemTreeRow {
* Dispatch order: Collection, Search, annotation item, file attachment item,
* generic Zotero.Item, and finally the base ItemTreeRow fallback.
*/
/**
* Non-selectable section header row shown above each library's items when the
* items list displays a multi-library selection. Wraps a Zotero.Library.
*/
class LibraryHeaderItemTreeRow extends ItemTreeRow {
constructor(library, label, iconName) {
super(library, 0, false);
// Label and icon are computed from the selection (see
// CollectionViewItemTreeRow._computeSectionHeaders); fall back to the library's
// own name/icon when not provided
this._label = label;
this._iconName = iconName;
}
get type() {
return 'library-header';
}
get isObjectRow() {
return false;
}
getDisplayTitle() {
return this._label ?? Zotero.Libraries.getName(this.ref.libraryID);
}
getField(field) {
if (field == 'title') {
return this.getDisplayTitle();
}
return '';
}
getIcon() {
// An explicit null icon name means render no icon (e.g. the single-library
// summary header), distinct from undefined (use the library's own icon)
if (this._iconName === null) {
return null;
}
let iconName = this._iconName
?? (this.ref.libraryType == 'group' ? 'library-group' : 'library');
let icon = getCSSIcon(iconName);
icon.classList.add('icon-item-type');
return icon;
}
renderRow(div, _index, _columns, _rowData, _renderCtx) {
// Single cell with an optional icon and the label, spanning the row
let span = document.createElement('span');
span.className = 'cell primary library-header';
let textSpan = document.createElement('span');
textSpan.className = 'cell-text';
textSpan.textContent = this.getDisplayTitle();
let icon = this.getIcon();
if (icon) {
span.append(icon, textSpan);
}
else {
// No icon, but reserve its space so the label still lines up with item titles
let spacer = document.createElement('span');
spacer.className = 'library-header-icon-spacer';
span.append(spacer, textSpan);
}
div.appendChild(span);
}
}
/**
* A blank, non-selectable row providing one row of whitespace above a library header
* (see CollectionViewItemTreeRow._insertLibraryHeaders). Kept separate from the header
* row so the header stays a uniform height and pins flush to the top when sticky.
*/
class SpacerItemTreeRow extends ItemTreeRow {
constructor(library) {
super(library, 0, false, 'spacer-' + library.libraryID);
}
get type() {
return 'spacer';
}
get isObjectRow() {
return false;
}
getField() {
return '';
}
renderRow(_div, _index, _columns, _rowData, _renderCtx) {
// Intentionally empty -- the row's height alone provides the whitespace
}
}
ItemTreeRow.create = function (ref, level, isOpen) {
if (ref instanceof Zotero.Collection) return new CollectionItemTreeRow(ref, level, isOpen);
if (ref instanceof Zotero.Search) return new SearchItemTreeRow(ref, level, isOpen);
@ -646,3 +754,5 @@ module.exports.FileItemTreeRow = FileItemTreeRow;
module.exports.AnnotationItemTreeRow = AnnotationItemTreeRow;
module.exports.CollectionItemTreeRow = CollectionItemTreeRow;
module.exports.SearchItemTreeRow = SearchItemTreeRow;
module.exports.SpacerItemTreeRow = SpacerItemTreeRow;
module.exports.LibraryHeaderItemTreeRow = LibraryHeaderItemTreeRow;

View file

@ -56,6 +56,22 @@ var LibraryTree = class LibraryTree extends React.Component {
return this._waitForEvent('select');
}
componentDidMount() {
// Create an element where we can create drag images to be displayed next to the cursor while dragging
// since for multiple item drags we need to display all the elements
let elem = this._dragImageContainer = document.createElement("div");
elem.style.width = "100%";
elem.style.height = "2000px";
elem.style.position = "absolute";
elem.style.top = "-10000px";
elem.className = "drag-image-container";
this.domEl.appendChild(elem);
}
componentWillUnmount() {
this.domEl.removeChild(this._dragImageContainer);
}
componentDidCatch(error, info) {
// Async operations might attempt to update the react components
// after window close in tests, which will cause unnecessary crashing
@ -118,6 +134,28 @@ var LibraryTree = class LibraryTree extends React.Component {
return this._rowMap[id];
}
/**
* Get selected tree rows
*/
getSelectedRows() {
var indexes = this.selection ? Array.from(this.selection.selected) : [];
indexes = indexes.filter(index => index < this._rows.length);
try {
return indexes.map(index => this.getRow(index));
}
catch (e) {
Zotero.debug(indexes);
throw e;
}
}
/**
* Get selected objects, including collections and searches in the trash in item trees
*/
getSelectedObjects() {
return this.getSelectedRows().map(row => row.ref);
}
/**
* Add a tree row to the main array, update the row count, tell the treebox that the row
* count changed, and update the row map
@ -157,8 +195,8 @@ var LibraryTree = class LibraryTree extends React.Component {
let level = this.getLevel(index);
// Maintain selection unless specified otherwise
if (!skipSelectionUpdate && index <= this.selection.focused) {
this.selection.select(this.selection.focused - 1);
if (!skipSelectionUpdate) {
this.selection.adjustForRowRemoval(index);
}
this._rows.splice(index, 1);
@ -214,16 +252,6 @@ var LibraryTree = class LibraryTree extends React.Component {
this.tree && this.tree.scrollToRow(index);
}
_updateHeight = () => {
this.forceUpdate(() => {
if (this.tree) {
this.tree.rerender();
}
});
}
updateHeight = Zotero.Utilities.debounce(this._updateHeight, 200);
updateFontSize() {
this.tree.updateFontSize();
}
@ -248,11 +276,45 @@ var LibraryTree = class LibraryTree extends React.Component {
// the same action as the dropEffect. This allows the dropEffect setting
// (which we use in the tree's canDrop() and drop() to determine the desired
// action) to be changed, even if the cursor doesn't reflect the new setting.
//
// The effect also has to be one of the actions allowed at drag start: on Windows,
// OLE refuses the drop entirely if it isn't. Some drags allow only 'copy' (see
// Zotero.Utilities.Internal.onDragItems()), so a 'move' within Zotero has to be sent
// as a 'copy'. The trees' onDrop() handlers act on the effect set here, kept in
// Zotero.DragDrop.currentDropEffect, rather than on the drop event's dropEffect, so
// the drop still moves.
Zotero.DragDrop.currentDropEffect = effect;
let allowed = event.dataTransfer.effectAllowed;
if (effect != 'none' && allowed && !['uninitialized', 'all'].includes(allowed)
&& !allowed.toLowerCase().includes(effect)) {
effect = ['copy', 'move', 'link'].find(x => allowed.toLowerCase().includes(x)) || 'none';
}
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = effect;
}
event.dataTransfer.dropEffect = effect;
}
/**
* Start a drag using HTML 5 Drag and Drop
*/
onDragStart(event, index) {
// Propagate selection before we set the drag image if dragging not one of the selected rows
if (!this.selection.isSelected(index)) {
this.selection.select(index);
}
// Set drag image
const dragElems = this.domEl.querySelectorAll('.selected');
for (let elem of dragElems) {
elem = elem.cloneNode(true);
elem.style.position = "initial";
this._dragImageContainer.appendChild(elem);
}
}
onDragEnd(_event, _index) {
this._dragImageContainer.innerHTML = "";
}
};
Zotero.Utilities.Internal.makeClassEventDispatcher(LibraryTree);

@ -1 +1 @@
Subproject commit 938c09c164a60b3cfeabe61089f1bf4c3ea6a4ed
Subproject commit bc0a222b4ca526126faf892c35b2b7b1215c10eb

View file

@ -29,7 +29,7 @@ var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUti
* This object contains the various functions for the interface
*/
var Zotero_LocateMenu = new function () {
XPCOMUtils.defineLazyServiceGetter(this, "ios", "@mozilla.org/network/io-service;1", "nsIIOService");
XPCOMUtils.defineLazyServiceGetter(this, "ios", "@mozilla.org/network/io-service;1", Ci.nsIIOService);
/**
* Clear and build the locate menu

View file

@ -28,6 +28,9 @@
* @namespace
*/
var Zotero_Lookup = new function () {
this._button = null;
this._accepted = false;
/**
* Performs a lookup by DOI, PMID, or ISBN on the given textBox value
* and adds any items it can.
@ -41,7 +44,6 @@ var Zotero_Lookup = new function () {
* @param toggleProgress {function} - Callback to toggle progress on/off
* @returns {Promise<Zotero.Item[]>}
*/
this._button = null;
this.addItemsFromIdentifier = async function (textBox, childItem, toggleProgress) {
var identifiers = Zotero.Utilities.extractIdentifiers(textBox.value);
if (!identifiers.length) {
@ -70,9 +72,9 @@ var Zotero_Lookup = new function () {
}
else {
try {
libraryID = ZoteroPane.getSelectedLibraryID();
let collection = ZoteroPane.getSelectedCollection();
collections = collection ? [collection.id] : false;
libraryID = ZoteroPane.getSelectedLibraryIDs()[0];
let selectedCollections = ZoteroPane.getSelectedCollections();
collections = selectedCollections.length ? selectedCollections.map(c => c.id) : false;
}
catch (e) {
/** TODO: handle this **/
@ -137,6 +139,8 @@ var Zotero_Lookup = new function () {
* Try a lookup and hide popup if successful
*/
this.accept = async function (textBox) {
this._accepted = true;
let newItems = await Zotero_Lookup.addItemsFromIdentifier(
textBox,
false,
@ -147,7 +151,12 @@ var Zotero_Lookup = new function () {
// Send the focus to the item tree after the popup closes
ZoteroPane.lastFocusedElement = null;
document.getElementById("zotero-lookup-panel").hidePopup();
document.getElementById("item-tree-main-default").focus();
// The item tree's DOM id has a view-specific suffix, so use the current view's id
document.getElementById(ZoteroPane.itemsView.id).focus();
}
else {
// Hide on failure too
document.getElementById("zotero-lookup-panel").hidePopup();
}
return false;
};
@ -163,6 +172,11 @@ var Zotero_Lookup = new function () {
};
this.onFocusOut = function (event) {
// Ignore focus loss caused by the window being deactivated
if (Services.focus.activeWindow !== window) {
return;
}
// If the lookup popup was triggered by the lookup button,
// we want to return there on focus out. So we check
// (1) that we came from a button and (2) that
@ -182,14 +196,53 @@ var Zotero_Lookup = new function () {
};
/**
* Focuses the field
*/
this.onShown = function (event) {
// Ignore context menu
if (event.originalTarget.id != 'zotero-lookup-panel') return;
this._accepted = false;
// Focus the field
this.getActivePanel().querySelector('textarea').focus();
// Add handlers to dismiss the popup when contextually appropriate.
// We set noautohide="true" so the popup doesn't lose input when
// switching windows (e.g., so you can build a long list of identifiers
// copied from another app), but that means we need to manually handle
// closing on click outside (_onMouseDown) and closing on a window
// switch when there's no content (_onBlur)
window.addEventListener('mousedown', this._onMouseDown, { capture: true });
document.getElementById('zotero-lookup-panel').addEventListener('blur', this._onBlur, { capture: true });
};
/**
* Hide on a click outside the panel
*/
this._onMouseDown = (event) => {
// Ignore clicks inside the panel or any popup (e.g. its context menu);
// only a click outside dismisses it
if (event.target.closest("panel, menupopup")) {
return;
}
document.getElementById("zotero-lookup-panel").hidePopup();
// Prevent the toolbar button's own handlers from triggering, so the
// popup doesn't immediately reopen
if (document.getElementById("zotero-tb-lookup").contains(event.target)) {
event.preventDefault();
event.stopPropagation();
}
};
this._onBlur = () => {
if (Services.focus.activeWindow === window) {
return;
}
let textBox = document.getElementById('zotero-lookup-textbox');
if (textBox.value.trim() === '') {
document.getElementById('zotero-lookup-panel').hidePopup();
}
};
@ -200,7 +253,12 @@ var Zotero_Lookup = new function () {
// Ignore context menu
if (event.originalTarget.id != 'zotero-lookup-panel') return;
document.getElementById("zotero-lookup-textbox").value = "";
window.removeEventListener('mousedown', this._onMouseDown, { capture: true });
document.getElementById('zotero-lookup-panel').removeEventListener('blur', this._onBlur, { capture: true });
if (this._accepted) {
document.getElementById("zotero-lookup-textbox").value = "";
}
Zotero_Lookup.setShowProgress(false);
// Revert to single-line when closing

View file

@ -298,7 +298,7 @@ var Zotero_Merge_Window = new function () {
var nextButton = _wizard.getButton("next");
if (Zotero.isMac) {
nextButton.setAttribute("hidden", "false");
nextButton.removeAttribute("hidden");
_wizard.getButton("finish").setAttribute("hidden", "true");
}
else {
@ -315,7 +315,7 @@ var Zotero_Merge_Window = new function () {
if (Zotero.isMac) {
_wizard.getButton("next").setAttribute("hidden", "true");
finishButton.setAttribute("hidden", "false");
finishButton.removeAttribute("hidden");
}
// Windows and Linux use a deck to switch between the Next and Finish buttons
else {

View file

@ -4,6 +4,10 @@ export function mergeItems(item, otherItems) {
Zotero.debug("Merging items");
return Zotero.DB.executeTransaction(async function () {
Zotero.UndoHistory.stageAction(
'undo-action-merge-items',
{ count: otherItems.length + 1 }
);
var toSave = {};
toSave[item.id] = item;

Some files were not shown because too many files have changed in this diff Show more