mirror of
https://github.com/zotero/zotero.git
synced 2026-10-04 02:31:47 +00:00
Merge branch 'main' into local-api-reader-open-items
This commit is contained in:
commit
08f9bfa53f
689 changed files with 63808 additions and 10976 deletions
386
.github/workflows/ci.yml
vendored
386
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
169
app/build.sh
169
app/build.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
app/scripts/check_app_matches_commit
Executable file
37
app/scripts/check_app_matches_commit
Executable 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
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
322
app/scripts/incremental_update
Executable 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"
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e66cbd095e4b714ef5f2494240190f67da78af77053368b65f380c7c7ef95f41
|
||||
size 393284
|
||||
oid sha256:13a76afa9dac4bfbb5f1a9b2c69ecc82f300a6f52a673c562da8f30a11b62438
|
||||
size 876372
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:070a38196290224fcb0db22bbf0aa739d60188cf3df6137ced3b26819b7e98b6
|
||||
size 524100
|
||||
oid sha256:5ab890f9893d17d1b492f6762adc9d60d29e405eb089ca1311e522cb987cbca9
|
||||
size 559376
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} `;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
83
chrome/content/zotero/elements/advancedSearchDeck.js
Normal file
83
chrome/content/zotero/elements/advancedSearchDeck.js
Normal 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);
|
||||
}
|
||||
407
chrome/content/zotero/elements/advancedSearchPane.js
Normal file
407
chrome/content/zotero/elements/advancedSearchPane.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || "" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
385
chrome/content/zotero/elements/queryTextbox.js
Normal file
385
chrome/content/zotero/elements/queryTextbox.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
264
chrome/content/zotero/elements/searchTextbox.js
Normal file
264
chrome/content/zotero/elements/searchTextbox.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
246
chrome/content/zotero/import/mendeley/mendeleyDecrypt.mjs
Normal file
246
chrome/content/zotero/import/mendeley/mendeleyDecrypt.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) || [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue