Compare commits

..

No commits in common. "main" and "8.0.0" have entirely different histories.
main ... 8.0.0

936 changed files with 26392 additions and 99230 deletions

View file

@ -1,352 +1,93 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
schedule:
# Monthly, to catch runner image and OS changes
- cron: '0 6 1 * *'
on: [push, pull_request]
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
# 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
build:
name: Build, Upload, Test
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: 24
node-version: 18
#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-24-${{ hashFiles('package-lock.json') }}
key: node-modules-${{ 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
# Currently necessary for pdf-worker Webpack: https://stackoverflow.com/a/69746937
env:
NODE_OPTIONS: --openssl-legacy-provider
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
# 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
# (e.g., resolved symlinks, missing files).
- name: Create deployment ZIP
- name: Upload deployment ZIP
if: |
env.ACT != 'true'
&& github.repository == 'zotero/zotero'
&& github.event_name == 'push'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fx128' || endsWith(github.ref, '-hotfix'))
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
mkdir build-zip
cd build
zip -r ../build.zip *
zip -r ../build-zip/$GITHUB_SHA.zip *
cd ..
- name: Replace build with ZIP contents
run: |
rm -rf build
mkdir build
cd build
unzip ../build.zip
- 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
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
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
run: xvfb-run test/runtests.sh -f
- 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') }}
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'
@ -355,73 +96,3 @@ jobs:
- 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'
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:
ruby-version: '3.3'
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Upload deployment ZIP
if: steps.check-deploy.outputs.deploy == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
mkdir build-zip
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

4
.gitmodules vendored
View file

@ -30,8 +30,8 @@
url = https://github.com/zotero/reader.git
branch = master
[submodule "pdf-worker"]
path = document-worker
url = https://github.com/zotero/document-worker.git
path = pdf-worker
url = https://github.com/zotero/pdf-worker.git
branch = master
[submodule "note-editor"]
path = note-editor

110
CLAUDE.md
View file

@ -1,110 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Zotero is a Firefox-based desktop research management application. It runs as a XUL/XHTML application on a Mozilla platform (not Electron, not a web app). The UI uses a mix of XUL/XHTML, custom elements (Web Components), and React components.
## Build & Development Commands
```bash
npm install # Install dependencies
npm run build # Full build (Babel transpile JS/JSX, compile SCSS, browserify, symlink)
npm run clean-build # Clean build output and rebuild
```
### Running Tests
Tests run inside a built Zotero application instance via `test/runtests.sh`:
```bash
test/runtests.sh # Run all tests
test/runtests.sh item # Run a single test file (omit "Test" and .js extension)
test/runtests.sh item collections # Run multiple test files
test/runtests.sh -g "pattern" # Run only tests matching a grep pattern
test/runtests.sh -f # Stop after first failure
test/runtests.sh -s item # Start at a specific test file
test/runtests.sh -e item # End at a specific test file
test/runtests.sh -c # Open JS console, don't quit on completion
test/runtests.sh -b # Skip bundled translator/style installation (for faster startup when not necessary)
test/runtests.sh -d 5 # Enable debug logging (level 1-5; default is 3)
```
Tests use Mocha + Chai (assert style) + Sinon. Test files live in `test/tests/` and follow the naming convention `<module>Test.js`. Test helpers are in `test/content/support.js`.
The test runner automatically triggers `npm run build` if the watch process isn't running, so it's not necessary to manually run a build before running tests.
Tips:
- Pass flags before test names (e.g., `test/runtests.sh -f item`).
- `-f` should almost always be used, since test failures can cause later spurious test failures.
- To view debug logging for a specific test when running multiple tests, add `Zotero.Debug.init(1)` at the beginning of the test and run with `-d 5`.
### Linting
```bash
npx eslint <file> # Lint a specific file
```
ESLint v9 flat config is in `eslint.config.mjs`. Uses `@zotero/eslint-config`, Babel parser, and React plugin. Currently, the code base is not fully linted, so only worry about issues with new or modified code lines.
## Architecture
### Core Layers
- **XPCOM modules** (`chrome/content/zotero/xpcom/`) -- Core business logic. Loaded sequentially by `chrome/content/zotero/zotero.mjs`. All modules attach to the global `Zotero` namespace (e.g., `Zotero.Items`, `Zotero.Sync.Runner`).
- **Data model** (`chrome/content/zotero/xpcom/data/`) -- ORM-like classes for database entities. `Zotero.DataObject` is the base class; `Zotero.Item`, `Zotero.Collection`, `Zotero.Search`, `Zotero.Library`, etc. extend it. Plural classes (`Zotero.Items`, `Zotero.Collections`) manage object caches and queries. Data objects use an async `saveTx()` pattern for persistence.
- **Database** (`chrome/content/zotero/xpcom/db.js`) -- SQLite via Mozilla's mozStorage API. Accessed through `Zotero.DB.queryAsync()`, `Zotero.DB.executeTransaction()`.
- **Sync system** (`chrome/content/zotero/xpcom/sync/`) -- `syncRunner.js` orchestrates sync. `syncEngine.js` handles object-level sync logic. `syncAPIClient.js` communicates with the Zotero API server. `syncLocal.js` manages local sync state. Similar `storage*` files for file syncing, along with zfs.js (Zotero Storage) and webdav.js (WebDAV).
- **HTTP server** (`chrome/content/zotero/xpcom/server/`) -- Local HTTP server for browser connector integration and local API.
### UI Layers
- **Main window**: `chrome/content/zotero/zoteroPane.xhtml` + `zoteroPane.js`
- **Custom elements** (`chrome/content/zotero/elements/`) -- XUL custom elements inheriting from `XULElementBase` (defined in `elements/base.js`), which provides lifecycle helpers (`init()`, `destroy()`, `content` getter for templates). Registered via `customElements.define()`.
- **React components** (`chrome/content/zotero/components/`) -- Used for complex interactive UI (tag selector, virtualized table, item tree, collection tree). Major tree views are `itemTree.jsx` and `collectionTree.jsx`.
- **SCSS styles** (`scss/`) -- Compiled to CSS. Platform-specific overrides in `scss/mac/`, `scss/win/`, `scss/linux/`.
- **Localization** -- Fluent (`.ftl` files) in `chrome/locale/en-US/zotero/`. Accessed via `data-l10n-id` attributes or `Zotero.getString()` for legacy `.properties` strings. All new strings must be added to `.ftl` files -- do not add new strings to `.properties` or `.dtd` files.
### Submodules
Several features are developed in separate repos and included as Git submodules:
- `reader/` -- PDF/EPUB/snapshot reader with annotations
- `note-editor/` -- Rich text note editor
- `document-worker/` -- PDF processing (extraction, manipulation)
- `translators/` -- 760+ web translators for importing metadata from websites
- `styles/` -- CSL citation styles
- `chrome/content/zotero/xpcom/utilities/` -- Shared utility library
- `chrome/content/zotero/xpcom/translate/` -- Translation framework
- `resource/SingleFile/` -- Web page archiving
### Build System (`js-build/`)
Custom Node.js build system (not Webpack). `js-build/config.js` defines what gets built:
- **JS/JSX files** in `chrome/`, `components/`, `defaults/`, `test/` are transpiled via Babel (React JSX, CommonJS modules)
- **SCSS files** in `scss/` and `chrome/skin/` are compiled with Dart Sass
- **Browserify** bundles select npm packages (react-select, sinon, url) for use in the XUL environment
- **Symlinks** connect source files and submodules into the `build/` directory
- The built app reads from `build/`, not directly from source
## Code Style
- Tabs for indentation, not spaces
- Use `let` instead of `const` except for true scalar constants (e.g., `const MAX_SECONDS = 5 * 60;`)
- No cuddled braces (opening brace on same line, but `else`/`catch`/etc. on their own line)
- Use two hyphens `--` in comments, not an em dash
- Indent blank lines to match surrounding indentation level
- Objects attach to the `Zotero` global namespace rather than using ES module exports
- Async code uses `async`/`await` throughout
- Mozilla/XPCOM APIs are available globally: `Cc`, `Ci`, `Cu`, `Cr`, `Services`, `ChromeUtils`, `IOUtils`, `PathUtils`
## Translators
See `translators/CLAUDE.md` for guidelines. Key rules: **never** generate translators from scratch, **never** generate UUIDs, **never** generate test cases -- all of these must be done through Zotero's Scaffold tool.

View file

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

View file

@ -4,5 +4,4 @@
-brand-product-name = Zotero
-vendor-short-name = Zotero
-app-name = Zotero
-subscription-name = Zotero Storage
trademarkInfo = Zotero is a trademark of the Corporation for Digital Scholarship.

View file

@ -50,10 +50,8 @@ 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;
Services.ww.openWindow(
null,

View file

@ -42,7 +42,7 @@ Options
-f FILE ZIP file to build from (cannot be used with -d)
-t add devtools
-p PLATFORMS build for platforms PLATFORMS (m=Mac, w=Windows, l=Linux)
-a ARCH architecture to build (arm64, x64, win32)
-a ARCH architecture to build (arm64, x64, i686, win32)
* Ignored for Mac (always universal)
* If omitted on Windows/Linux, all standard archs are built
-c CHANNEL use update channel CHANNEL
@ -59,17 +59,6 @@ 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);
}
@ -221,7 +210,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; do
for _a in x64 arm64 i686; do
check_xulrunner_hash l "$_a"
done
fi
@ -307,7 +296,7 @@ elif [[ $BUILD_LINUX == 1 ]]; then
if [[ -n $arch ]]; then
omni_arch=$(get_canonical_arch l $arch)
else
for cand in x86_64 arm64; do
for cand in x86_64 arm64 i686; do
[[ -d "${LINUX_RUNTIME_PATH_PREFIX}${cand}" ]] && { omni_arch="$cand"; break; }
done
fi
@ -322,6 +311,7 @@ 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
@ -360,10 +350,11 @@ 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"
@ -385,18 +376,11 @@ if [ -n "$ZIP_FILE" ]; then
echo "Building from $ZIP_FILE"
unzip -q $ZIP_FILE -d "$omni_dir"
else
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
rsync_params=""
if [ $include_tests -eq 0 ]; then
rsync_params="--exclude /test"
fi
rsync -a $rsync_params "$SOURCE_DIR/" ./
fi
mv defaults defaults-z
@ -408,7 +392,7 @@ prefs_file=defaults/preferences/zotero.js
# - network.captive-portal-service.enabled
# Disable the captive portal check against Mozilla servers
# - extensions.systemAddon.update.url
grep -E -v '(network.captive-portal-service.enabled|extensions.systemAddon.update.url)' defaults/preferences/firefox.js > $prefs_file
egrep -v '(network.captive-portal-service.enabled|extensions.systemAddon.update.url)' defaults/preferences/firefox.js > $prefs_file
rm defaults/preferences/firefox.js
# Combine app and "extension" Zotero prefs
@ -422,8 +406,6 @@ 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
@ -476,58 +458,6 @@ if [ $DEVTOOLS -eq 1 ]; then
replace_line 'let command = Services.dirsvc.get\("XREExeF", Ci.nsIFile\).path;' \
'let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path; command = command.replace("zotero-bin", "zotero");' \
chrome/devtools/modules/devtools/client/framework/browser-toolbox/Launcher.sys.mjs
# Fix source display breaking when switching windows in the Browser Toolbox.
# When the user switches which chrome window is being inspected (via the frame
# picker), the thread actor's _onWindowReady handler clears all debuggees and
# source actors, then re-adds debuggees. But it never calls addAllSources() to
# re-emit the existing scripts — it only does that for BFCache navigations.
# Since the scripts already exist (they're not new), onNewScript won't fire,
# so the debugger's source list stays empty. Fix: also call addAllSources()
# after frame switching, just like BFCache.
replace_line '_onWindowReady\(\{ isTopLevel, isBFCache \}\)' \
'_onWindowReady({ isTopLevel, isBFCache, isFrameSwitching })' \
chrome/devtools/modules/devtools/server/actors/thread.js
replace_line 'if \(isBFCache\) {' \
'if (isBFCache || isFrameSwitching) {' \
chrome/devtools/modules/devtools/server/actors/thread.js
# Suppress "Failed to get the active browserId" error when profile recording starts
replace_line 'console\.error\(' \
'if (false) console.error(' \
chrome/devtools/modules/devtools/shared/performance-new/recording-utils.sys.mjs
# Stub out profile favicon lookup since we don't have PlacesUtils
replace_line 'return getPageFavicons\(pageUrls\);' \
'return pageUrls ? pageUrls.map(() => null) : [];' \
chrome/devtools/modules/devtools/client/performance-new/shared/background.sys.mjs
# Use our profilerViewer.xhtml to display profiles.
# We can't use basicViewer because that loads Zotero scripts, and profiling
# runs in the Browser Toolbox child process, where a second Zotero instance
# would collide with the locked database.
replace_line 'const contentBrowser = await new Promise\(resolveOnContentBrowserCreated =>' \
'if (typeof win.openWebLinkIn !== "function") {
const ww = Cc["\@mozilla.org\/embedcomp\/window-watcher;1"].getService(
Ci.nsIWindowWatcher
);
const arg = { uri: urlToLoad };
arg.wrappedJSObject = arg;
const viewerWin = ww.openWindow(
null,
"chrome:\/\/zotero\/content\/standalone\/profilerViewer.xhtml",
null,
"chrome,dialog=no,resizable,centerscreen,scrollbars",
arg
);
return await new Promise(resolve => {
viewerWin.addEventListener("load", () => {
resolve(viewerWin.document.getElementById("content"));
}, { once: true });
});
}
const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>' \
chrome/devtools/modules/devtools/client/performance-new/shared/browser.js
fi
# 5.0.96.3 / 5.0.97-beta.37+ddc7be75c
@ -592,11 +522,11 @@ fi
# Copy platform-specific assets
if [ $BUILD_MAC == 1 ]; then
copy_dir "$CALLDIR/assets/mac" .
rsync -a "$CALLDIR/assets/mac/" ./
elif [ $BUILD_WIN == 1 ]; then
copy_dir "$CALLDIR/assets/win" .
rsync -a "$CALLDIR/assets/win/" ./
elif [ $BUILD_LINUX == 1 ]; then
copy_dir "$CALLDIR/assets/unix" .
rsync -a "$CALLDIR/assets/unix/" ./
fi
# Add word processor plug-ins
@ -694,8 +624,8 @@ if [ $BUILD_MAC == 1 ]; then
# Merge relevant assets from Firefox
mkdir "$CONTENTSDIR/MacOS"
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"
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"
# Add our custom ChannelPrefs.framework and change channel if not a source build
mkdir "$CONTENTSDIR/Frameworks"
@ -739,7 +669,7 @@ if [ $BUILD_MAC == 1 ]; then
echo
# Copy app files
copy_dir "$base_dir" "$CONTENTSDIR/Resources"
rsync -a "$base_dir/" "$CONTENTSDIR/Resources/"
# Add word processor plug-ins
mkdir "$CONTENTSDIR/Resources/integration"
@ -750,66 +680,12 @@ 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 extensions -- this depends on signing but needs to be done before generating
# Add Safari App Extension -- 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"
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
cp -R $SAFARI_APPEX "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
rm -rf "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/test/"
fi
# Copy over removed-files and make a precomplete file
@ -845,36 +721,34 @@ 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 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)
# extract both, sign the library, and then update each ZIP.
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 extensions
# Sign Safari App Extension
#
# 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
# 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
echo
# Extract entitlements, which differ from parent app
/usr/bin/codesign -d --entitlements "$BUILD_DIR/safari-entitlements.plist" --xml "$appex"
/usr/bin/codesign -d --entitlements "$BUILD_DIR/safari-entitlements.plist" --xml "$SAFARI_APPEX"
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
# 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
# Sign final app package
echo
@ -882,14 +756,11 @@ if [ $BUILD_MAC == 1 ]; then
# Verify app
/usr/bin/codesign --verify -vvvv "$APPDIR"
# Verify Safari extensions
for appex in "$APPDIR"/Contents/PlugIns/*.appex; do
if [ ! -d "$appex" ]; then
continue
fi
# Verify Safari App Extension
if [[ -n "$SAFARI_APPEX" ]] && [[ -d "$SAFARI_APPEX" ]]; then
echo
/usr/bin/codesign --verify -vvvv "$appex"
done
/usr/bin/codesign --verify -vvvv "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
fi
fi
# Build and notarize disk image
@ -962,7 +833,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|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"
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"
# Copy zotero.exe, which is built directly from Firefox source and then modified by
# ResourceHacker to add icons
@ -987,11 +858,11 @@ if [ $BUILD_WIN == 1 ]; then
fi
# Copy app files
copy_dir "$base_dir" "$APPDIR"
rsync -a "$base_dir/" "$APPDIR/"
#mv "$APPDIR/app/application.ini" "$APPDIR/"
# Copy in common files
copy_dir "$COMMON_APPDIR" "$APPDIR"
rsync -a "$COMMON_APPDIR/" "$APPDIR/"
cat "$CALLDIR/win/installer/updater_append.ini" >> "$APPDIR/updater.ini"
@ -1133,7 +1004,7 @@ if [ $BUILD_LINUX == 1 ]; then
if [[ -n $arch ]]; then
archs=("$(get_canonical_arch l $arch)")
else
archs=(x64 arm64)
archs=(x64 arm64 i686)
fi
for arch in "${archs[@]}"; do
[[ $arch == x64 ]] && arch="x86_64"
@ -1147,7 +1018,7 @@ if [ $BUILD_LINUX == 1 ]; then
mkdir "$APPDIR"
# Merge relevant assets from Firefox
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"
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"
# Use our own launcher that calls the original Firefox executable with -app
mv "$APPDIR"/firefox-bin "$APPDIR"/zotero-bin
@ -1163,14 +1034,12 @@ if [ $BUILD_LINUX == 1 ]; then
chmod 755 "$APPDIR/updater"
# Copy app files
copy_dir "$base_dir" "$APPDIR"
rsync -a "$base_dir/" "$APPDIR/"
# Add word processor plug-ins
mkdir "$APPDIR/integration"
cp -RH "$CALLDIR/modules/zotero-libreoffice-integration/install" "$APPDIR/integration/libreoffice"
# Firefox includes an 'icons' folder with updater.png, but some Linux distro builds omit it
mkdir -p "$APPDIR/icons"
# Copy icons
cp "$CALLDIR/linux/icons/icon32.png" "$APPDIR/icons/"
cp "$CALLDIR/linux/icons/icon64.png" "$APPDIR/icons/"

View file

@ -1,17 +1,17 @@
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Version of Gecko to build with
GECKO_VERSION_MAC="153.1.0esr"
GECKO_VERSION_LINUX="153.1.0esr"
GECKO_VERSION_WIN="153.1.0esr"
RUST_VERSION=1.94.0
GECKO_VERSION_MAC="140.6.0esr"
GECKO_VERSION_LINUX="140.6.0esr"
GECKO_VERSION_WIN="140.6.0esr"
RUST_VERSION=1.86.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="ac2cd5e2d4190c07af1649ca0372ff07dae70afd880d309441dafb575ccdc61a"
custom_components_hash_win_arm64="2369eb75912fb91633fb16d0f756c78d41f533522a3415bb4746c2a911547083"
custom_components_hash_win32="8eea54a3754b5f5331fc9147a6e7741976d70ddb48c8759f3b1a77e8729d8019"
custom_components_hash_win_x64="ff5092d6b4c4cbcc459bef833e00782f3dae8ce327e63b326e4fa1e21ea76a00"
custom_components_hash_win_arm64="e61d36a2c26887e6cbd2e6961e07bc8aa0a75e7d9412f9df3e38bf9d998c04f3"
custom_components_hash_win32="a9bbd2e414964745900b8af499b7f74b79243d96313d11969ada0930966003ab"
APP_NAME="Zotero"
APP_ID="zotero\@zotero.org"
@ -45,7 +45,8 @@ S3_CI_ZIP_PATH="ci/client"
S3_DIST_PATH="client"
DEPLOY_HOST="deploy.zotero"
DEPLOY_PATH="www/www-production/public/download/client"
DEPLOY_PATH="www/www-production/public/download/client/manifests"
DEPLOY_CMD="ssh $DEPLOY_HOST update-site-files"
BUILD_PLATFORMS=""
NUM_INCREMENTALS=6

View file

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

View file

@ -11,7 +11,7 @@ fi
version="$1"
for arch in x86_64 aarch64; do
for arch in x86_64 aarch64 i686; 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
$tar --owner=0 --group=0 --numeric-owner --no-xattrs --mode=0755 -cJvf updater.tar.xz updater-x86_64 updater-arm64 updater-i686
rm updater-x86_64 updater-arm64
rm updater-x86_64 updater-arm64 updater-i686
echo "Done: updater.tar.xz created"

View file

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

View file

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

@ -1 +1 @@
Subproject commit 227551c1644c6cb1156226d4db5d1b293fdb271d
Subproject commit 692759ba43d77a2448b37e6d1dd7992a2ba59007

@ -1 +1 @@
Subproject commit 51faa4b21e1a433a6c0f69a4bdfc5a7882341f23
Subproject commit 74eed0152378d776ced32000100a8a90d3c0a705

@ -1 +1 @@
Subproject commit c0aa6e4bef039d94e17e81cb28b1fe9170c45b96
Subproject commit e960ae3a3cc676f6279d61c8b222bb9a8f149da6

View file

@ -0,0 +1,30 @@
#!/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="beta"
BRANCH="main"
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/beta/ZoteroSafariExtension.appex"
# 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
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
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL

View file

@ -0,0 +1,27 @@
#!/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="dev"
BRANCH="main"
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/dev/ZoteroSafariExtension.appex"
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
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL -i 1

View file

@ -0,0 +1,29 @@
#!/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="main"
if [ "`uname`" = "Darwin" ]; then
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/release/ZoteroSafariExtension.appex"
fi
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
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL

View file

@ -0,0 +1,27 @@
#!/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="test"
BRANCH="main"
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/test/ZoteroSafariExtension.appex"
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
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL -i 1

View file

@ -1,59 +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="9.0"
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/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
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`
function cleanup {
rm -rf "$source_dir"
rm -rf "$build_dir"
}
trap cleanup EXIT
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
VERSION="`cat \"$build_dir/version\"`"
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
if [ $BUILD_ONLY -eq 1 ]; then
echo
echo "Build only -- skipping deploy."
echo "To deploy, run on the deploy server:"
echo " $DEPLOY_PATH/deploy $CHANNEL $VERSION $BUILD_PLATFORMS"
else
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
fi

View file

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

View file

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

View file

@ -1,64 +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="beta"
BRANCH="main"
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
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`
function cleanup {
rm -rf "$source_dir"
rm -rf "$build_dir"
}
trap cleanup EXIT
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
VERSION="`cat \"$build_dir/version\"`"
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
if [ $BUILD_ONLY -eq 1 ]; then
echo
echo "Build only -- skipping deploy."
echo "To deploy, run on the deploy server:"
echo " $DEPLOY_PATH/deploy $CHANNEL $VERSION $BUILD_PLATFORMS"
else
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
fi

View file

@ -1,7 +1,6 @@
#!/bin/bash
#
# Builds Zotero and uploads to S3 and deploy server, but does not make the build live.
# To make the build live, run the deploy script on the deploy server (or via SSH).
# Builds and deploys Zotero with full and incremental updates
#
set -euo pipefail
@ -108,23 +107,13 @@ do
usage
;;
esac
echo
echo "Getting $platform_name incrementals"
INCREMENTALS="`\"$SCRIPT_DIR/manage_incrementals\" -c $CHANNEL -p ${PLATFORMS:i:1} -n $NUM_INCREMENTALS`"
# Fetch pinned incrementals from deploy server and merge
PINNED_FILE="$DEPLOY_PATH/manifests/$CHANNEL/pinned-incrementals-$platform"
PINNED=""
if ssh $DEPLOY_HOST "test -f $PINNED_FILE"; then
PINNED="`ssh $DEPLOY_HOST cat $PINNED_FILE`"
fi
# Combine and deduplicate
INCREMENTALS="`echo -e "$INCREMENTALS\n$PINNED" | sort -u | grep -v '^$' || true`"
echo "$INCREMENTALS"
echo
if [ -n "$INCREMENTALS" ]; then
for from in $INCREMENTALS; do
echo "Building incremental update for $platform_name from $from to $VERSION"
@ -137,31 +126,46 @@ done
# Upload builds to S3
"$SCRIPT_DIR/upload_builds" $CHANNEL $VERSION
# Upload file lists and build info for each platform
channel_deploy_path="$DEPLOY_PATH/manifests/$CHANNEL"
# Upload file lists for each platform
channel_deploy_path="$DEPLOY_PATH/$CHANNEL"
mkdir "$TEMP_DIR/version_info"
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=$(echo "$VERSION" | grep -oE '^[0-9]+\.[0-9]+')
DETAILS_URL="https://www.zotero.org/support/${SHORT_VERSION}_changelog"
rsync -rv "$TEMP_DIR/version_info/" $DEPLOY_HOST:"$channel_deploy_path/$VERSION/"
# Download updates JSON for each platform, update it, and reupload it
for i in `seq 0 1 $((${#PLATFORMS}-1))`
do
case ${PLATFORMS:i:1} in
m) os_name="mac" ;;
w) os_name="win" ;;
l) os_name="linux" ;;
m)
architectures="mac"
;;
w)
architectures="win32 win-x64 win-arm64"
;;
l)
architectures="linux-i686 linux-x86_64 linux-arm64"
;;
esac
printf '{\n "buildID": "%s",\n "detailsURL": "%s"\n}\n' "$BUILD_ID" "$DETAILS_URL" \
> "$TEMP_DIR/version_info/build-${os_name}.json"
chmod g+w "$TEMP_DIR/version_info/build-${os_name}.json"
for arch in $architectures;
do
jsonfile="updates-$arch.json"
scp $DEPLOY_HOST:"$channel_deploy_path/$jsonfile" "$TEMP_DIR/$jsonfile"
"$SCRIPT_DIR/add_version_info" -f "$TEMP_DIR/$jsonfile" -v $VERSION -b $BUILD_ID
scp "$TEMP_DIR/$jsonfile" $DEPLOY_HOST:"$channel_deploy_path/$jsonfile"
done
done
rsync -rv "$TEMP_DIR/version_info/" $DEPLOY_HOST:"$channel_deploy_path/$VERSION/"
# Add version to incremental lists
echo
for i in `seq 0 1 $((${#PLATFORMS}-1))`
do
"$SCRIPT_DIR/manage_incrementals" -c $CHANNEL -p ${PLATFORMS:i:1} -a $VERSION
done
$DEPLOY_CMD
rm -rf "$STAGE_DIR"/*
echo
echo "Build $VERSION uploaded successfully."
echo "To deploy, run on the deploy server:"
echo " ./deploy $CHANNEL $VERSION $PLATFORMS"

View file

@ -10,23 +10,13 @@ if [ -n "${ZOTERO_PROFILE:-}" ]; then
profile_args=(-p "$ZOTERO_PROFILE")
fi
FORCE_FULL=0
NO_REBUILD=0
REBUILD=0
SKIP_BUNDLED_FILES=0
DEBUGGER=0
while getopts "rfnbd" opt; do
while getopts "rbd" opt; do
case $opt in
r)
# 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
REBUILD=1
;;
b)
@ -38,6 +28,7 @@ while getopts "rfnbd" opt; do
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 1
;;
esac
@ -46,15 +37,12 @@ done
# Remove options from $@
shift $((OPTIND-1))
if [ $NO_REBUILD -eq 0 ]; then
if [ $REBUILD -eq 1 ]; 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
@ -62,11 +50,16 @@ if [ $NO_REBUILD -eq 0 ]; then
echo
cd $ROOT_DIR
# TEMP: --openssl-legacy-provider avoids a build error in pdf.js
NODE_OPTIONS=--openssl-legacy-provider node js-build/build.js
NODE_OPTIONS=--openssl-legacy-provider npm run build
echo
fi
"$SCRIPT_DIR/dir_build" $PARAMS
"$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
fi
PARAMS=""
@ -94,5 +87,4 @@ else
exit 1
fi
echo
"$APP_ROOT_DIR/staging/$command" "${profile_args[@]}" -ZoteroDebugText $PARAMS "$@"
"$APP_ROOT_DIR/staging/$command" "${profile_args[@]}" -ZoteroDebugText -purgecaches $PARAMS "$@"

View file

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

View file

@ -1,41 +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="dev"
BRANCH="main"
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`
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 -i 1
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS

View file

@ -5,7 +5,6 @@ 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
@ -16,9 +15,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, win32) — **Windows/Linux only**
-a ARCH Target architecture (arm64, x64, i686, win32) — **Windows/Linux only**
-t add devtools
-f force a full rebuild instead of updating the staged build in place
-q quick build (skip compression and other optional steps for faster restarts during development)
DONE
exit 1
}
@ -26,8 +25,8 @@ DONE
platform=""
arch=""
devtools=0
force_full=0
while getopts "tp:a:f" opt; do
quick_build=0
while getopts "tp:a:q" opt; do
case $opt in
t)
devtools=1
@ -48,11 +47,12 @@ while getopts "tp:a:f" opt; do
a)
arch="$OPTARG"
;;
f)
force_full=1
q)
quick_build=1
;;
\?)
usage
echo "Invalid option: -$OPTARG" >&2
exit 1
;;
esac
done
@ -76,100 +76,29 @@ if [[ $platform = "m" ]]; then
else
# Windows / Linux: derive arch if not supplied
if [[ -z $arch ]]; then
# 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
case "$(uname -m)" in
arm64|aarch64) arch="arm64" ;;
x86_64) arch="x64" ;;
i?86) arch="i686" ;;
esac
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)
manifest_tmp=""
cleanup() {
rm -rf "$build_dir"
if [ -n "$manifest_tmp" ]; then
rm -f "$manifest_tmp"
fi
}
cleanup() { rm -rf "$build_dir"; }
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 -q)
build_cmd=("$APP_ROOT_DIR/build.sh" -d "$build_dir" -p "$platform" -c "$CHANNEL" -s)
[[ -n $arch ]] && build_cmd+=( -a "$arch" )
[[ $devtools -eq 1 ]] && build_cmd+=( -t )
[[ $quick_build -eq 1 ]] && build_cmd+=( -q )
"${build_cmd[@]}"
# Save manifest for future incremental updates
if [ -n "$manifest_tmp" ]; then
if [[ -z "${ZOTERO_TEST:-}" ]] || [[ "${ZOTERO_TEST:-}" == "0" ]]; then
include_tests=0
else
include_tests=1
fi
{
echo "#format=2"
echo "#include_tests=$include_tests"
echo "#devtools=$devtools"
echo "#app_hash=$app_hash"
cat "$manifest_tmp"
} > "$STAGE_DIR/.build-manifest"
fi
# Ad-hoc-sign the Word dylib so it works on Apple Silicon. This only needs to
# happen when the staged build is fully rebuilt -- incremental updates don't
# touch the dylib.
if [[ $platform = "m" ]] && [[ "$(uname -s)" = "Darwin" ]]; then
"$SCRIPT_DIR/codesign_local" "$STAGE_DIR/Zotero.app"
fi
echo Done

View file

@ -35,7 +35,7 @@ Required
Optional / Conditional
-a ARCH - Mac: disallowed (builds are universal)
- Windows: x64 | arm64 | win32
- Linux: x64 (or x86_64) | arm64
- Linux: x64 (or x86_64) | arm64 | i686
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|arm64) ;; *) echo "Invalid Linux arch: $arch" >&2; echo; usage;; esac
case $arch in x86_64|i686|arm64) ;; *) echo "Invalid Linux arch: $arch" >&2; echo; usage;; esac
fi
fi
fi
@ -130,15 +130,8 @@ function modify_omni {
rm actors/PictureInPictureChild.sys.mjs
rm actors/ThumbnailsChild.sys.mjs
rm actors/Translations{Engine,}{Parent,Child}.sys.mjs
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
#
@ -169,20 +162,6 @@ function modify_omni {
# Prevent error during network requests
replace_line 'async lazyInit\(\) \{' 'async lazyInit() { if (this.features) return false;' modules/UrlClassifierExceptionListService.sys.mjs
# Silence CrashManager "Could not get children of [...]/Crash Reports/events" spam
replace_line '\/\/ Missing events folders are allowed' '\/\/ Missing events folders are allowed (silence warning)
if (ex) return entries;' modules/CrashManager.sys.mjs
# Several services pull in services-settings (which we strip) and throw
# at profile-after-change. Remove their #init() methods to prevent errors.
replace_line 'async #init\(\) \{' 'async #init() {} async _unused_init() {' modules/EssentialDomainsRemoteSettings.sys.mjs
replace_line 'async #init\(\) \{' 'async #init() {} async _unused_init() {' modules/SimpleURIUnknownSchemesRemoteObserver.sys.mjs
# RemotePermissionService uses a top-level import, so even loading the
# module throws
replace_line 'import \{ RemoteSettings \} from "resource:\/\/services-settings\/remote-settings.sys.mjs";' \
'const RemoteSettings = () => ({ on() {}, get: async () => [] });' \
modules/RemotePermissionService.sys.mjs
replace_line 'pref\("network.captive-portal-service.enabled".+' 'pref("network.captive-portal-service.enabled", false);' greprefs.js
replace_line 'pref\("network.connectivity-service.enabled".+' 'pref("network.connectivity-service.enabled", false);' greprefs.js
@ -201,17 +180,14 @@ function modify_omni {
file="modules/ActorManagerParent.sys.mjs"
# Remove deleted actors
# 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
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
# Do not trigger LoginManager event that logs an error on autocomplete submission
remove_line 'DOMInputPasswordAdded: \{\},' $file
@ -269,20 +245,6 @@ function modify_omni {
# When installing addon, use app version instead of toolkit version for targetApplication
replace_line "id: TOOLKIT_ID," "id: '$APP_ID'," modules/addons/XPIInstall.sys.mjs
# Set strictCompatibility to false in beta/dev/source builds to skip strict_max_version check
replace_line 'addon.strictCompatibility = true;' \
'let version = Services.appinfo.version;
addon.strictCompatibility = !version.includes("-beta") && !version.includes("-dev") && !version.includes("SOURCE");' modules/addons/XPIInstall.sys.mjs
# In beta/dev/source builds, ignore the persisted strictCompatibility flag for already-installed
# plugins, so plugins installed on a stable build (where the flag was set to true) aren't marked
# incompatible after upgrading. The manifest isn't re-parsed on app upgrade, so the flag set at
# install time sticks around.
replace_line '!this\.addon\.strictCompatibility' \
'(!this.addon.strictCompatibility || Services.appinfo.version.includes("-beta") || Services.appinfo.version.includes("-dev") || Services.appinfo.version.includes("SOURCE"))' modules/addons/XPIInstall.sys.mjs
replace_line '!this\.strictCompatibility &&' \
'(!this.strictCompatibility || Services.appinfo.version.includes("-beta") || Services.appinfo.version.includes("-dev") || Services.appinfo.version.includes("SOURCE")) &&' modules/addons/XPIDatabase.sys.mjs
# Accept zotero@chnm.gmu.edu for target application to allow Zotero 6 plugins to remain
# installed in Zotero 7
replace_line "if \(targetApp.id == Services.appinfo.ID\) \{" "if (targetApp.id == 'zotero\@chnm.gmu.edu') targetApp.id = '$APP_ID'; if (targetApp.id == Services.appinfo.ID) {" modules/addons/XPIDatabase.sys.mjs
@ -300,11 +262,6 @@ function modify_omni {
replace_line "id: TOOLKIT_ID," "id: '$APP_ID'," modules/addons/AddonUpdateChecker.sys.mjs
replace_line 'lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion' '"7.0"' modules/addons/AddonUpdateChecker.sys.mjs
replace_line 'result.targetApplications.push' 'false && result.targetApplications.push' modules/addons/AddonUpdateChecker.sys.mjs
# In beta/dev/source builds, don't set strictCompatibility on parsed update entries so that
# AddonUpdateChecker doesn't reject an available update whose maxVersion is lower than the
# current (non-stable) app version.
replace_line 'result\.strictCompatibility = appEntry\.maxVersion != "\*";' \
'let appVer = Services.appinfo.version; result.strictCompatibility = appEntry.maxVersion != "*" && !appVer.includes("-beta") && !appVer.includes("-dev") && !appVer.includes("SOURCE");' modules/addons/AddonUpdateChecker.sys.mjs
# Set uninstall flag to true if the update config has it set
replace_line 'targetApplications: \[appEntry\],' \
'targetApplications: \[appEntry\],
@ -395,12 +352,8 @@ function modify_omni {
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.css"
echo >> $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
# Hide search bar, Themes and Plugins tabs, and sidebar footer
echo '.main-search, button[name="theme"], button[name="plugin"], sidebar-footer { display: none; }' >> $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
@ -416,46 +369,38 @@ 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-utils.mjs"
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.js"
# Hide unsigned-addon warning
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"
replace_line 'if \(!isCorrectlySigned\(addon\)\) \{' 'if (!isCorrectlySigned(addon)) {return {};' $file
# Hide Private Browsing setting in addon details
replace_line 'pbRow\.' '\/\/pbRow.' $file
replace_line 'let isAllowed = await isAllowedInPrivateBrowsing' '\/\/let isAllowed = await isAllowedInPrivateBrowsing' $file
# 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>' $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 '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;' 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"' \
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
# 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
@ -472,7 +417,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" \/>' chrome/toolkit/content/global/commonDialog.xhtml
check_line '<html:link rel="stylesheet" href="chrome:\/\/global\/skin\/global.css" \/>'
replace_line 'chrome:\/\/global\/skin\/commonDialog.css"' \
'chrome:\/\/global\/skin\/commonDialog.css"\/>
<html:link rel="stylesheet" href="chrome:\/\/zotero-platform\/content\/zotero.css"' \
@ -502,19 +447,8 @@ function modify_omni {
}
popupset.appendChild(this._autoScrollPopup);' chrome/toolkit/content/global/elements/browser-custom-element.mjs
# 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 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
# Remove non-native text input styles
remove_between 'html\|input\:where\(' '^}' chrome/toolkit/skin/classic/global/global-shared.css
@ -528,12 +462,10 @@ function modify_omni {
overflow: var(--moz-text-control-overflow);
}' >> chrome/toolkit/res/forms.css
# 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' \
# 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' \
chrome/toolkit/content/global/elements/autocomplete-popup.js
zip -qr9XD omni.ja *
@ -581,7 +513,7 @@ if [ $BUILD_MAC == 1 ]; then
echo "Using Firefox $GECKO_VERSION.app.zip"
unzip "Firefox $GECKO_VERSION.app.zip"
else
curl -fo Firefox.dmg "$DOWNLOAD_URL/mac/en-US/Firefox%20$GECKO_VERSION.dmg"
curl -o Firefox.dmg "$DOWNLOAD_URL/mac/en-US/Firefox%20$GECKO_VERSION.dmg"
set +e
hdiutil detach -quiet /Volumes/Firefox 2>/dev/null
set -e
@ -667,7 +599,7 @@ if [ $BUILD_WIN == 1 ]; then
else
moz_arch=$arch
fi
curl -fO "$DOWNLOAD_URL/$moz_arch/en-US/Firefox%20Setup%20$GECKO_VERSION.exe"
curl -O "$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
@ -783,7 +715,7 @@ if [ $BUILD_LINUX == 1 ]; then
if [[ -n $arch ]]; then
arch_list=("$arch")
else
arch_list=(x86_64 arm64)
arch_list=(x86_64 arm64 i686)
fi
for arch in "${arch_list[@]}"; do
xdir="firefox-linux-$arch"
@ -799,7 +731,7 @@ if [ $BUILD_LINUX == 1 ]; then
else
moz_arch=$arch
fi
curl -fO "$DOWNLOAD_URL/linux-$moz_arch/en-US/firefox-$GECKO_VERSION.tar.xz"
curl -O "$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

View file

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

View file

@ -64,19 +64,22 @@ if [[ "$VERSION" ]] && [[ "$NUM_VERSIONS" ]]; then
usage
fi
INCR_FILENAME="incrementals-$PLATFORM"
REMOTE_PATH="$DEPLOY_PATH/manifests/$CHANNEL/$INCR_FILENAME"
INCR_FILENAME="incrementals-$CHANNEL-$PLATFORM"
S3_URL="s3://$S3_BUCKET/$S3_DIST_PATH/$CHANNEL/incrementals-$PLATFORM"
INCR_PATH="$DIST_DIR/$INCR_FILENAME"
if [ "`uname -o 2> /dev/null`" = "Cygwin" ]; then
INCR_PATH=$(cygpath -w "$INCR_PATH")
fi
mkdir -p "$DIST_DIR"
scp $DEPLOY_HOST:"$REMOTE_PATH" "$INCR_PATH" >&2
aws s3 cp $S3_URL "$INCR_PATH" >&2
# Add version to file and reupload
if [ "$VERSION" ]; then
echo "Adding $VERSION to incrementals-$PLATFORM"
echo $VERSION >> "$INCR_PATH"
scp "$INCR_PATH" $DEPLOY_HOST:"$REMOTE_PATH"
aws s3 cp "$INCR_PATH" $S3_URL
# Show last n versions
elif [ "$NUM_VERSIONS" -gt 0 ]; then
# TEMP: Don't include 6.0 versions

View file

@ -71,42 +71,34 @@ def main():
tmp_src_dir = os.path.join(tmp_dir, 'zotero')
# 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
])
# 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
])
# Make sure the copy worked
# Make sure rsync 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
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)
subprocess.check_call([
'find',
os.path.normpath(tmp_src_dir + '/chrome/content/zotero/locale/csl/'),
'-mindepth', '1',
'!', '-name', '*.xml',
'!', '-name', 'locales.json',
#'-print',
'-delete'
])
# Delete styles build script
os.remove(os.path.join(tmp_src_dir, 'styles', 'update'))

View file

@ -1,46 +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="test"
BRANCH="main"
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`
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 -i 1
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS

View file

@ -39,88 +39,9 @@ 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
if ! egrep -q "$pattern" "$file"; then
echo "$pattern" not found in "$file" -- aborting 2>&1
exit 1
fi
@ -131,7 +52,7 @@ function replace_line {
replacement=$2
file=$3
if grep -E -q "$pattern" "$file"; then
if egrep -q "$pattern" "$file"; then
perl -pi -e "s/$pattern/$replacement/" "$file"
else
echo "$pattern" not found in "$file" -- aborting 2>&1
@ -143,8 +64,8 @@ function remove_line {
pattern=$1
file=$2
if grep -E -q "$pattern" "$file"; then
grep -E -v "$pattern" "$file" > "$file.tmp"
if egrep -q "$pattern" "$file"; then
egrep -v "$pattern" "$file" > "$file.tmp"
mv "$file.tmp" "$file"
else
echo "$pattern" not found in "$file" -- aborting 2>&1
@ -157,7 +78,7 @@ function remove_between {
end_pattern=$2
file=$3
if grep -E -q "$start_pattern" "$file" && grep -E -q "$end_pattern" "$file"; then
if egrep -q "$start_pattern" "$file" && egrep -q "$end_pattern" "$file"; then
perl -ni -e '
if (/'"$start_pattern"'/) { $skip = 1; next; }
elsif ($skip && /'"$end_pattern"'/) { $skip = 0; next; }

View file

@ -8,7 +8,7 @@
# * **Windows (w)** a is **required**. Accepted values:
# win32 | x64 | arm64
# * **Linux (l)** a is **required**. Accepted values:
# x86_64 | arm64 (alias x64 → x86_64)
# x86_64 | i686 | 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
Linux: x64 (or x86_64) | arm64 | i686
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|arm64) ;;
x86_64|i686|arm64) ;;
*) echo "Invalid Linux arch: $arch" >&2; echo; usage;;
esac
GECKO_VERSION="$GECKO_VERSION_LINUX"
@ -91,5 +91,4 @@ fi
input_string="${platform}-${arch}-${components_hash}|${GECKO_VERSION}|${xulrunner_content}"
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $NF}'
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $2}'

View file

@ -97,8 +97,6 @@ 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"
@ -138,43 +136,20 @@ for version in "$FROM" "$TO"; do
mkdir -p "$versiondir"
cd "$versiondir"
# Determine Linux archive extension by major Zotero version
MAJOR_VERSION="${version%%.*}"
if [ "$MAJOR_VERSION" -lt 8 ]; then
LINUX_EXT="tar.bz2"
else
LINUX_EXT="tar.xz"
fi
MAC_ARCHIVE="Zotero-${version}.dmg"
WIN32_ARCHIVE="Zotero-${version}_win32.zip"
WIN_X64_ARCHIVE="Zotero-${version}_win-x64.zip"
WIN_ARM64_ARCHIVE="Zotero-${version}_win-arm64.zip"
LINUX_X86_ARCHIVE="Zotero-${version}_linux-i686.${LINUX_EXT}"
LINUX_X86_64_ARCHIVE="Zotero-${version}_linux-x86_64.${LINUX_EXT}"
LINUX_ARM64_ARCHIVE="Zotero-${version}_linux-arm64.${LINUX_EXT}"
LINUX_X86_ARCHIVE="Zotero-${version}_linux-i686.tar.xz"
LINUX_X86_64_ARCHIVE="Zotero-${version}_linux-x86_64.tar.xz"
LINUX_ARM64_ARCHIVE="Zotero-${version}_linux-arm64.tar.xz"
CACHE_DIR="$ROOT_DIR/cache"
if [ ! -e "$CACHE_DIR" ]; then
mkdir "$CACHE_DIR"
fi
# 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_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
for archive in "${ARCHIVES[@]}"; do
for archive in "$MAC_ARCHIVE" "$WIN32_ARCHIVE" "$WIN_X64_ARCHIVE" "$WIN_ARM64_ARCHIVE" "$LINUX_X86_ARCHIVE" "$LINUX_X86_64_ARCHIVE" "$LINUX_ARM64_ARCHIVE"; do
if [[ $archive = "$MAC_ARCHIVE" ]] && [[ $BUILD_MAC != 1 ]]; then
continue
fi
@ -239,13 +214,8 @@ for version in "$FROM" "$TO"; do
set -e
done
# 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
# Delete cached files older than 14 days
find "$CACHE_DIR" -ctime +14 -delete
# Unpack Zotero.app
if [ $BUILD_MAC == 1 ]; then
@ -266,14 +236,9 @@ 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") &
UNPACK_PIDS="$UNPACK_PIDS $!"
done
for pid in $UNPACK_PIDS; do
wait $pid
unzip -q "$build"
rm "$build"
done
INCREMENTALS_FOUND=1
else
@ -283,36 +248,14 @@ for version in "$FROM" "$TO"; do
# Unpack Linux tarballs
if [ $BUILD_LINUX == 1 ]; then
# 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
MISSING=0
for build in "${LINUX_BUILDS_TO_UNPACK[@]}"; do
if [ ! -f "$build" ]; then
MISSING=1
break
fi
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") &
UNPACK_PIDS="$UNPACK_PIDS $!"
done
for pid in $UNPACK_PIDS; do
wait $pid
if [[ -f "$LINUX_X86_ARCHIVE" ]] && [[ -f "$LINUX_X86_64_ARCHIVE" ]] && [[ -f "$LINUX_ARM64_ARCHIVE" ]]; then
for build in "$LINUX_X86_ARCHIVE" "$LINUX_X86_64_ARCHIVE" "$LINUX_ARM64_ARCHIVE"; do
tar -xf "$build"
rm "$build"
done
INCREMENTALS_FOUND=1
else
echo "Linux tarballs not found: ${LINUX_BUILDS_TO_UNPACK[*]}"
echo "$LINUX_X86_ARCHIVE/$LINUX_X86_64_ARCHIVE/$LINUX_ARM64_ARCHIVE not found"
fi
fi
@ -323,21 +266,8 @@ done
export MOZ_PRODUCT_VERSION="$TO"
export MAR_CHANNEL_ID="$CHANNEL"
# Reuse diffs and compressed files across incremental builds, since FROM
# versions often share files with each other and with the TO version. Entries
# that haven't been used recently are cleaned up above.
export UPDATE_CACHE_DIR="$ROOT_DIR/cache/update-packaging"
CHANGES_MADE=0
for build in "mac" "win32" "win-x64" "win-arm64" "linux-i686" "linux-x86_64" "linux-arm64"; do
# Zotero 7 has no Linux arm64 builds
if [[ $build == "linux-arm64" ]] && [[ $FROM == 7.* ]]; then
continue
fi
# Zotero 11 has no Linux i686 builds, so there's nothing to update to
if [[ $build == "linux-i686" ]] && [[ $TO_MAJOR_VERSION -ge 11 ]]; then
continue
fi
if [[ $build == "mac" ]]; then
if [[ $BUILD_MAC == 0 ]]; then
continue

View file

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

View file

@ -13,12 +13,6 @@ 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() {
@ -56,7 +50,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
@ -94,23 +88,6 @@ 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]}"
@ -120,6 +97,11 @@ for ((i=0; $i<$num_files; i=$i+1)); do
make_add_instruction "$f" "$updatemanifestv3"
fi
dir=$(dirname "$f")
mkdir -p "$workdir/$dir"
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$targetdir/$f" > "$workdir/$f"
copy_perm "$targetdir/$f" "$workdir/$f"
targetfiles="$targetfiles \"$f\""
done

View file

@ -13,12 +13,6 @@ 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() {
@ -118,7 +112,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
@ -170,67 +164,93 @@ 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
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
# 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"
make_add_if_not_instruction "$f" "$updatemanifestv3"
archivefiles="$archivefiles \"$f\""
elif check_for_forced_update "$requested_forced_updates" "$f"; then
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"
make_add_instruction "$f" "$updatemanifestv3" 1
archivefiles="$archivefiles \"$f\""
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\""
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
fi
else
# remove instructions are added after add / patch instructions for
@ -245,18 +265,33 @@ 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 < "$tmpdir/newonly"
rm -rf "$tmpdir"
archivefiles="$archivefiles \"$f\""
done
notice ""
notice "Adding file remove instructions to update manifests"

View file

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

View file

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

View file

@ -30,8 +30,8 @@ else
echo "Unknown architecture $arch" >&2
exit 1
fi
rustup default $RUST_VERSION
rustup target add $rust_target-pc-windows-msvc
rustup default $RUST_VERSION
cp "$SCRIPT_DIR/mozconfig" "$gecko_path"
@ -43,17 +43,3 @@ fi
cd "$gecko_path"
./mach build
./mach package
# Copy the packaged ZIP back to app/win/ for launcher/xul.dll extraction
case $arch in
x64) zip_suffix=win64 ;;
arm64) zip_suffix=win64-aarch64 ;;
win32) zip_suffix=win32 ;;
esac
zip_file=$(ls "$gecko_path"/obj-*/dist/firefox-*.en-US.${zip_suffix}.zip 2>/dev/null | head -1) || true
if [ -z "$zip_file" ] || [ ! -f "$zip_file" ]; then
echo "Could not find packaged zip for $arch in $gecko_path/obj-*/dist/" >&2
exit 1
fi
cp "$zip_file" "$SCRIPT_DIR/"
echo "Copied $(basename "$zip_file") to $SCRIPT_DIR"

View file

@ -41,15 +41,15 @@ fi
# Clean up any old artifacts
rm -f "$version"-*.zip
# Process whichever arch ZIPs are present
processed=0
# Process each architecture
for i in "${!files[@]}"; do
file="${files[$i]}"
arch="${arches[$i]}"
temp_zip="xul-temp-$arch.zip"
if [ ! -f "$file" ]; then
continue
echo "Missing file: $file"
exit 1
fi
dlls=(xul.dll)
@ -75,10 +75,4 @@ for i in "${!files[@]}"; do
rm -f xul.dll lgpllibs.dll
echo "Created $final_zip"
processed=$((processed + 1))
done
if [ $processed -eq 0 ]; then
echo "No firefox-$version.en-US.*.zip files found in $SCRIPT_DIR" >&2
exit 1
fi

View file

@ -29,11 +29,6 @@ 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

144
app/win/mozilla-140.patch Normal file
View file

@ -0,0 +1,144 @@
diff --git a/browser/app/nsBrowserApp.cpp b/browser/app/nsBrowserApp.cpp
index a85fdbfebe0f..819475717ffb 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;
static int do_main(int argc, char* argv[], char* envp[]) {
+ // Allow profile downgrade for Zotero
+ _putenv_s("MOZ_ALLOW_DOWNGRADE", "1");
+ // Don't create dedicated profile (default-esr)
+ _putenv_s("MOZ_LEGACY_PROFILES", "1");
+
// Allow firefox.exe to launch XULRunner apps via -app <application.ini>
// Note that -app must be the *first* argument.
- const char* appDataFile = getenv("XUL_APP_FILE");
+ UniqueFreePtr<char> iniPath = BinaryPath::GetApplicationIni();
+ if (!iniPath) {
+ Output("Couldn't find application.ini.\n");
+ return 255;
+
+ }
+ char *appDataFile = iniPath.get();
if ((!appDataFile || !*appDataFile) && (argc > 1 && IsArg(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
--- 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")) {
result |= mozilla::LauncherFlags::eWaitForBrowser;
}
- if (mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
+ // Disable deelevation for Zotero
+ //
+ // If people are running as Administrator, or in some cases running with
+ // UAC disabled, Word runs at integrity level High and deelevation drops
+ // Zotero down to Medium, which causes 'Could not find a running Word
+ // instance' errors.
+ //
+ // Even when not running as administrator, the process switch involved in
+ // deelevation also seems to be causing various security software, such as
+ // Cisco Secure Endpoint, to block Zotero from running:
+ // https://forums.zotero.org/discussion/116762
+ //
+ // Disabling deelevation returns us to the behavior of Zotero 6, Word, and
+ // most other programs
+ if (true || mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
result |= mozilla::LauncherFlags::eNoDeelevate;
}
if (mozilla::CheckArg(aArgc, aArgv, ATTEMPTING_DEELEVATION_FLAG) ==
mozilla::ARG_FOUND) {
result |= mozilla::LauncherFlags::eDeelevating;
}
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
// 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.
-# define ROOT_UPDATE_DIR_NAME "Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38"
+# define ROOT_UPDATE_DIR_NAME "Zotero"
// 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
--- 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));
return result;
}
+ static UniqueFreePtr<char> GetApplicationIni() {
+ char path[MAXPATHLEN];
+ if (NS_FAILED(Get(path))) {
+ return nullptr;
+ }
+
+ char *c = path + strlen(path);
+ while (c >= path && *c != '\\' && *c != '/') {
+ *c = NULL;
+ c--;
+ }
+
+ if (c < path) {
+ return nullptr;
+ }
+
+ char iniPath[MAXPATHLEN];
+ int n = snprintf(iniPath, MAXPATHLEN, "%s\\app\\application.ini", path);
+ if (n < 0 || n >= MAXPATHLEN) {
+ return nullptr;
+ }
+
+ UniqueFreePtr<char> result;
+ result.reset(strdup(iniPath));
+ return result;
+ }
+
#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];

View file

@ -1,179 +0,0 @@
diff --git a/browser/app/nsBrowserApp.cpp b/browser/app/nsBrowserApp.cpp
index 4c5982c056fd..1ad4c9dfd597 100644
--- a/browser/app/nsBrowserApp.cpp
+++ b/browser/app/nsBrowserApp.cpp
@@ -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
+ _putenv_s("MOZ_ALLOW_DOWNGRADE", "1");
+ // Don't create dedicated profile (default-esr)
+ _putenv_s("MOZ_LEGACY_PROFILES", "1");
+
// Allow firefox.exe to launch XULRunner apps via -app <application.ini>
// Note that -app must be the *first* argument.
- const char* appDataFile = getenv("XUL_APP_FILE");
+ UniqueFreePtr<char> iniPath = BinaryPath::GetApplicationIni();
+ if (!iniPath) {
+ Output("Couldn't find application.ini.\n");
+ return 255;
+
+ }
+ char *appDataFile = iniPath.get();
if ((!appDataFile || !*appDataFile) && (argc > 1 && IsFlag(argv[1], "app"))) {
if (argc == 2) {
Output("Incorrect number of arguments passed to -app");
diff --git a/browser/app/winlauncher/LauncherProcessWin.cpp b/browser/app/winlauncher/LauncherProcessWin.cpp
index 8337336dc894..c55677631c88 100644
--- a/browser/app/winlauncher/LauncherProcessWin.cpp
+++ b/browser/app/winlauncher/LauncherProcessWin.cpp
@@ -250,7 +250,21 @@ static mozilla::LauncherFlags ProcessCmdLine(int& aArgc, wchar_t* aArgv[]) {
result |= mozilla::LauncherFlags::eWaitForBrowser;
}
- if (mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
+ // Disable deelevation for Zotero
+ //
+ // If people are running as Administrator, or in some cases running with
+ // UAC disabled, Word runs at integrity level High and deelevation drops
+ // Zotero down to Medium, which causes 'Could not find a running Word
+ // instance' errors.
+ //
+ // Even when not running as administrator, the process switch involved in
+ // deelevation also seems to be causing various security software, such as
+ // Cisco Secure Endpoint, to block Zotero from running:
+ // https://forums.zotero.org/discussion/116762
+ //
+ // Disabling deelevation returns us to the behavior of Zotero 6, Word, and
+ // most other programs
+ if (true || mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
result |= mozilla::LauncherFlags::eNoDeelevate;
}
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
@@ -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.
-# define ROOT_UPDATE_DIR_NAME "Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38"
+# define ROOT_UPDATE_DIR_NAME "Zotero"
// 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"
diff --git a/xpcom/build/BinaryPath.h b/xpcom/build/BinaryPath.h
index 2c8d78f02440..f55d3db32469 100644
--- a/xpcom/build/BinaryPath.h
+++ b/xpcom/build/BinaryPath.h
@@ -286,6 +286,33 @@ class BinaryPath {
return result;
}
+ static UniqueFreePtr<char> GetApplicationIni() {
+ char path[MAXPATHLEN];
+ if (NS_FAILED(Get(path))) {
+ return nullptr;
+ }
+
+ char *c = path + strlen(path);
+ while (c >= path && *c != '\\' && *c != '/') {
+ *c = NULL;
+ c--;
+ }
+
+ if (c < path) {
+ return nullptr;
+ }
+
+ char iniPath[MAXPATHLEN];
+ int n = snprintf(iniPath, MAXPATHLEN, "%s\\app\\application.ini", path);
+ if (n < 0 || n >= MAXPATHLEN) {
+ return nullptr;
+ }
+
+ UniqueFreePtr<char> result;
+ result.reset(strdup(iniPath));
+ return result;
+ }
+
#ifdef MOZILLA_INTERNAL_API
static nsresult GetFile(nsIFile** aResult) {
nsCOMPtr<nsIFile> lf;

View file

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

View file

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

View file

@ -60,7 +60,6 @@ var Scaffold = new function () {
var _editors = {};
var _browserProgressListener = null;
var _persistentCookieContext = null;
var _propertyMap = {
'textbox-translatorID': 'translatorID',
@ -225,10 +224,6 @@ var Scaffold = new function () {
this.handleUnload = function () {
Zotero.Prefs.unregisterObserver(_prefsObserverID);
if (_persistentCookieContext) {
_persistentCookieContext.dispose();
_persistentCookieContext = null;
}
};
this.promptForTranslatorsDirectory = async function () {
@ -295,8 +290,6 @@ var Scaffold = new function () {
let translatorID = document.getElementById('textbox-translatorID').value;
let modifiedTime = Scaffold_Translators.getModifiedTime(translatorID);
if (modifiedTime && modifiedTime > _lastModifiedTime) {
_lastModifiedTime = modifiedTime;
let ps = Services.prompt;
let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING;
@ -311,6 +304,9 @@ var Scaffold = new function () {
if (index == 1) {
await this.load(translatorID);
}
else {
_lastModifiedTime = modifiedTime;
}
}
_updateTitle();
@ -2012,33 +2008,15 @@ var Scaffold = new function () {
_clearOutput();
let rememberCookies = document.getElementById('checkbox-remember-cookies').checked;
let cookieContext;
if (rememberCookies) {
if (!_persistentCookieContext) {
_persistentCookieContext = Zotero.HTTP.newCookieContext();
}
cookieContext = _persistentCookieContext;
}
else {
cookieContext = Zotero.HTTP.newCookieContext();
}
let tester = new TranslatorTester(_getTranslatorFromPane(), {
translatorProvider: _translatorProvider,
cookieSandbox: cookieContext.id,
cookieSandbox: rememberCookies ? null : new Zotero.CookieSandbox(),
debug: _logOutput,
webTranslationEnvironment: new ZoteroWebTranslationEnvironment(),
});
try {
for (let test of tests) {
yield { test, ...await tester.run(test) };
}
}
finally {
if (!rememberCookies) {
cookieContext.dispose();
}
for (let test of tests) {
yield { test, ...await tester.run(test) };
}
};

View file

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

View file

@ -11,7 +11,7 @@ export class ZoteroWebTranslationEnvironment extends AbstractWebTranslationEnvir
const { HiddenBrowser } = ChromeUtils.importESModule('chrome://zotero/content/HiddenBrowser.mjs');
let browser = new HiddenBrowser({
docShell: { allowMetaRedirects: true },
userContextId: tester.cookieSandbox,
cookieSandbox: tester.cookieSandbox,
});
await browser.load(url, { requireSuccessfulStatus: true });

View file

@ -37,10 +37,6 @@ 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
**/
@ -52,13 +48,10 @@ export class HiddenBrowser {
* @param {Boolean} [options.blockRemoteResources] Block all remote (non-file:) resources
* @param {Boolean} [options.useHiddenFrame=true] Use a hidden frame to create the browser.
* Must be set to false if intending to call print().
* @param {Number} [options.userContextId] - From Zotero.HTTP.newCookieContext() for cookie isolation
* @param {String} [options.customUserAgent] - Override User-Agent for all requests
* from this browser's browsing context
* @param {Zotero.CookieSandbox} [options.cookieSandbox]
*/
constructor(options = {}) {
this._destroyed = false;
this._allowJavaScript = options.allowJavaScript !== false;
this._createdPromise = (async () => {
let doc;
if (options.useHiddenFrame !== false) {
@ -66,6 +59,7 @@ 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);
@ -86,12 +80,13 @@ export class HiddenBrowser {
browser.setAttribute("remote", "true");
browser.setAttribute('maychangeremoteness', 'true');
browser.setAttribute("disableglobalhistory", "true");
if (options.userContextId) {
browser.setAttribute("usercontextid", String(options.userContextId));
}
browser.style.display = "none";
doc.documentElement.appendChild(browser);
if (options.cookieSandbox) {
options.cookieSandbox.attachToBrowser(browser);
}
if (options.blockRemoteResources) {
this._blockingObserver = new BlockingObserver({
shouldBlock(uri) {
@ -101,17 +96,6 @@ export class HiddenBrowser {
this._blockingObserver.register(browser);
}
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;
})();
@ -158,11 +142,15 @@ export class HiddenBrowser {
// Figure out whether the browser should be remote. We actually
// perform the load in PageDataChild, but remoteness changes
// need to happen here.
let remoteType = ChromeUtils.predictRemoteTypeForURI(uri, {
preferredRemoteType: E10SUtils.DEFAULT_REMOTE_TYPE,
useRemoteTabs: true,
useRemoteSubframes: false
});
let oa = E10SUtils.predictOriginAttributes({ browser: this });
let remoteType = E10SUtils.getRemoteTypeForURI(
uri,
true,
false,
E10SUtils.DEFAULT_REMOTE_TYPE,
null,
oa
);
if (this.remoteType !== remoteType) {
// The following functions need to be called on the <browser> directly,
// not through our proxy (aka 'this')
@ -262,22 +250,12 @@ 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, { timeout = 30000 } = {}) {
async getPageData(props) {
var actor = this.browsingContext.currentWindowGlobal.getActor("PageData");
var data = {};
for (let prop of props) {
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]);
data[prop] = await actor.sendQuery(prop);
}
return data;
}

View file

@ -29,9 +29,6 @@ export class PageDataChild extends JSWindowActorChild {
case "documentHTML":
return new XMLSerializer().serializeToString(document);
case "querySelectorMatches":
return !!document.querySelector(message.data.selector);
case "channelInfo": {
let docShell = this.contentWindow.docShell;
try {

View file

@ -1,33 +0,0 @@
export class ProfilerViewerChild extends JSWindowActorChild {
async handleEvent(event) {
if (event.type !== "click" || event.defaultPrevented) {
return;
}
let target = event.composedTarget || event.originalTarget || event.target;
let a = target?.closest?.("a[href]");
if (!a) return;
let href = a.href;
if (href.startsWith("blob:") && a.hasAttribute("download")) {
event.preventDefault();
event.stopPropagation();
let contentBuffer = await (await fetch(href)).arrayBuffer();
let bytes = new Uint8Array(contentBuffer.byteLength);
bytes.set(new Uint8Array(contentBuffer));
this.sendAsyncMessage("ProfilerViewer:Download", {
filename: a.download || "profile",
bytes,
});
return;
}
if (/^https?:/.test(href)) {
let isOffOrigin = !href.startsWith("https://profiler.firefox.com/");
if (a.target === "_blank" || isOffOrigin) {
event.preventDefault();
event.stopPropagation();
this.sendAsyncMessage("ProfilerViewer:OpenExternal", { href });
}
}
}
}

View file

@ -1,31 +0,0 @@
export class ProfilerViewerParent extends JSWindowActorParent {
async receiveMessage(message) {
switch (message.name) {
case "ProfilerViewer:OpenExternal": {
// launchWithURI via the protocol handler info skips the
// prompt that loadURI() triggers
const uri = Services.io.newURI(message.data.href);
const handler = Cc[
"@mozilla.org/uriloader/external-protocol-service;1"
].getService(Ci.nsIExternalProtocolService).getProtocolHandlerInfo(uri.scheme);
handler.preferredAction = Ci.nsIHandlerInfo.useSystemDefault;
handler.launchWithURI(uri, null);
return;
}
case "ProfilerViewer:Download": {
const browser = this.browsingContext.top.embedderElement;
const fp = Cc["@mozilla.org/filepicker;1"]
.createInstance(Ci.nsIFilePicker);
fp.init(
browser.browsingContext,
"Save Profile",
Ci.nsIFilePicker.modeSave
);
fp.defaultString = message.data.filename;
const rv = await new Promise(resolve => fp.open(resolve));
if (rv === Ci.nsIFilePicker.returnCancel) return;
await IOUtils.write(fp.file.path, message.data.bytes);
}
}
}
}

View file

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

View file

@ -109,6 +109,15 @@ Zotero.HTTP = new function() {
xmlhttp.open(method, url, true);
// Overwrite the system nsILoadInfo with one tied to our document
// so CookieSandbox can identify the source of the XHR
xmlhttp.channel.loadInfo = NetUtil.newChannel({
uri: url,
loadingNode: document,
securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST,
}).loadInfo;
for (let header in options.headers) {
xmlhttp.setRequestHeader(header, options.headers[header]);
}
@ -149,7 +158,7 @@ Zotero.HTTP = new function() {
* @param {String} url URL to request
* @param {Function} onDone Callback to be executed upon request completion
* @param {String} responseCharset
* @param {N/A} cookieSandbox Unused, kept for compatibility with translate submodule
* @param {N/A} cookieSandbox Not used in Connector
* @param {Object} headers HTTP headers to include with the request
* @return {Boolean} True if the request was sent, or false if the browser is offline
*/

View file

@ -0,0 +1,190 @@
/*
***** 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 ItemTree from 'zotero/itemTree';
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;
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 ItemTree.init(elem, {
id: "advanced-search",
dragAndDrop: true,
persistColumns: true,
columnPicker: true,
onActivate: this.onItemActivate.bind(this),
columns,
});
await this.itemsView.changeCollectionTreeRow({
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,
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({
ref: _searchBox.search,
visibilityGroup: 'default',
isSearchMode: () => true,
isSearch: () => true,
getItems: async function () {
await Zotero.Libraries.get(_libraryID).waitForDataLoad('item');
var search = _searchBox.search.clone();
search.libraryID = _libraryID;
var ids = await search.search();
return Zotero.Items.get(ids);
}
});
}
function clear() {
this.itemsView.changeCollectionTreeRow(null);
var s = new Zotero.Search();
// Don't clear the selected library
s.libraryID = _searchBox.search.libraryID;
s.addCondition('title', 'contains', '');
_searchBox.search = s;
_searchBox.active = false;
}
this.save = async function () {
_searchBox.updateSearch();
var promptService = Services.prompt;
var libraryID = _searchBox.search.libraryID;
var searches = await Zotero.Searches.getAll(libraryID);
var prefix = Zotero.getString('pane.collections.untitled');
var name = Zotero.Utilities.Internal.getNextName(
prefix,
searches.map(s => s.name).filter(n => n.startsWith(prefix))
);
name = { value: name };
var result = promptService.prompt(window,
Zotero.getString('pane.collections.newSavedSeach'),
Zotero.getString('pane.collections.savedSearchName'), name, "", {});
if (!result) {
return;
}
if (!name.value) {
name.value = 'untitled';
}
var s = _searchBox.search.clone();
s.name = name.value;
await s.saveTx();
window.close();
};
this.onLibraryChange = function (libraryID) {
_libraryID = libraryID;
var library = Zotero.Libraries.get(libraryID);
var isEditable = library.editable && library.libraryType != 'publications';
document.getElementById('zotero-search-save').disabled = !isEditable;
}
function onItemActivate(event, items)
{
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
var lastWin = wm.getMostRecentWindow("navigator:browser");
if (!lastWin) {
return;
}
lastWin.ZoteroPane.selectItems(items.map(item => item.id));
lastWin.focus();
}
}

View file

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

View file

@ -63,7 +63,7 @@ window.Zotero_File_Interface_Bibliography = new function () {
styleConfigurator = document.querySelector("#style-configurator");
// Disable accept button until CE is initialized
document.querySelector("dialog").getButton('accept').setAttribute('disabled', 'true');
document.querySelector("dialog").getButton('accept').setAttribute('disabled', true);
document.querySelector("dialog").getButton('cancel').focus();
// Set font size from pref
@ -103,7 +103,7 @@ window.Zotero_File_Interface_Bibliography = new function () {
// Wait for CE initialization
await styleConfigurator.ready;
document.querySelector("dialog").getButton('accept').removeAttribute('disabled');
document.querySelector("dialog").getButton('accept').setAttribute('disabled', false);
// reset focus to the first focusable element (requestAnimationFrame to make sure richlistbox has already appeared)
requestAnimationFrame(() => {
Services.focus.moveFocus(
@ -392,4 +392,4 @@ window.Zotero_File_Interface_Bibliography = new function () {
window.cancel = () => {
window.close();
}
}

View file

@ -26,7 +26,7 @@
const React = require('react');
const ReactDOM = require('react-dom');
const LibraryTree = require('./libraryTree');
const VirtualizedTree = require('components/virtualized-table').VirtualizedTree;
const VirtualizedTable = require('components/virtualized-table');
const { getCSSIcon } = require('components/icons');
const { getDragTargetOrient } = require('components/utils');
const { noop } = require("./components/utils");
@ -50,7 +50,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
dragAndDrop: false,
filterLibraryIDs: false,
hideSources: [],
multiSelect: false,
onContextMenu: noop,
};
@ -60,19 +59,17 @@ 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";
this.id = "collection-tree";
this._rows = [];
this._rowMap = {};
this._highlightedRows = new Set();
this._unregisterID = Zotero.Notifier.registerObserver(
this,
@ -141,8 +138,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
componentDidMount() {
super.componentDidMount();
this.selection.select(0);
this.makeVisible();
if (this.props.dragAndDrop) {
@ -166,10 +161,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false; // In-line editing active
}
let treeRow = this.getRow(this.selection.focused);
var libraryID = treeRow?.ref?.libraryID;
var libraryID = this.getSelectedLibraryID();
if (!libraryID) return true;
let treeRow = this.getRow(this.selection.focused);
if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
this.expandLibrary(libraryID, true);
}
@ -185,16 +180,6 @@ 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
@ -255,11 +240,6 @@ 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) {
@ -293,7 +273,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (!treeRow.editingName) return;
treeRow.ref.name = treeRow.editingName;
delete treeRow.editingName;
await treeRow.ref.saveTx({ undoAction: 'undo-action-rename-collection' });
await treeRow.ref.saveTx();
window.Zotero_Tabs.rename("zotero-pane", treeRow.ref.name);
}
@ -320,14 +300,16 @@ var CollectionTree = class CollectionTree extends LibraryTree {
// Div creation and content
let div = oldDiv || document.createElement('div');
div.innerHTML = "";
// When a hidden focused row is added last during filtering, it
// is removed on focus change, which can happen at the same time as rendering.
// In this case, just return empty div.
if (index >= this._rows.length) {
return div;
}
// 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);
@ -356,6 +338,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (treeRow.isFeeds()) {
depth = 0;
}
div.style.paddingInlineStart = (CHILD_INDENT * depth) + 'px';
// Create a single-cell for the row (for the single-column layout)
let cell = document.createElement('span');
@ -480,7 +463,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
render() {
return React.createElement(VirtualizedTree,
return React.createElement(VirtualizedTable,
{
getRowCount: () => this._rows.length,
id: this.id,
@ -495,18 +478,15 @@ var CollectionTree = class CollectionTree extends LibraryTree {
isContainer: this.isContainer,
isContainerEmpty: this.isContainerEmpty,
isContainerOpen: this.isContainerOpen,
onToggleOpenState: this.toggleOpenState,
toggleOpenState: 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,
onActivate: (...args) => (this.props.onActivate ? this.props.onActivate(...args) : this.handleActivate(...args)),
role: 'tree',
label: Zotero.getString('pane.collections.title')
}
);
@ -533,8 +513,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
this._virtualCollectionLibraries.unfiled =
Zotero.Prefs.getVirtualCollectionState('unfiled');
this._virtualCollectionLibraries.recentlyRead =
Zotero.Prefs.getVirtualCollectionState('recentlyRead');
this._virtualCollectionLibraries.retracted =
Zotero.Prefs.getVirtualCollectionState('retracted');
this._virtualCollectionLibraries.publications = Zotero.Prefs.getVirtualCollectionState('publications');
@ -752,9 +730,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return 0;
}
var currentLibraryID = this.getSelectedLibraryID();
var libraryID = items[0].libraryID;
// If not in one of the selected libraries
if (!this.getSelectedLibraryIDs().includes(libraryID)) {
// If in a different library
if (libraryID != currentLibraryID) {
Zotero.debug("Library ID differs; switching library");
await this.selectLibrary(libraryID);
}
@ -1266,7 +1245,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (treeRow.isLibrary(true) || treeRow.isCollection() || treeRow.isFeeds()) {
count = await this._expandRow(this._rows, index, true);
}
this.selection.adjustForRowInsertion(index, count);
if (this.selection.focused > index) {
this.selection.select(this.selection.focused + count);
}
this.selection.selectEventsSuppressed = false;
this._rows[index].isOpen = true;
@ -1277,7 +1258,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
/**
* Toggle virtual collection (duplicates/unfiled/recently read/retracted) visibility
* Toggle virtual collection (duplicates/unfiled) visibility
*
* @param libraryID {Number}
* @param type {String}
@ -1288,7 +1269,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
const types = {
duplicates: 'D',
unfiled: 'U',
recentlyRead: 'Y',
retracted: 'R',
publications: 'P'
};
@ -1328,40 +1308,17 @@ var CollectionTree = class CollectionTree extends LibraryTree {
* @returns {Promise<void>}
*/
async deleteSelection(deleteItems) {
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();
var treeRow = this.getRow(this.selection.focused);
if (treeRow.isFeed()) {
await treeRow.ref.eraseTx();
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 });
}
}
});
treeRow.ref.deleted = true;
if (treeRow.isCollection()) {
await treeRow.ref.saveTx({ deleteItems });
return;
}
await treeRow.ref.saveTx();
}
unregister() {
@ -1407,63 +1364,40 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return this.getRow(index).getName();
}
getSelectedLibraryID() {
throw new Error("CollectionTree#getSelectedLibraryID() was removed "
+ "-- use getSelectedLibraryIDs()");
}
/**
* Return the libraryID of every selected row, in tree order
*
* @return {Integer[]}
* Return libraryID of selected row (which could be a collection, etc.)
*/
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() {
throw new Error("CollectionTree#getSelectedCollection() was removed "
+ "-- use getSelectedCollections()");
}
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;
getSelectedLibraryID() {
var treeRow = this.getRow(this.selection.focused);
return treeRow && treeRow.ref && treeRow.ref.libraryID !== undefined
&& treeRow.ref.libraryID;
}
getSelectedSearch() {
throw new Error("CollectionTree#getSelectedSearch() was removed "
+ "-- use getSelectedSearches()");
}
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);
}
getSelectedCollection(asID) {
var collection = this.getRow(this.selection.focused);
if (collection && collection.isCollection()) {
return asID ? collection.ref.id : collection.ref;
}
return searches;
}
getSelectedGroup() {
throw new Error("CollectionTree#getSelectedGroup() was removed "
+ "-- filter getSelectedRows() by isGroup()");
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;
}
}
return false;
}
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;
}
}
return false;
}
getIconName(index) {
@ -1493,10 +1427,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
case 'feeds':
icon = 'feed-library';
break;
case 'recentlyRead':
icon = 'recent';
break;
case 'header':
if (treeRow.ref.id == 'group-libraries-header') {
@ -1517,27 +1447,18 @@ var CollectionTree = class CollectionTree extends LibraryTree {
////////////////////////////////////////////////////////////////////////////////
onDragStart(event, index) {
super.onDragStart(event, index);
const treeRow = this.getRow(index);
// See note in #setDropEffect()
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = 'copyMove';
}
let rows = this.getSelectedRows();
// Ignore drag if all selected rows aren't the same type
if (!rows.every(o => o.type == rows[0].type)) {
if (!treeRow.isCollection() && !treeRow.isSearch()) {
return;
}
// 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]));
let type = treeRow.isCollection() ? "zotero/collection" : "zotero/search";
event.dataTransfer.setData(type, treeRow.ref.id);
Zotero.debug(`Dragging ${type} ` + treeRow.id);
}
onDragOver(event, index) {
@ -1611,19 +1532,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
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) {
if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
this.setDropEffect(event, "move");
}
else {
@ -1693,9 +1602,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
onDragEnd = (event, index) => {
super.onDragEnd(event, index);
onDragEnd = () => {
let dropRow = this._dropRow;
this._dropRow = null;
this.tree.invalidateRow(dropRow);
@ -1824,14 +1731,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// Intra-library drag
// 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.)
// Don't allow drag onto root of same library
if (treeRow.isLibrary(true)) {
Zotero.debug("Item " + item.id + " already in library " + treeRow.ref.libraryID);
continue;
Zotero.debug("Can't drag into same library root");
return false;
}
// Make sure there's at least one item that's not already in this destination
@ -1870,20 +1774,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false;
}
for (let id of data) {
let draggedCollection = Zotero.Collections.get(id);
let draggedCollectionID = data[0];
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (id == treeRow.ref.id) {
return false;
}
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (draggedCollectionID == 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;
}
}
@ -2288,21 +2191,16 @@ 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();
@ -2315,31 +2213,21 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
if (dataType == 'zotero/collection') {
let droppedCollections = await Zotero.Collections.getAsync(data);
if (droppedCollections.some(c => c.id == targetCollectionID)) {
throw new Error("Can't drop onto source row");
}
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
// Collection drag between libraries
if (targetLibraryID != droppedCollections[0].libraryID) {
for (let droppedCollection of droppedCollections) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
if (targetLibraryID != droppedCollection.libraryID) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
// Collection drag within a library
else {
await Zotero.DB.executeTransaction(async () => {
for (let droppedCollection of droppedCollections) {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.save({ undoAction: 'undo-action-move-collection' });
}
});
droppedCollection.parentID = targetCollectionID;
await droppedCollection.saveTx();
}
}
else if (dataType == 'zotero/item') {
@ -2386,61 +2274,42 @@ var CollectionTree = class CollectionTree extends LibraryTree {
});
}
// 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 newItems = [];
let newIDs = [];
let toMove = [];
// TODO: support items coming from different sources?
let sameLibrary = items[0].libraryID == targetLibraryID
for (let item of items) {
if (!item.isTopLevelItem()) {
continue;
}
if (item.libraryID == targetLibraryID) {
sameLibraryItems.push(item);
newItems.push(item);
if (sameLibrary) {
newIDs.push(item.id);
toMove.push(item.id);
}
else {
otherLibraryItems.push(item);
}
}
// Add same-library items to the target container
if (sameLibraryItems.length) {
if (sameLibrary) {
// Add items to target container in the same library.
if (targetCollectionID) {
let ids = sameLibraryItems.map(item => item.id);
let ids = newIDs.filter(itemID => Zotero.Items.get(itemID).isTopLevelItem());
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(sameLibraryItems, copyOptions);
await Zotero.Items.addToPublications(newItems, copyOptions);
}
}
// Copy items from other libraries into the target library
if (otherLibraryItems.length) {
else {
let toReconcile = [];
await Zotero.Utilities.Internal.forEachChunkAsync(
otherLibraryItems,
newItems,
100,
function (chunk) {
return Zotero.DB.executeTransaction(async () => {
@ -2509,10 +2378,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// 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 moving, remove items from source collection
if (dropEffect == 'move' && toMove.length) {
if (!sameLibrary) {
throw new Error("Cannot move items between libraries");
}
if (!sourceTreeRow || !sourceTreeRow.isCollection()) {
throw new Error("Drag source must be a collection for move action");
}
@ -2648,72 +2518,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return true;
}
// 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) => {
isSelectable = index => {
let treeRow = this.getRow(index);
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;
return treeRow && !(treeRow.isSeparator() || treeRow.isHeader());
}
_closeContainer(row, skipMap) {
@ -2724,10 +2531,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var level = this.getLevel(row);
var nextRow = row + 1;
// Remove child rows, remapping any selected ones to the collapsed container
// Remove child rows
while ((nextRow < this._rows.length) && (this.getLevel(nextRow) > level)) {
this.selection.adjustForRowRemoval(nextRow, true);
this._removeRow(nextRow, true, true);
this._removeRow(nextRow, true);
}
this.selection.selectEventsSuppressed = false;
@ -2838,7 +2644,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 = Zotero.Utilities.Internal.normalizeForSearch(filterText);
this._filter = filterText.toLowerCase();
let currentRow = this.getRow(this.selection.focused) || this._hiddenFocusedRow;
let currentRowDisplayed = currentRow && this._includedInTree(currentRow.ref);
let shouldRestoreScrollPosition = willBeEmpty && !isEmpty && !this._treeWasFocused;
@ -2853,10 +2659,6 @@ 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.
@ -3089,15 +2891,14 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (this._filterResultsCache[objectID] && !resetCache) {
return this._filterResultsCache[objectID];
}
// Filtering is case- and accent-insensitive
let normalize = Zotero.Utilities.Internal.normalizeForSearch;
let objectName = normalize(object.name || "");
// Filtering is case insensitive
let objectName = (object.name || "").toLowerCase();
// Special treatment to fetch the name for My Library or Feeds
if (objectID[0] == 'L' && object._ObjectType !== "Group") {
objectName = normalize(Zotero.getString('pane.collections.library'));
objectName = Zotero.getString('pane.collections.library').toLowerCase();
}
else if (objectID == 'feeds') {
objectName = normalize(Zotero.getString('pane.collections.feedLibraries'));
objectName = Zotero.getString('pane.collections.feedLibraries').toLowerCase();
}
let filterValue = this._filter;
@ -3170,7 +2971,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
&& this._virtualCollectionLibraries.duplicates[libraryID] !== false;
var showUnfiled = this.props.hideSources.indexOf('unfiled') == -1
&& this._virtualCollectionLibraries.unfiled?.[libraryID] !== false;
var showRecentlyRead = this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false;
var showRetracted = this.props.hideSources.indexOf('retracted') == -1
&& this._virtualCollectionLibraries.retracted?.[libraryID] !== false
&& Zotero.Retractions.libraryHasRetractedItems(libraryID);
@ -3183,7 +2983,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var savedSearches = [];
var showDuplicates = false;
var showUnfiled = false;
var showRecentlyRead = false;
var showRetracted = false;
var showPublications = false;
var showTrash = false;
@ -3198,7 +2997,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return 0;
}
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRecentlyRead || showRetracted || showTrash);
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRetracted || showTrash);
// If this isn't a manual open, set the initial state depending on whether
// there are child nodes
@ -3212,21 +3011,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var newRows = 0;
// Recently Read
if (showRecentlyRead && this._isFilterEmpty()) {
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this,
'recentlyRead',
{
libraryID,
name: Zotero.getString('recently-read'),
},
level + 1
)
);
newRows++;
}
// Add collections
for (var i = 0, len = collections.length; i < len; i++) {
// Skip collections in trash
@ -3362,19 +3146,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
else {
// Get all collections at the same level that don't have a different parent
startRow++;
// Skip past virtual collections (e.g., Recently Read) that come
// before collections in the tree
while (startRow < this._rows.length
&& this.getLevel(startRow) == level
&& this.getRow(startRow).isRecentlyRead()) {
startRow++;
}
loop:
for (let i = startRow; i < this._rows.length; i++) {
let treeRow = this.getRow(i);
beforeRow = i;
// If we reach something that's not a collection, stop
// Since collections come first, if we reach something that's not a collection,
// stop
if (!treeRow.isCollection()) {
break;
}
@ -3437,11 +3215,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
for (let i = startRow; i < this._rows.length; i++) {
let treeRow = this.getRow(i);
beforeRow = i;
// Skip forward to first collection
if (treeRow.isRecentlyRead()) {
continue;
}
// If we've reached something other than collections, stop
if (treeRow.isSearch()) {
// If current search sorts after, stop

File diff suppressed because it is too large Load diff

View file

@ -75,10 +75,10 @@ const ProgressQueueTable = ({ onActivate = noop, progressQueue }) => {
for (let column of columns) {
if (column.dataKey === 'success') {
let span = document.createElement('span');
if (!span.documentGlobal) {
if (!span.ownerGlobal) {
// 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 documentGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
// an ownerGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
// let's just abort if we get an invalid Element.
// TODO: Remove once we're using ES modules
return div;

View file

@ -33,14 +33,13 @@ const { CSSIcon, CSSItemTypeIcon } = require('./icons');
const SCROLL_ARROW_SCROLL_BY = 222;
const Tab = memo((props) => {
const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, onAudioStatusClick, selected, title, renderTitle, width, audioStatus } = props;
const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, selected, title, renderTitle, width } = props;
const handleTabMouseDown = useCallback(event => onTabMouseDown(event, id), [onTabMouseDown, id]);
const handleContextMenu = useCallback(event => onContextMenu(event, id), [onContextMenu, id]);
const handleTabClick = useCallback(event => onTabClick(event, id), [onTabClick, id]);
const handleDragStart = useCallback(event => onDragStart(event, id, index), [onDragStart, id, index]);
const handleTabClose = useCallback(event => onTabClose(event, id), [onTabClose, id]);
const handleAudioStatusClick = useCallback(event => onAudioStatusClick(event, id), [onAudioStatusClick, id]);
let titleText;
let titleHTML;
@ -73,17 +72,6 @@ const Tab = memo((props) => {
? <CSSItemTypeIcon itemType={icon} className="tab-icon" />
: <CSSIcon name={icon} className="tab-icon" />
}
{audioStatus?.active && (
<div
className="tab-audio-status"
role="button"
data-l10n-id={`reader-tab-audio-${audioStatus.paused ? 'play' : 'pause'}`}
onClick={handleAudioStatusClick}
onMouseDown={e => e.stopPropagation()}
>
<CSSIcon name={audioStatus.paused ? 'play' : 'pause'} className="icon-16"/>
</div>
)}
{titleHTML
? <div className="tab-name" title={titleText} dangerouslySetInnerHTML={{ __html: titleHTML }}/>
: <div className="tab-name" title={titleText}>{titleText}</div>}
@ -110,15 +98,10 @@ Tab.propTypes = {
onTabClick: PropTypes.func.isRequired,
onTabClose: PropTypes.func.isRequired,
onTabMouseDown: PropTypes.func.isRequired,
onAudioStatusClick: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
renderTitle: PropTypes.bool,
width: PropTypes.number,
audioStatus: PropTypes.shape({
active: PropTypes.bool.isRequired,
paused: PropTypes.bool.isRequired,
}),
width: PropTypes.number
};
@ -252,11 +235,6 @@ const TabBar = forwardRef(function (props, ref) {
props.onTabSelect(id);
event.stopPropagation();
}, [props.onTabSelect]);
const handleAudioStatusClick = useCallback((event, id) => {
props.onToggleAudio(id);
event.stopPropagation();
}, [props.onToggleAudio]);
const handleContextMenu = useCallback((event, id) => {
let { screenX, screenY } = event;
@ -441,7 +419,6 @@ const TabBar = forwardRef(function (props, ref) {
onTabClick={ handleTabClick}
onTabClose={ handleTabClose}
onTabMouseDown = { handleTabMouseDown }
onAudioStatusClick={handleAudioStatusClick}
/>
: null}
</div>
@ -479,7 +456,6 @@ const TabBar = forwardRef(function (props, ref) {
onTabClick={handleTabClick}
onTabClose={handleTabClose}
onTabMouseDown={handleTabMouseDown}
onAudioStatusClick={handleAudioStatusClick}
/>)}
<div ref={spacerRef} className="spacer"></div>
</div>
@ -511,7 +487,6 @@ TabBar.propTypes = {
onTabMove: PropTypes.func.isRequired,
onRefocus: PropTypes.func.isRequired,
onContextMenu: PropTypes.func.isRequired,
onToggleAudio: PropTypes.func.isRequired,
tabs: PropTypes.arrayOf(
PropTypes.shape({
icon: PropTypes.element.isRequired,
@ -524,12 +499,7 @@ TabBar.propTypes = {
onTabClick: PropTypes.func.isRequired,
onTabMouseDown: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
renderTitle: PropTypes.bool.isRequired,
audioStatus: PropTypes.shape({
active: PropTypes.bool.isRequired,
paused: PropTypes.bool.isRequired,
}),
title: PropTypes.string.isRequired
})
).isRequired
};

View file

@ -33,7 +33,6 @@ const Draggable = require('./draggable');
const { CSSIcon, getCSSIcon } = require('components/icons');
const TYPING_TIMEOUT = 1000;
// TODO: Move these to CSS variables
const MINIMUM_ROW_HEIGHT = 20; // px
const RESIZER_WIDTH = 5; // px
const COLUMN_MIN_WIDTH = 20;
@ -144,10 +143,9 @@ class TreeSelection {
* @returns {boolean} False if nothing to select and select handlers won't be called
*/
select(index, shouldDebounce) {
index = Math.max(0, index);
if (!this._tree.props.isSelectable(index)) return;
index = Math.max(0, index);
if (this.selected.size == 1 && this.isSelected(index)) {
this._updateTree(shouldDebounce);
return false;
}
@ -163,12 +161,7 @@ class TreeSelection {
this._tree.scrollToRow(index);
this._updateTree(shouldDebounce);
if (this._tree.invalidate) {
const rowCount = this._tree.props.getRowCount();
toInvalidate.forEach((idx) => {
// this._updateTree() may change row count
if (idx >= rowCount) return;
this._tree.invalidateRow(idx);
});
toInvalidate.forEach(this._tree.invalidateRow.bind(this._tree));
}
return true;
}
@ -258,63 +251,6 @@ 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
@ -336,9 +272,8 @@ class TreeSelection {
}
set selectEventsSuppressed(val) {
let valChanged = val !== this._selectEventsSuppressed;
this._selectEventsSuppressed = val;
if (!val && valChanged) {
if (!val) {
this._updateTree();
if (this._tree.invalidate) {
this._tree.invalidate();
@ -388,8 +323,6 @@ class VirtualizedTable extends React.Component {
this._typingString = "";
this._jsWindowID = `virtualized-table-list-${Zotero.Utilities.randomString(5)}`;
this._containerWidth = props.containerWidth || window.innerWidth;
this.className = props.className || "";
this.firstColumnExtraWidth = (props.firstColumnExtraWidth) - (COLUMN_PADDING / 2); // missing left-padding of the content cell
this._columns = new Columns(this);
@ -429,24 +362,11 @@ class VirtualizedTable extends React.Component {
staticColumns: false,
alternatingRowColors: Zotero.isMac ? ['-moz-OddTreeRow', '-moz-EvenTreeRow'] : null,
firstColumnExtraWidth: 0,
// Render with display: none
hide: false,
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
@ -499,8 +419,6 @@ class VirtualizedTable extends React.Component {
staticColumns: PropTypes.bool,
// Used for initial column widths calculation
containerWidth: PropTypes.number,
// If first column is injected with extra stuff, like an item icon
// and we need to reserve extra min-width for it, set this prop
firstColumnExtraWidth: PropTypes.number,
// Internal windowed-list ref
@ -511,11 +429,6 @@ 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
@ -626,7 +539,7 @@ class VirtualizedTable extends React.Component {
*
* @param {Event} e
*/
_onKeyDown = async (e) => {
_onKeyDown = (e) => {
if (this.props.onKeyDown && this.props.onKeyDown(e) === false) return;
this._preventKeyboardScrolling(e);
@ -720,25 +633,6 @@ 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:
@ -746,7 +640,7 @@ class VirtualizedTable extends React.Component {
if (this.props.isContainer(this.selection.focused)
&& !this.props.isContainerEmpty(this.selection.focused)
&& this.props.isContainerOpen(this.selection.focused)) {
this.toggleOpenState(this.selection.focused);
this.props.toggleOpenState(this.selection.focused);
}
else if (parentIndex != -1) {
this.onSelection(parentIndex);
@ -757,7 +651,7 @@ class VirtualizedTable extends React.Component {
if (this.props.isContainer(this.selection.focused)
&& !this.props.isContainerEmpty(this.selection.focused)) {
if (!this.props.isContainerOpen(this.selection.focused)) {
this.toggleOpenState(this.selection.focused);
this.props.toggleOpenState(this.selection.focused);
}
else {
this.onSelection(this.selection.focused + 1);
@ -889,16 +783,7 @@ class VirtualizedTable extends React.Component {
* @param index {Number}
*/
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);
this._jsWindow && this._jsWindow.scrollToRow(index);
}
/**
@ -924,11 +809,6 @@ 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) {
@ -941,9 +821,7 @@ class VirtualizedTable extends React.Component {
}
// Normal selection
else if (!toggleSelection) {
// 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)) {
if (index > 0 && !this.props.isSelectable(index)) {
return;
}
this.selection.select(index, shouldDebounce);
@ -986,7 +864,10 @@ class VirtualizedTable extends React.Component {
event.stopPropagation();
const result = this._getResizeColumns();
if (!result) return;
const columns = this._getVisibleColumns();
const [aColumn, bColumn, resizingColumn] = result;
const isFirstColumn = columns[0].dataKey === aColumn.dataKey;
const firstColumnExtraWidth = isFirstColumn ? (this.props.firstColumnExtraWidth || 0) : 0;
const a = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(aColumn.dataKey)}`);
const b = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(bColumn.dataKey)}`);
const resizing = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(resizingColumn.dataKey)}`);
@ -1000,12 +881,9 @@ class VirtualizedTable extends React.Component {
const widthSum = aRect.width + bRect.width;
const aColumnPadding = aColumn.iconLabel ? 0 : COLUMN_PADDING;
const bColumnPadding = bColumn.iconLabel ? 0 : COLUMN_PADDING;
const aMinWidth = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding;
const bMinWidth = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding;
const aMaxWidth = widthSum - bMinWidth;
const aDragWidth = event.clientX - (RESIZER_WIDTH / 2) - offset;
// Constrain the drag position to the min and max widths
const aColumnWidth = Math.min(aMaxWidth, Math.max(aMinWidth, aDragWidth));
const aSpacingOffset = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding + firstColumnExtraWidth;
const bSpacingOffset = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding;
const aColumnWidth = Math.min(widthSum - bSpacingOffset, Math.max(aSpacingOffset, event.clientX - (RESIZER_WIDTH / 2) - offset));
const bColumnWidth = widthSum - aColumnWidth;
let onResizeData = {};
onResizeData[aColumn.dataKey] = aColumnWidth;
@ -1177,31 +1055,18 @@ class VirtualizedTable extends React.Component {
this._setXulTooltip();
this._topDiv.style.setProperty("--first-column-extra-width", `${this.firstColumnExtraWidth}px`);
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();
}
this._topDiv.style.setProperty("--firstColumnExtraWidth", `${this.props.firstColumnExtraWidth || 0}px`);
window.addEventListener("resize", () => {
this._debouncedRerender();
});
}
componentWillUnmount() {
this._resizeObserver?.disconnect();
if (this.props.stickySectionHeaders && this._jsWindow) {
this._jsWindow.targetElement.removeEventListener('scroll', this._updateStickySectionHeader);
}
this._jsWindow.destroy();
}
componentDidUpdate(prevProps) {
if (this.props.id !== prevProps.id
|| this.props.columns !== prevProps.columns) {
if (this.props.id !== prevProps.id) {
this._columns = new Columns(this);
this.forceUpdate();
}
@ -1247,13 +1112,13 @@ class VirtualizedTable extends React.Component {
return {
getItemCount: this.props.getRowCount,
itemHeight: this._rowHeight,
renderItem: this._renderItem.bind(this),
renderItem: this._renderItem,
targetElement: document.getElementById(this._jsWindowID),
customRowHeights: this.props.customRowHeights ?? []
};
}
_renderItem(index, oldElem = null) {
_renderItem = (index, oldElem = null) => {
let node = this.props.renderItem(index, this.selection, oldElem, this._getColumns());
if (!node.dataset.eventHandlersAttached) {
node.dataset.eventHandlersAttached = true;
@ -1268,12 +1133,8 @@ 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;
// 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);
node.classList.toggle('odd', index % 2 == 1);
node.classList.toggle('even', index % 2 == 0);
if (!node.hasAttribute('role')) {
node.setAttribute('role', 'row');
}
@ -1322,7 +1183,7 @@ class VirtualizedTable extends React.Component {
if (!column.iconLabel && column.sortDirection) {
sortIndicator = <CSSIcon name="sort-indicator" className={"icon-8 sort-indicator " + (column.sortDirection === 1 ? "ascending" : "descending")} />;
}
const className = cx("cell", column.className, { dragging: this.state.draggingColumn == index },
const className = cx("cell", column.className, { 'first-column': index === 0, dragging: this.state.draggingColumn == index },
{ "cell-icon": !!column.iconLabel });
return (<Draggable
onDragStart={this._handleColumnDragStart.bind(this, index)}
@ -1376,9 +1237,7 @@ class VirtualizedTable extends React.Component {
{
resizing: this.state.resizing,
'multi-select': this.props.multiSelect
},
this.className
]),
}]),
id: this.props.id,
ref: ref => this._topDiv = ref,
tabIndex: 0,
@ -1403,12 +1262,7 @@ class VirtualizedTable extends React.Component {
}
let jsWindowProps = {
id: this._jsWindowID,
// 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" : ""),
className: "virtualized-table-body",
onFocus: (e) => {
if (e.target.id == this._jsWindowID) {
// Focus should always remain on the list itself.
@ -1421,23 +1275,7 @@ class VirtualizedTable extends React.Component {
<div {...props}>
{columnDragMarker}
{header}
<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 {...jsWindowProps} />
</div>
);
}
@ -1449,7 +1287,6 @@ class VirtualizedTable extends React.Component {
if (!this._jsWindow) return;
this._jsWindow.invalidate();
this._updateWidth();
this._refreshStickySectionHeader();
}
/**
@ -1460,149 +1297,6 @@ 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 = () => {
@ -1665,6 +1359,8 @@ 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`);
@ -1711,19 +1407,10 @@ class VirtualizedTable extends React.Component {
&& row <= this._jsWindow.getLastVisibleRow();
}
toggleOpenState(index, ...args) {
let onToggleOpenState = this.props.toggleOpenState;
if (typeof onToggleOpenState == 'function') return onToggleOpenState(index, ...args);
}
async _resetColumns() {
this.invalidate();
this._columns = new Columns(this);
return new Promise((resolve) => {
this.forceUpdate(() => {
this._jsWindow.invalidate();
resolve();
});
})
await new Promise((resolve) => {this.forceUpdate(resolve)});
}
// Set aria-activedescendant on table container
@ -1736,143 +1423,6 @@ class VirtualizedTable extends React.Component {
}
}
/**
* VirtualizedTree wraps VirtualizedTable to provide common tree affordances:
* - Adds an indent spacer based on depth to the first visible cell
* - Adds a twisty for non-empty containers
* - Sets tree-specific ARIA attributes on rows and the container
* - Wires twisty mouse handlers to toggle container open state
*
* Consumers should provide isContainer/isContainerEmpty/isContainerOpen/onToggleOpenState
* and getParentIndex(index) to compute ancestry.
*/
class VirtualizedTree extends VirtualizedTable {
static propTypes = { ...VirtualizedTable.propTypes,
getParentIndex: PropTypes.func.isRequired,
isContainer: PropTypes.func.isRequired,
isContainerEmpty: PropTypes.func.isRequired,
isContainerOpen: PropTypes.func.isRequired,
onToggleOpenState: PropTypes.func.isRequired,
}
_toggledOpenStateIndex = null;
constructor(props) {
super(props);
this.className += " virtualized-tree";
this.firstColumnExtraWidth += 16; // 16px for twisty
}
toggleOpenState(index, ...args) {
this._toggledOpenStateIndex = index;
return this.props.onToggleOpenState(index, ...args);
}
_renderItem(index, oldElem=null) {
let node = super._renderItem(index, oldElem);
if (!(node instanceof (node?.ownerDocument?.defaultView || window).Element)) {
return node;
}
node = this._addIndentAndTwisty(node, index);
this._setRowAria(node, index);
return node;
}
_getDepth(index) {
let depth = 0;
try {
let parent = typeof this.props.getParentIndex == 'function' ? this.props.getParentIndex(index) : -1;
while (parent != -1 && typeof parent == 'number') {
depth++;
parent = this.props.getParentIndex(parent);
}
}
catch (e) {}
return depth;
}
/**
* Adds an indent spacer and twisty to the first cell of the node
*
* We add it to the first cell instead of as a separate pseudo-cell or just elements before
* the first cell because otherwise it messes with column spacing.
*
* @param node {HTMLElement} The rendered item row
* @param index {number} The index of the node being rendered
* @returns {HTMLElement}
*/
_addIndentAndTwisty(node, index) {
let firstCell = node.querySelector('.cell');
if (!firstCell) return node;
let twisty;
if (this.props.isContainerEmpty(index)) {
twisty = firstCell.querySelector('.spacer-twisty');
if (!twisty) {
twisty = node.ownerDocument.createElement('span');
firstCell.prepend(twisty);
twisty.classList.add('spacer-twisty');
}
firstCell.querySelector(`:scope > .twisty`)?.remove();
}
else {
twisty = firstCell.querySelector('.twisty');
if (!twisty) {
twisty = getCSSIcon('twisty');
twisty.classList.add('twisty');
twisty.style.pointerEvents = 'auto';
twisty.addEventListener('mousedown', (event) => event.stopPropagation());
twisty.addEventListener('mouseup', (event) => {
this.toggleOpenState(index);
event.stopPropagation();
}, { passive: true });
twisty.addEventListener('dblclick', (event) => event.stopImmediatePropagation(), { passive: true });
firstCell.prepend(twisty);
}
firstCell.querySelector(`:scope > .spacer-twisty`)?.remove();
// Apply the twisty animation
if (this._toggledOpenStateIndex == index) {
twisty.classList.toggle('open', !this.props.isContainerOpen(index));
requestAnimationFrame(() => {
twisty.classList.toggle('open', this.props.isContainerOpen(index));
this._toggledOpenStateIndex = null;
});
}
else {
twisty.classList.toggle('open', this.props.isContainerOpen(index));
}
}
let indentSpan = firstCell.querySelector('.cell-indent');
if (!indentSpan) {
indentSpan = node.ownerDocument.createElement('span');
indentSpan.className = 'cell-indent';
firstCell.prepend(indentSpan);
}
// Use padding for indent similar to ItemTree
const CHILD_INDENT = 16;
indentSpan.style.paddingInlineStart = (CHILD_INDENT * this._getDepth(index)) + 'px';
return node;
}
_setRowAria(node, index) {
const depth = this._getDepth(index);
node.setAttribute('role', 'treeitem');
node.setAttribute('aria-level', depth + 1);
if (!this.props.isContainerEmpty(index)) {
node.setAttribute('aria-expanded', !!this.props.isContainerOpen(index));
}
else {
node.removeAttribute('aria-expanded');
}
}
}
VirtualizedTree.propTypes = Object.assign({}, VirtualizedTable.propTypes);
VirtualizedTree.defaultProps = Object.assign({}, VirtualizedTable.defaultProps, { role: 'tree' });
/**
* Create a function that calls the given function `fn` only once per animation
* frame.
@ -1918,10 +1468,9 @@ var Columns = class {
// Fixed width columns can sometimes somehow obtain a width property
// this fixes it for users that may have run into the bug
if (column.fixedWidth && typeof columnsSettings[column.dataKey] == "object") {
delete columnsSettings[column.dataKey].width;
delete columnsSettings[column.dataKey].width;;
}
// Don't load column settings for disabled columns (they are overriden to be hidden)
column = Object.assign({}, column, column.disabled ? {} : columnsSettings[column.dataKey]);
column = Object.assign({}, column, columnsSettings[column.dataKey]);
column.className = cx(column.className, column.dataKey, column.dataKey + this._cssSuffix,
{ 'fixed-width': column.fixedWidth });
if (column.type) {
@ -1941,22 +1490,33 @@ var Columns = class {
// if new columns got added recently
columns.forEach((column, index) => column.ordinal = index);
// Compute initial CSS widths for visible columns. Widths are not persisted
// from here they flow to disk only when the user resizes (see onResize).
// Setting column widths
const visibleColumns =
columns.reduce((accumulator, column) => accumulator += column.hidden ? 0 : 1, 0);
const containerWidth = this._virtualizedTable._containerWidth;
let columnWidths = {};
for (let column of columns) {
if (column.disabled || column.hidden) continue;
if (column.width) {
columnWidths[column.dataKey] = column.width;
}
else {
column.flex = column.flex || 1;
columnWidths[column.dataKey] = column.width = this._computeFlexWidth(column);
}
}
for (let i = 0; i < columns.length; i++) {
let column = columns[i];
if (!column.hidden) {
if (column.width) {
columnWidths[column.dataKey] = column.width;
}
else {
column.flex = column.flex || 1;
columnWidths[column.dataKey] = column.width = containerWidth / visibleColumns * (column.flex || 1);
}
}
// Serializing back column settings for storage
columnsSettings[column.dataKey] = this._getColumnPrefsToPersist(column);
}
// Storing back persist settings to account for legacy upgrades
this._storePrefs(columnsSettings);
this._adjustColumnWidths();
// Set column width CSS rules
this.onResize(columnWidths);
// Whew, all this just to get a list of columns
}
_initializeStyleMap() {
@ -1966,17 +1526,16 @@ var Columns = class {
if (this._stylesheet) {
this._columnStyleMap = {};
for (let i = 0; i < this._stylesheet.sheet.cssRules.length; i++) {
const selector = this._stylesheet.sheet.cssRules[i].selectorText;
const dataKey = selector.slice(1, selector.length - this._cssSuffix.length);
const cssText = this._stylesheet.sheet.cssRules[i].cssText;
const dataKey = cssText.slice(1, cssText.indexOf('-'));
this._columnStyleMap[dataKey] = i;
}
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
let column = this._virtualizedTable.props.columns[i];
let escapedDataKey = window.CSS.escape(column.dataKey);
if (escapedDataKey in this._columnStyleMap) continue;
if (column.dataKey in this._columnStyleMap) continue;
const ruleIndex = Object.keys(this._columnStyleMap).length;
this._stylesheet.sheet.insertRule(`.${escapedDataKey + this._cssSuffix} {flex-basis: 100px}`, ruleIndex);
this._columnStyleMap[escapedDataKey] = ruleIndex;
this._stylesheet.sheet.insertRule(`.${window.CSS.escape(column.dataKey) + this._cssSuffix} {flex-basis: 100px}`, ruleIndex);
this._columnStyleMap[column.dataKey] = ruleIndex;
}
} else {
this._stylesheet = document.createElement('style');
@ -1985,9 +1544,8 @@ var Columns = class {
this._columnStyleMap = {};
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
let column = this._virtualizedTable.props.columns[i];
let escapedDataKey = window.CSS.escape(column.dataKey);
this._stylesheet.sheet.insertRule(`.${escapedDataKey + this._cssSuffix} {flex-basis: 100px}`, i);
this._columnStyleMap[escapedDataKey] = i;
this._stylesheet.sheet.insertRule(`.${window.CSS.escape(column.dataKey) + this._cssSuffix} {flex-basis: 100px}`, i);
this._columnStyleMap[column.dataKey] = i;
}
}
}
@ -2020,6 +1578,24 @@ var Columns = class {
this._virtualizedTable.props.storeColumnPrefs(prefs);
}
_adjustColumnWidths = () => {
if (!this._virtualizedTable.props.firstColumnExtraWidth) {
return;
}
const extraWidth = this._virtualizedTable.props.firstColumnExtraWidth;
this._columns.filter(c => !c.hidden).forEach((column, index) => {
const isFirstColumn = index === 0;
if (column.fixedWidth) {
column.width = isFirstColumn ? parseInt(column.originalWidth) + extraWidth : column.originalWidth;
}
if (column.staticWidth) {
column.minWidth = isFirstColumn ? (column.originalMinWidth ?? 20) + extraWidth : column.originalMinWidth;
column.width = isFirstColumn ? Math.max(parseInt(column.width) ?? 0, column.minWidth) : column.width;
}
});
};
/**
* Programatically sets the injected CSS width rules for each column.
* This is necessary for performance reasons
@ -2031,36 +1607,28 @@ var Columns = class {
var prefs = this._getPrefs();
}
let visibleColumns = this.getAsArray().filter(column => !column.hidden);
for (let [dataKey, width] of Object.entries(columnWidths)) {
if (typeof dataKey == "number") {
dataKey = this._columns[dataKey].dataKey;
}
const column = this._columns.find(column => column.dataKey == dataKey);
if (column.hidden) continue;
const styleIndex = this._columnStyleMap[window.CSS.escape(dataKey)];
const styleIndex = this._columnStyleMap[dataKey];
const columnPadding = column.iconLabel ? 0 : COLUMN_PADDING;
let cssWidth = width;
// It's set in CSS, so we subtract it here to prevent sliding
if (column.dataKey === visibleColumns[0].dataKey) {
cssWidth -= this._virtualizedTable.firstColumnExtraWidth;
if (storePrefs && !column.fixedWidth) {
column.width = width;
prefs[dataKey] = this._getColumnPrefsToPersist(column);
}
if (column.fixedWidth) {
cssWidth = width = column.width;
width = column.width;
}
if (column.fixedWidth && column.width || column.staticWidth) {
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex', `0 0`, `important`);
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `calc(var(--extra-width, 0px) + ${cssWidth}px`, 'important');
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `calc(var(--extra-width, 0px) + ${cssWidth}px`, 'important');
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `${width}px`, 'important');
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `${width}px`, 'important');
} else {
width -= columnPadding;
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `calc(var(--extra-width, 0px) + ${cssWidth}px`);
}
Zotero.debug(`Columns ${dataKey} width ${width}`);
if (storePrefs) {
column.width = width;
prefs[dataKey] = this._getColumnPrefsToPersist(column);
width = (width - columnPadding);
Zotero.debug(`Columns ${dataKey} width ${width}`);
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `${width}px`);
}
}
if (storePrefs) {
@ -2078,6 +1646,7 @@ var Columns = class {
return a.ordinal - b.ordinal;
});
this._adjustColumnWidths();
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
let prefs = this._getPrefs();
@ -2117,6 +1686,7 @@ var Columns = class {
this._columns.find(c => c.dataKey === 'title').hidden = false;
}
this._adjustColumnWidths();
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
this._storePrefs(prefs);
this._updateVirtualizedTable();
@ -2126,34 +1696,15 @@ var Columns = class {
const column = this._columns[index];
column.hidden = !column.hidden;
if (!column.hidden && !column.width && !(column.fixedWidth || column.staticWidth)) {
column.width = this._computeFlexWidth(column);
}
let prefs = this._getPrefs();
prefs[column.dataKey] = prefs[column.dataKey] || {};
prefs[column.dataKey].hidden = column.hidden;
if (prefs[column.dataKey]) {
prefs[column.dataKey].hidden = column.hidden;
}
this._adjustColumnWidths();
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
this._storePrefs(prefs);
this._updateVirtualizedTable();
}
_computeFlexWidth(column) {
const containerWidth = this._virtualizedTable._containerWidth;
const visibleColumns = this._columns.filter(c => !c.hidden);
let fixedWidth = 0;
let totalFlex = 0;
for (let col of visibleColumns) {
if (col.fixedWidth || col.staticWidth || !col.flex) {
fixedWidth += parseFloat(col.width) || col.minWidth || 0;
}
else {
totalFlex++;
}
}
let availableWidth = containerWidth - fixedWidth;
return availableWidth / totalFlex * (column.flex || 1);
}
toggleSort(sortIndex) {
if (!this._virtualizedTable.props.onColumnSort) return;
@ -2173,9 +1724,8 @@ var Columns = class {
}
}
});
let result = this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
this._virtualizedTable.forceUpdate();
return result;
}
getAsArray() {
@ -2185,8 +1735,8 @@ var Columns = class {
function renderCell(index, data, column, dir = null) {
column = column || { dataKey: "" };
if (column.renderCell) {
return column.renderCell(index, data, column, dir);
if (column.renderer) {
return column.renderer(index, data, column, dir);
}
let span = document.createElement('span');
span.className = `cell ${column.className}`;
@ -2287,10 +1837,12 @@ function makeRowRenderer(getRowData) {
else {
div.appendChild(renderCell(index, rowData[column.dataKey], column));
}
let columnName = formatColumnName(column);
let value = rowData[column.dataKey];
if (typeof value === "string" || typeof value === "number") {
ariaLabel += `${columnName}: ${value} `;
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]} `;
}
else {
ariaLabel += `${columnName} `;
@ -2310,8 +1862,7 @@ function formatColumnName(column) {
if (column.label in Zotero.Intl.strings) {
return Zotero.getString(column.label);
}
// Dotted keys (.properties) or hyphenated keys with 3+ segments (Fluent)
else if (/^[^\s]+\w\.\w[^\s]+$/.test(column.label) || /^\w+(-\w+){2,}$/.test(column.label)) {
else if (/^[^\s]+\w\.\w[^\s]+$/.test(column.label)) {
try {
let labelString = Zotero.getString(column.label);
if (labelString !== column.label) {
@ -2326,8 +1877,6 @@ function formatColumnName(column) {
}
module.exports = VirtualizedTable;
module.exports.VirtualizedTree = VirtualizedTree;
module.exports.TreeSelection = TreeSelection;
module.exports.TreeSelectionStub = TreeSelectionStub;
module.exports.renderCell = renderCell;

View file

@ -205,11 +205,8 @@ 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, topOffset = 0) {
scrollToRow(index, forceScrollToTop = false) {
const { scrollOffset } = this;
const itemCount = this._getItemCount();
const height = this.getWindowHeight();
@ -217,18 +214,11 @@ module.exports = class {
index = Math.max(0, Math.min(index, itemCount - 1));
let startPosition = this._getItemPosition(index);
let endPosition = this._getItemPosition(index + 1);
// If forceScrollToTop is set, always scroll to the start position even if the row is
// already visible. This is used when restoring scroll position, where we need an exact
// first-visible-row rather than just ensuring the row is within view.
if (forceScrollToTop) {
if (startPosition < scrollOffset) {
this.scrollTo(startPosition);
return;
}
if (startPosition - topOffset < scrollOffset) {
this.scrollTo(startPosition - topOffset);
}
else if (endPosition > scrollOffset + height) {
this.scrollTo(endPosition - height - 1);
this.scrollTo(forceScrollToTop ? startPosition : endPosition - height - 1);
}
}

View file

@ -61,9 +61,6 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
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.
@ -104,13 +101,10 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
componentDidUpdate(_prevProps, _prevState) {
Zotero.debug("Tag selector updated");
// 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;
}
// If we changed collections, scroll to top
if (this.collectionTreeRow && this.collectionTreeRow.id != this.prevTreeViewID) {
this.tagListRef.current.scrollToTop();
this.prevTreeViewID = this.collectionTreeRow.id;
}
}
@ -120,107 +114,24 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
return null;
}
/**
* 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
* that case showing no tags rather than throwing upwards and breaking the UI.
* Real bugs (TypeError, etc.) are re-thrown so they surface in tests and logs.
*/
async _safeGetTags(...args) {
try {
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) {
Zotero.logError(e);
return [];
}
throw e;
}
}
/**
* 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({ collectionTreeRows, libraryID }) {
async onItemViewChanged({ collectionTreeRow, libraryID }) {
Zotero.debug('Updating tag selector from current view');
var prevLibraryIDs = this.libraryIDs;
this.collectionTreeRows = collectionTreeRows;
this.libraryIDs = this._getSelectedLibraryIDs(collectionTreeRows, libraryID);
this.libraryID = this.libraryIDs[0];
var prevLibraryID = this.libraryID;
this.collectionTreeRow = collectionTreeRow;
this.libraryID = libraryID;
var newState = {
loaded: true
};
if (!Zotero.Utilities.arrayEquals(prevLibraryIDs, this.libraryIDs)) {
newState.tagColors = this._getScopeColors();
if (prevLibraryID != libraryID) {
if (libraryID) {
newState.tagColors = Zotero.Tags.getColors(libraryID);
}
else {
newState.tagColors = new Map();
}
}
var { tags, scope } = await this.getTagsAndScope();
newState.tags = tags;
@ -234,14 +145,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: this._getScopeColors()
tagColors: Zotero.Tags.getColors(this.libraryID)
});
}
return;
}
// Ignore anything other than deletes in duplicates view
if (this.collectionTreeRows?.[0]?.isDuplicates()) {
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
switch (event) {
case 'delete':
case 'trash':
@ -281,9 +192,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
}
// Check tags for each tag type to see if they're in view/scope
for (let [type, tagIDs] of tagsByType) {
changedTagsInScope.push(...await this._safeGetTags([type], tagIDs));
changedTagsInScope.push(...await this.collectionTreeRow.getTags([type], tagIDs));
if (this.displayAllTags) {
changedTagsInView.push(...await this._getAllTagsInLibraries(tagIDs));
changedTagsInView.push(
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
);
}
}
if (!this.displayAllTags) {
@ -402,11 +315,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
}
async getTagsAndScope() {
var tags = await this._safeGetTags();
var tags = await this.collectionTreeRow.getTags();
// 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 this._getAllTagsInLibraries();
tags = await Zotero.Tags.getAll(this.libraryID);
}
// If tags haven't changed, return previous array without sorting again
@ -584,10 +497,10 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
});
tags = coloredTags.concat(tags);
// Filter (case- and accent-insensitive, matching search conditions)
// Filter
if (this.state.searchString) {
let normalizedStr = Zotero.Utilities.Internal.normalizeForSearch(this.state.searchString);
tags = tags.filter(tag => Zotero.Utilities.Internal.normalizeForSearch(tag.tag).includes(normalizedStr));
let lcStr = this.state.searchString.toLowerCase();
tags = tags.filter(tag => tag.tag.toLowerCase().includes(lcStr));
}
// Prepare tag objects for list component
@ -650,14 +563,6 @@ 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(
@ -739,11 +644,7 @@ 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) {
@ -819,7 +720,6 @@ 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);
@ -883,16 +783,12 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
var tagID = Zotero.Tags.getID(this.contextTag.name);
// 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);
}
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);
}
}

View file

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

View file

@ -53,8 +53,8 @@ var ZoteroContextPane = new function () {
return this.splitter.getAttribute('state') === 'collapsed';
},
set: (collapsed) => {
_contextPane.toggleAttribute('collapsed', !!collapsed);
_contextPaneInner.toggleAttribute('collapsed', !!collapsed);
_contextPane.setAttribute('collapsed', !!collapsed);
_contextPaneInner.setAttribute('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.removeAttribute('hidden');
_contextPaneSplitterStacked.setAttribute('hidden', false);
_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.removeAttribute('hidden');
_contextPaneSplitter.setAttribute('hidden', false);
_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.hasAttribute('collapsed')) {
if (_contextPane.getAttribute('collapsed') != 'true') {
height = _contextPaneInner.getBoundingClientRect().height;
}
}
_contextPaneInner.toggleAttribute('collapsed', this.collapsed);
_contextPaneInner.setAttribute('collapsed', this.collapsed ? 'true' : 'false');
let tabContent = _getTabContent();
if (tabContent) {

View file

@ -28,13 +28,12 @@
Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
Services.scriptloader.loadSubScript("chrome://global/content/customElements.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/base.js", this);
Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemPaneSection.js', this);
Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMenuBar.js', this);
{
// 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'],
@ -51,10 +50,8 @@ 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'],
@ -65,7 +62,6 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['zoterosearchcondition', 'chrome://zotero/content/elements/zoteroSearch.js'],
['zoterosearchtextbox', 'chrome://zotero/content/elements/zoteroSearch.js'],
['zoterosearchagefield', 'chrome://zotero/content/elements/zoteroSearch.js'],
['item-pane-custom-section', 'chrome://zotero/content/elements/itemPaneCustomSection.js'],
['item-pane-header', 'chrome://zotero/content/elements/itemPaneHeader.js'],
['editable-text', 'chrome://zotero/content/elements/editableText.js'],
['item-pane-sidenav', 'chrome://zotero/content/elements/itemPaneSidenav.js'],
@ -82,7 +78,6 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['libraries-collections-box', 'chrome://zotero/content/elements/librariesCollectionsBox.js'],
['autocomplete-textarea', 'chrome://zotero/content/elements/autocompleteTextArea.js'],
['bubble-input', 'chrome://zotero/content/elements/bubbleInput.js'],
['file-renaming-settings', 'chrome://zotero/content/elements/fileRenamingSettings.js']
]) {
customElements.setElementCreationCallback(tag, () => {
Services.scriptloader.loadSubScript(script, window);
@ -340,89 +335,6 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
delete document.createXULElement("menulist");
}
// Space on menulist should open the popup
// Space on menulist option will select it, same as Enter
document.addEventListener("keydown", async (event) => {
let target = event.originalTarget;
if (target.tagName !== "menulist") return;
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
if (event.key !== " ") return;
if (target.open) {
// Simulate blinking of the selected menuitem on macOS (same as on Return keypress)
if (Zotero.isMac) {
let interval = 70;
target.activeChild.removeAttribute("_moz-menuactive");
await Zotero.Promise.delay(interval);
target.activeChild.setAttribute("_moz-menuactive", true);
await Zotero.Promise.delay(interval);
target.activeChild.removeAttribute("_moz-menuactive");
await Zotero.Promise.delay(interval);
target.activeChild.doCommand();
target.addEventListener("popuphiding", () => {
target.selectedItem.setAttribute("selected", true);
}, { once: true });
target.open = false;
}
// No blinking happens on Linux or Windows
else {
target.activeChild.doCommand();
// Timeout to avoid empty context menu behind an alert or dialog if one
// appears on 'command' event on Windows (https://github.com/zotero/zotero/issues/5633)
setTimeout(() => {
target.open = false;
});
}
}
else {
target.open = true;
}
event.stopPropagation();
event.preventDefault();
}, true);
if (Zotero.isWin) {
// ArrowUp/ArrowDown should change the active menuitem without triggering the command event.
// Otherwise navigating menulist options via keyboard triggers alerts meant to fire after
// a confirmed selection (e.g. language change alert in preferences). Only relevant to Windows.
document.addEventListener("keydown", (event) => {
let target = event.originalTarget;
if (target.tagName !== "menulist") return;
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
if (!(["ArrowUp", "ArrowDown"].includes(event.key) && target.open)) return;
// Locate the deepest open menu
let currentMenu = target;
while (currentMenu.activeChild?.tagName === "menu" && currentMenu.activeChild.open) {
currentMenu = currentMenu.activeChild;
}
// Determine which menu item should be marked as selected
let nextItem;
// If the menu has no active child, arrowDown will select the first item
if (!currentMenu.activeChild) {
if (event.key !== "ArrowDown") return;
nextItem = currentMenu.querySelector("menuitem,menu");
}
// If there is an active child, arrowUp/Down will move to the previous/next item
else {
let node = currentMenu.activeChild;
do {
node = event.key === "ArrowUp" ? node.previousElementSibling : node.nextElementSibling;
} while (node && !["menu", "menuitem"].includes(node.tagName));
nextItem = node;
}
// Set the item as active to highlight it
if (nextItem) {
currentMenu.activeChild = nextItem;
}
// Prevent the default event handling which fires the 'command' event
event.stopImmediatePropagation();
}, true);
}
// inject custom CSS into FF built-in custom elements
const InjectCSSConfig = {
global: [
@ -442,6 +354,7 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
}
],
mac: [
"wizard",
{
element: "dialog",
// The `attachShadow` are cleared in <dialog>, we need to monkey-patch after `connectedCallback`.

View file

@ -1,9 +1,5 @@
/* eslint-disable no-extend-native */
// Patches Date.prototype.toLocale*String in whichever global this script is
// loaded into, so that date formatting follows the app locale when no
// explicit locale is passed
let originalToLocaleString = Date.prototype.toLocaleString;
Date.prototype.toLocaleString = function (locales, options) {
if (locales === undefined || (Array.isArray(locales) && !locales.length)) {

View file

@ -26,10 +26,6 @@
"use strict";
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
ChromeUtils.importESModule("chrome://zotero/content/actors/ActorManager.mjs");
const SANDBOX_ALL_FLAGS = 0xFFFFF;
@ -43,10 +39,6 @@
</collapsible-section>
`);
_mode = 'view';
_fieldAlternatives = {};
get item() {
return this._item;
}
@ -74,36 +66,6 @@
super.editable = editable;
}
get mode() {
return this._mode;
}
set mode(val) {
switch (val) {
case 'view':
case 'edit':
case 'merge':
break;
case 'fieldmerge':
this._fieldAlternatives = {};
break;
default:
throw new Error(`Invalid mode '${val}'`);
}
this._mode = val;
this.setAttribute('mode', val);
}
set fieldAlternatives(val) {
if (val.constructor.name != 'Object') {
throw Error('fieldAlternatives must be an Object in <abstract-box>.fieldAlternatives');
}
if (this._mode != 'fieldmerge') {
throw Error('fieldAlternatives is valid only in fieldmerge mode in <abstract-box>.fieldAlternatives');
}
this._fieldAlternatives = val;
}
init() {
this._notifierID = Zotero.Notifier.registerObserver(this, ['item', 'tab'], 'abstractBox');
@ -138,13 +100,7 @@
throw new Error('Item has not been added to library');
}
this._item.setField('abstractNote', this._abstractField.value);
await this._item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString('abstractNote'),
count: 1
}
});
await this._item.saveTx();
}
this._forceRenderAll();
}
@ -201,59 +157,6 @@
}
this._abstractField.readOnly = !this.editable;
this._abstractField.setAttribute('aria-label', Zotero.ItemFields.getLocalizedString('abstractNote'));
this._renderFieldVersionButton();
}
_renderFieldVersionButton() {
let existing = this.querySelector('.zotero-field-version-button');
if (existing) {
existing.remove();
}
if (this._mode !== 'fieldmerge') {
return;
}
let alternatives = this._fieldAlternatives?.abstractNote;
if (!alternatives || !alternatives.length) {
return;
}
let button = document.createXULElement('toolbarbutton');
button.className = 'zotero-field-version-button zotero-clicky-merge';
document.l10n.setAttributes(button, 'itembox-button-merge', {
field: Zotero.ItemFields.getLocalizedString('abstractNote') || ''
});
let popup = button.appendChild(document.createXULElement('menupopup'));
for (let v of alternatives) {
let menuitem = document.createXULElement('menuitem');
let sv = Zotero.Utilities.ellipsize(v, 60);
menuitem.setAttribute('label', sv);
if (v != sv) {
menuitem.setAttribute('tooltiptext', v);
}
menuitem.setAttribute('originalValue', v);
menuitem.addEventListener('command', () => {
this.item.setField('abstractNote', menuitem.getAttribute('originalValue'));
this._forceRenderAll();
});
popup.appendChild(menuitem);
}
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (event.screenX) {
popup.openPopupAtScreen(event.screenX - 5, event.screenY + 5, true);
}
else {
popup.openPopup(button, 'after_start');
}
});
this.querySelector('.body').appendChild(button);
}
_ensureFeedAbstractBrowserExists = Zotero.Utilities.Internal.serial(async () => {

View file

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

View file

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

View file

@ -39,23 +39,13 @@
return this._items || [];
}
get filter() {
return (this._filter || "").toLowerCase();
}
set filter(val) {
this._filter = val;
}
init() {
this._body = this.querySelector('.body');
this._notifierID = Zotero.Notifier.registerObserver(this, ['item']);
this._body.addEventListener('keydown', this._handleKeyDown);
}
destroy() {
Zotero.Notifier.unregisterObserver(this._notifierID);
this._body.removeEventListener('keydown', this._handleKeyDown);
}
notify(action, type, ids) {
@ -85,7 +75,7 @@
let topLevelItems = Zotero.Items.getTopLevel(this.items);
// Remove collapsible sections for top-level items that no longer have any annotations
// Remove collapsible sections for top-level items whose annotations are no longer selected
for (let section of [...this.querySelectorAll("collapsible-section")]) {
let parentID = section.dataset.pane.split("-")[1];
if (!topLevelItems.some(item => item.id == parentID)) {
@ -93,8 +83,7 @@
}
}
for (let parentItem of topLevelItems) {
let allAnnotations = this.items.filter(item => item.topLevelItem.id == parentItem.id);
let visibleAnnotations = allAnnotations.filter(item => this._passesFilter(item));
let selectedAnnotations = this.items.filter(item => item.topLevelItem.id == parentItem.id);
// Create a collapsible section for each top-level item if it does not exist yet
let section = this.querySelector(`[data-pane="annotations-${parentItem.id}"]`);
if (!section) {
@ -109,23 +98,17 @@
section.appendChild(sectionBody);
this._body.append(section);
}
document.l10n.setArgs(section, { count: visibleAnnotations.length });
// Hide section if all of its annotations are filtered out
section.toggleAttribute("hidden", visibleAnnotations.length === 0);
// Add annotations into this collapsible section (create if they don't exist)
for (let annotation of allAnnotations) {
let row = this.querySelector(`annotation-row[annotation-id="${annotation.id}"]`);
if (!row) {
row = document.createXULElement('annotation-row');
row.annotation = annotation;
section.querySelector('.body').append(row);
}
// Hide annotation rows that are filtered out
row.toggleAttribute("hidden", !this._passesFilter(annotation));
document.l10n.setArgs(section, { count: selectedAnnotations.length });
// Add annotations into this collapsible section
for (let annotation of selectedAnnotations) {
// Skip rows that already exist
if (this.querySelector(`annotation-row[annotation-id="${annotation.id}"]`)) continue;
let row = document.createXULElement('annotation-row');
row.annotation = annotation;
section.querySelector('.body').append(row);
}
}
// Remove annotation rows for annotations that are no longer in this.items
// Remove annotation rows for annotations that are no longer selected
for (let row of [...this.querySelectorAll("annotation-row")]) {
let rowID = row.getAttribute("annotation-id");
if (!this.items.some(obj => obj.id == rowID)) {
@ -145,40 +128,6 @@
append,
});
}
_passesFilter(annotation) {
if (!this.filter) return true;
let text = (annotation.annotationText || "").toLowerCase();
let comment = (annotation.annotationComment || "").toLowerCase();
let tags = (annotation.getTags() || []).map(tag => tag.tag.toLowerCase()).join(" ");
return text.includes(this.filter) || comment.includes(this.filter) || tags.includes(this.filter);
}
// Handle arrowUp/Down navigation between focused annotation rows
_handleKeyDown = (event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
let currentRow = event.target.closest('annotation-row');
if (!currentRow) return;
let visibleRows = [...this.querySelectorAll('annotation-row:not([hidden])')];
let currentIndex = visibleRows.indexOf(currentRow);
if (currentIndex === -1) return;
let nextIndex;
if (event.key === 'ArrowDown') {
nextIndex = currentIndex + 1;
}
else {
nextIndex = currentIndex - 1;
}
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
visibleRows[nextIndex].focus();
event.preventDefault();
}
};
}
customElements.define("annotation-items-pane", AnnotationItemsPane);

View file

@ -31,7 +31,6 @@
<html:div class="head">
<image class="icon"/>
<html:div class="title"/>
<html:div class="action"/>
</html:div>
<html:div class="body"/>
<html:div class="tags"/>
@ -40,7 +39,7 @@
_annotation = null;
static get observedAttributes() {
return ['annotation-id', 'action'];
return ['annotation-id'];
}
attributeChangedCallback(name, oldValue, newValue) {
@ -48,9 +47,6 @@
case 'annotation-id':
this._annotation = Zotero.Items.get(newValue);
break;
case 'action':
this.action = newValue;
break;
}
this.render();
}
@ -64,21 +60,6 @@
this.setAttribute('annotation-id', annotation.id);
}
get action() {
return this._action;
}
set action(val) {
if (!val) {
this._action = null;
return;
}
if (val !== "plus") {
throw new Error("Invalid button value: " + val);
}
this._action = val;
}
init() {
this._head = this.querySelector('.head');
this._title = this.querySelector('.title');
@ -88,7 +69,7 @@
}
render() {
if (!this.initialized || !this._annotation) return;
if (!this.initialized) return;
this._title.textContent = Zotero.getString('pdfReader.page') + ' '
+ (this._annotation.annotationPageLabel || '-');
@ -98,14 +79,6 @@
type = 'area';
}
this.querySelector('.icon').src = 'chrome://zotero/skin/16/universal/annotate-' + type + '.svg';
this.querySelector('.action').replaceChildren();
if (this.action) {
let icon = document.createXULElement('toolbarbutton');
icon.classList.add('zotero-clicky');
icon.classList.add('zotero-clicky-' + this.action);
icon.setAttribute('action', this.action);
this.querySelector('.action').append(icon);
}
this._body.replaceChildren();
if (['image', 'ink'].includes(this._annotation.annotationType)) {
@ -153,7 +126,7 @@
this.style.setProperty('--annotation-color', this._annotation.annotationColor);
// A11y - make focusable + add screen reader's labels
this.setAttribute("tabindex", 0);
let annotationTypeStr = Zotero.getString(`reader-${this.annotation.annotationType}-annotation`);
let annotationTypeStr = Zotero.getString(`pdfReader.${this.annotation.annotationType}Annotation`);
let a11yLabel = this._annotation.annotationText ? `${Zotero.getString('pdfReader.annotationText')}: ${this._annotation.annotationText}.` : annotationTypeStr;
let ariaComment = this._annotation.annotationComment ? `${Zotero.getString('pdfReader.annotationComment')}: ${this._annotation.annotationComment}.` : '';
let ariaTags = tags.length ? `${Zotero.getString('itemFields.tags')}: ${tags.map(tag => tag.tag).join(', ')}.` : '';

View file

@ -24,11 +24,6 @@
*/
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class AttachmentAnnotationsBox extends ItemPaneSectionElementBase {
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-attachments-annotations" data-pane="attachment-annotations">

View file

@ -28,10 +28,6 @@
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
let { canRenameFileFromParent, renameFileFromParent } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
class AttachmentBox extends ItemPaneSectionElementBase {
content = MozXULElement.parseXULToFragment(`
@ -324,17 +320,12 @@
}
notify(event, _type, ids, _extraData) {
if (event == 'modify' && ids.includes(this.item?.parentItem?.id)) {
// Ensure the "Rename from Parent" button is visible after the parent item changes (#5542, #5816)
if (this.hidden) {
this._resetRenderedFlags();
}
else {
this.updateInfo();
}
if (ids.includes(this.item?.parentItem?.id)) {
// Ensure the "Rename from Parent" button is visible after the parent item changes (#5542)
this._resetRenderedFlags();
}
if (event != 'modify' || !this.item?.id || !ids.includes(this.item.id)) return;
Promise.all([
this.updateInfo(),
this.updatePreview()
@ -349,9 +340,6 @@
this._previewDiscarded = false;
this.previewElem.render();
}
else {
this.previewElem.keepAlive();
}
return;
}
@ -593,7 +581,7 @@
}
if (show) {
reindexButton.removeAttribute('hidden');
reindexButton.setAttribute('hidden', false);
}
else {
reindexButton.setAttribute('hidden', true);
@ -765,13 +753,7 @@
_handleTitleBlur = () => {
this.item.setField('title', this._id('title').value);
this.item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString('title'),
count: 1
}
});
this.item.saveTx();
};
_handleFileNameFocus = () => {

View file

@ -24,11 +24,6 @@
*/
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class PreviewRenderAbortError extends Error {
constructor() {
super("AttachmentPreview render aborted");
@ -268,7 +263,7 @@
notify(event, type, ids, extraData) {
if (!this.item) return;
if (this.isReaderType && this._reader && this._reader._internalReader) {
if (this.isReaderType && this._reader) {
// Following chrome/content/zotero/xpcom/reader.js
if (event === "delete") {
let disappearedIDs = this._reader.annotationItemIDs.filter(x => ids.includes(x));
@ -350,19 +345,6 @@
});
}
/**
* Update the last task UID to keep the preview alive. This prevents deferred discard when
* the preview is still in use. Useful when the preview moves out of view but later enters
* the view again before the discard timeout.
*/
async keepAlive() {
if (!this._lastTaskUID || this._isProcessingTask) {
return;
}
let newUID = `${Date.now()}-${Math.random()}`;
this._lastTaskUID = newUID;
}
/**
* Clear all pending tasks and reset processing states.
*/
@ -423,18 +405,13 @@
this._debug(`Processing task ${task.type} (${uid})`);
try {
switch (task.type) {
case "render":
await Promise.race([this._processRender(task.data), Zotero.Promise.delay(3000)]);
break;
case "discard":
await Promise.race([this._processDiscard(task.data), Zotero.Promise.delay(3000)]);
break;
}
}
catch (e) {
this._debug(`Task ${task.type} (${uid}) failed: ${e}`);
switch (task.type) {
case "render":
await Promise.race([this._processRender(task.data), Zotero.Promise.delay(3000)]);
break;
case "discard":
await Promise.race([this._processDiscard(task.data), Zotero.Promise.delay(3000)]);
break;
}
this._isProcessingTask = false;
@ -459,12 +436,6 @@
this._debug(`Rendering item ${itemID}, previewType: ${previewType}`);
// Capture the current task UID to detect zombie renders.
// When _processTask's Promise.race times out, this render continues
// as a zombie while a new task starts. The new task changes
// _lastTaskUID, so the zombie can detect it's outdated.
let taskUID = this._lastTaskUID;
this._isRendering = true;
let success = false;
@ -503,11 +474,7 @@
}
}
finally {
// Only update status if this is still the current task,
// not a zombie from a timed-out Promise.race
if (this._lastTaskUID === taskUID) {
this.setPreviewStatus(success ? "success" : "fail");
}
this.setPreviewStatus(success ? "success" : "fail");
this._isRendering = false;
this._debug(`Render processed, item ${itemID} ${success ? "succeeded" : "failed"}`);
@ -550,16 +517,6 @@
let nextPreview = this._id("next-preview");
if (nextPreview) {
nextPreview.id = "preview";
// If the browser's document is already loaded, resolve the promise
// immediately. After the rename, its DOMContentLoaded would match
// "preview" instead of "next-preview", so the normal handler might
// not resolve this. We must check that readyState is "interactive"
// or "complete" (not just that contentWindow exists) because
// contentWindow can exist before DOMContentLoaded fires.
let readyState = nextPreview.contentDocument?.readyState;
if (readyState === "interactive" || readyState === "complete") {
this._nextPreviewInitializePromise.resolve();
}
}
this._debug("Preview discarded");
@ -655,10 +612,10 @@
*/
async _renderReader(itemID) {
this.setPreviewStatus("loading");
// This only need to be awaited during first load.
// _initializePromise (awaited in _processTask) already ensures both
// preview and next-preview are ready on first load.
// This only need to be awaited during first load
await this._previewInitializePromise.promise;
// This should be awaited in the following refreshes
await this._nextPreviewInitializePromise.promise;
this._tryAbortRender(itemID);
@ -713,13 +670,6 @@
_handleReaderLoad(event) {
if (this._id("preview")?.contentWindow?.document === event.target) {
this._previewInitializePromise.resolve();
// If there is no next-preview element, this preview was likely
// a next-preview promoted by _processDiscard before its
// DOMContentLoaded fired. Resolve that promise too so
// _processDiscard doesn't hang awaiting it.
if (!this._id("next-preview")) {
this._nextPreviewInitializePromise.resolve();
}
}
else if (this._id("next-preview")?.contentWindow?.document === event.target) {
this._nextPreviewInitializePromise.resolve();

View file

@ -25,11 +25,6 @@
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class AttachmentPreviewBox extends ItemPaneSectionElementBase {
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-attachment-preview" data-pane="attachment-preview">

View file

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

View file

@ -26,11 +26,6 @@
"use strict";
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class AttachmentsBox extends ItemPaneSectionElementBase {
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-attachments" data-pane="attachments" extra-buttons="add">
@ -219,9 +214,6 @@
}
}
}
else {
this.previewElem.keepAlive();
}
return;
}
this._renderStage = "final";

View file

@ -36,20 +36,11 @@
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);
}
set sortable(value) {
this.setAttribute("sortable", !!value);
}
get sortable() {
return this.getAttribute("sortable") == "true";
}
focus() {
this.refocusInput();
}
@ -124,10 +115,11 @@
if (isOnlyInput) {
document.l10n.setAttributes(this._body.firstChild, `integration-citationDialog-single-input-${dialogType}`);
}
// otherwise, set default placeholders for all inputs, with special handling
// of the last input after a bubble is added with visible placeholder
// otherwise, add a regular aria descriptions and placeholders to all inputs
else {
Utils.setupInputPlaceholders(this.showJustAddedPlaceholder, dialogType);
for (let input of [...this.querySelectorAll(".input")]) {
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
}
}
// If any two inputs end up next to each other (e.g. after bubble is deleted),
// have them merged
@ -220,7 +212,7 @@
*/
_createBubble(content, dialogReferenceID) {
let bubble = document.createElement("div");
bubble.setAttribute("draggable", this.sortable);
bubble.setAttribute("draggable", "true");
bubble.setAttribute("role", "button");
bubble.setAttribute("tabindex", "0");
bubble.setAttribute("data-l10n-id", "integration-citationDialog-aria-bubble");
@ -253,7 +245,7 @@
*/
_onBubbleKeydown(event) {
let bubble = event.target;
if (["ArrowLeft", "ArrowRight"].includes(event.key) && event.shiftKey && this.sortable) {
if (["ArrowLeft", "ArrowRight"].includes(event.key) && event.shiftKey) {
// On Shift-Left/Right swap focused bubble with it's neighbor
event.preventDefault();
event.stopPropagation();
@ -339,20 +331,10 @@
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;
}
@ -408,11 +390,9 @@
},
handleDragStart(event) {
if (!this.bubbleInput.sortable) return false;
this.dragBubble = event.target;
event.dataTransfer.setData("text/plain", '<span id="zotero-drag"/>');
event.stopPropagation();
return true;
},
handleDragEnter(event) {
@ -452,6 +432,7 @@
// Handle drag-drop of items from the citationDialog into bubble-input to add them
if (itemIDs) {
itemIDs = itemIDs.split(",");
console.log(itemIDs);
let newIndex = 0;
if (this.dragOver) {
newIndex = [...this.bubbleInput.querySelectorAll(".bubble")].findIndex(node => this.dragOver == node);
@ -606,95 +587,9 @@
this.bubbleInput._body.appendChild(span);
let spanWidth = span.getBoundingClientRect().width;
span.remove();
// set min-width of 1px if the input is focused to ensure
// that the cursor is always visible
if (document.activeElement == input && !spanWidth) {
spanWidth = 1;
}
return spanWidth;
},
getTextWidth(text) {
let span = document.createElement("span");
span.classList = "input";
span.innerText = text;
this.bubbleInput._body.appendChild(span);
let spanWidth = span.getBoundingClientRect().width;
span.remove();
return spanWidth;
},
// Return the longest prefix of `text` such that prefix + "…" fits within maxWidth,
// or the original text if it already fits. Used for placeholder truncation, since
// text-overflow:ellipsis doesn't work on <input> in Firefox chrome.
truncateToWidth(text, maxWidth) {
if (this.getTextWidth(text) <= maxWidth) return text;
let ellipsis = "…";
for (let i = text.length - 1; i > 0; i--) {
let candidate = text.slice(0, i) + ellipsis;
if (this.getTextWidth(candidate) <= maxWidth) {
return candidate;
}
}
return ellipsis;
},
// Set aria-descriptions and placeholders on every input in the bubble-input.
// For the just-added input (the last one, when showJustAddedPlaceholder is set), the
// placeholder is truncated to the space remaining on its line and passed to Fluent as
// a variable.
setupInputPlaceholders(showJustAddedPlaceholder, dialogType) {
let allInputs = [...this.bubbleInput.querySelectorAll(".input")];
let lastInput = allInputs[allInputs.length - 1];
let bodyRight = this.bubbleInput._body.getBoundingClientRect().right;
for (let input of allInputs) {
let isJustAdded = showJustAddedPlaceholder && input === lastInput;
if (!isJustAdded) {
// If the just-added placeholder was dismissed (e.g. by right-arrow at the
// end of the input) while the input is still focused and empty, keep the
// visible placeholder but fall back to the default search prompt, since
// searching still works. It collapses when the input loses focus (see the
// blur handler in _createInputElem())
if (input.classList.contains("just-added-placeholder")
&& document.activeElement == input && !input.value) {
let placeholder = this.truncateToWidth(
" " + Zotero.getString("integration-citationDialog-search-for-items"),
parseFloat(input.style.minWidth) || Infinity);
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title: "" });
continue;
}
input.classList.remove("just-added-placeholder");
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
// Clear any stale title and min-width left over from a previous just-added state
input.removeAttribute("title");
input.style.minWidth = "";
}
if (isJustAdded && !input.classList.contains("just-added-placeholder")) {
input.classList.add("just-added-placeholder");
// Leading NBSP gives a small visual gap between the cursor and the placeholder
// text (CSS padding/text-indent on input or ::placeholder both move the cursor too)
let fullPlaceholder = " " + Zotero.getString("integration-citationDialog-just-added-input-placeholder");
let availableWidth = bodyRight - input.getBoundingClientRect().left - 20;
let placeholderWidth = this.getTextWidth(fullPlaceholder);
let placeholder, title, minWidth;
if (availableWidth >= placeholderWidth) {
placeholder = fullPlaceholder;
title = "";
minWidth = placeholderWidth;
}
else {
placeholder = this.truncateToWidth(fullPlaceholder, availableWidth);
title = fullPlaceholder;
minWidth = availableWidth;
}
// min-width keeps the placeholder visible when the input is empty, but
// lets the input grow to fit content the user types beyond the placeholder.
input.style.minWidth = minWidth + 'px';
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title });
}
}
},
// If a bubble is removed between two inputs we need to combine them
combineNeighboringInputs(startingNode) {
let node = startingNode;

View file

@ -43,8 +43,7 @@
set open(newOpen) {
newOpen = !!newOpen;
let oldOpen = this.open;
if (oldOpen === newOpen || this.empty) return;
if (!newOpen && !this.collapsible) return;
if (oldOpen === newOpen || this.empty || !this.collapsible) return;
this.render();
// Force open before getting scrollHeight, so we get the right value
@ -119,22 +118,9 @@
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', 'no-collapse'];
return ['open', 'empty', 'label', 'summary', 'extra-buttons'];
}
attributeChangedCallback(name) {
@ -155,10 +141,7 @@
this._head = document.createElement('div');
this._head.role = 'button';
this._head.className = 'head';
// remove header from focus order in non-collapsible sections
if (!this._disableCollapsing || !this.collapsible) {
this._head.setAttribute("tabindex", "0");
}
this._head.setAttribute("tabindex", "0");
this._head.addEventListener('mousedown', this._handleMouseDown);
this._head.addEventListener('click', this._handleClick);
this._head.addEventListener('keydown', this._handleKeyDown);
@ -213,7 +196,7 @@
}
_buildContextMenu() {
let containerRoot = this.closest('.item-pane-container-root, .zotero-view-item-container, context-notes-list');
let containerRoot = this.closest('.zotero-view-item-container, context-notes-list');
let contextMenu = document.createXULElement('menupopup');
@ -312,7 +295,7 @@
let canMoveUp = sidenav?.isPaneMovable(this.dataset.pane, 'up');
let canMoveDown = sidenav?.isPaneMovable(this.dataset.pane, 'down');
let canReset = !!sidenav?.container?.supportsReorder && sidenav?.isOrderChanged();
let canReset = sidenav?.isOrderChanged();
moveSectionUp.hidden = !canMoveUp;
moveSectionDown.hidden = !canMoveDown;
@ -362,7 +345,7 @@
}
_saveOpenState() {
if (this._disableSavingOpenState || this._skipSaveOpenState) return;
if (this._disableSavingOpenState) return;
Zotero.Prefs.set(`panes.${this.dataset.pane}.open`, this.open);
}
@ -397,11 +380,11 @@
}
get _disableContextMenu() {
return !this._getSidenav() || !this.showContextMenu || !!this.closest('annotation-items-pane');
return !this._getSidenav() || !!this.closest('annotation-items-pane');
}
_handleClick = (event) => {
if (this._disableCollapsing || !this.collapsible) return;
if (this._disableCollapsing) return;
if (event.target.closest('.section-custom-button, menupopup')) return;
this.open = !this.open;
};
@ -431,7 +414,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 && this.collapsible) {
if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key) && !this._disableCollapsing) {
stopEvent();
this.open = ([" ", "Enter"].includes(event.key)) ? !this.open : (event.key == Zotero.arrowNextKey);
event.target.focus();
@ -494,7 +477,7 @@
this._title.textContent = this.label;
this._summary.textContent = this.summary;
let twisty = this._head.querySelector('.twisty');
twisty.hidden = this._disableCollapsing || !this.collapsible;
twisty.hidden = this._disableCollapsing;
document.l10n.setAttributes(twisty, `section-button-${this.open ? "collapse" : "expand"}`, { section: this._paneName || "" });
}
}

View file

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

View file

@ -24,39 +24,26 @@
*/
{
const { ItemPaneContainerBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneContainerBase.mjs",
{ global: "current" }
);
let lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
mergeItems: "chrome://zotero/content/mergeItems.mjs",
});
class DuplicatesMergePane extends ItemPaneContainerBase {
class DuplicatesMergePane extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<vbox id="zotero-duplicates-merge-controls">
<groupbox>
<button id="zotero-duplicates-merge-button" data-l10n-id="item-pane-duplicates-merge-items" />
</groupbox>
<groupbox>
<button id="zotero-duplicates-merge-button" data-l10n-id="item-pane-duplicates-merge-items" />
</groupbox>
<groupbox id="zotero-duplicates-merge-version-select">
<description>&zotero.duplicatesMerge.versionSelect;</description>
<hbox>
<richlistbox id="zotero-duplicates-merge-original-date" rows="0"/>
</hbox>
</groupbox>
<groupbox id="zotero-duplicates-merge-field-select">
<description>&zotero.duplicatesMerge.fieldSelect;</description>
</groupbox>
<groupbox id="zotero-duplicates-merge-version-select">
<description>&zotero.duplicatesMerge.versionSelect;</description>
<hbox>
<richlistbox id="zotero-duplicates-merge-original-date" rows="0"/>
</hbox>
</groupbox>
<groupbox id="zotero-duplicates-merge-field-select">
<description>&zotero.duplicatesMerge.fieldSelect;</description>
</groupbox>
<vbox id="zotero-duplicates-merge-info-box-container">
<info-box id="zotero-duplicates-merge-info-box"/>
</vbox>
<html:div id="zotero-duplicates-merge-view-item" class="zotero-view-item" tabindex="0">
<info-box id="zotero-duplicates-merge-info-box" data-pane="info"/>
<abstract-box id="zotero-duplicates-merge-abstract-box" data-pane="abstract"/>
</html:div>
`, ['chrome://zotero/locale/zotero.dtd']);
init() {
@ -65,10 +52,6 @@
this._otherItems = [];
this._ignoreFields = ['dateAdded', 'dateModified', 'accessDate'];
this._paneParent = this.querySelector('#zotero-duplicates-merge-view-item');
this._infoBox = this.querySelector('#zotero-duplicates-merge-info-box');
this._abstractBox = this.querySelector('#zotero-duplicates-merge-abstract-box');
this.querySelector("#zotero-duplicates-merge-button").addEventListener(
"command", () => this.merge());
this.querySelector("#zotero-duplicates-merge-original-date").addEventListener(
@ -125,14 +108,15 @@
// Update the UI
//
let button = this.querySelector('#zotero-duplicates-merge-button');
let versionSelect = this.querySelector('#zotero-duplicates-merge-version-select');
let fieldSelect = this.querySelector('#zotero-duplicates-merge-field-select');
let button = document.getElementById('zotero-duplicates-merge-button');
let versionSelect = document.getElementById('zotero-duplicates-merge-version-select');
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
let fieldSelect = document.getElementById('zotero-duplicates-merge-field-select');
let alternatives = oldestItem.multiDiff(otherItems, this._ignoreFields);
if (alternatives) {
// Populate richlistbox with Date Added values from all items
let dateList = this.querySelector('#zotero-duplicates-merge-original-date');
let dateList = document.getElementById('zotero-duplicates-merge-original-date');
dateList.innerHTML = '';
let numRows = 0;
@ -154,9 +138,9 @@
document.l10n.setArgs(button, { count: otherItems.length + 1 });
versionSelect.hidden = fieldSelect.hidden = !alternatives;
this._infoBox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
this._infoBox.open = true;
this._abstractBox.open = true;
itembox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
// Since the header of the collapsible section is hidden, the section has to be opened
itembox.open = true;
this.setMaster(0);
@ -164,8 +148,8 @@
}
setMaster(pos) {
this._infoBox.mode = 'fieldmerge';
this._abstractBox.mode = 'fieldmerge';
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
itembox.mode = 'fieldmerge';
this._otherItems = this._items.concat();
let item = this._otherItems.splice(pos, 1)[0];
@ -179,36 +163,26 @@
for (let i in alternatives) {
alternatives[i].unshift(itemValues[i] !== undefined ? itemValues[i] : '');
}
this._infoBox.fieldAlternatives = alternatives;
this._abstractBox.fieldAlternatives = {
abstractNote: alternatives.abstractNote
};
}
else {
this._infoBox.fieldAlternatives = {};
this._abstractBox.fieldAlternatives = {};
itembox.fieldAlternatives = alternatives;
}
this._masterItem = item;
// Share the same clone between info-box and abstract-box so that
// alternative selections from either side accumulate on a single item.
let clone = item.clone();
this._infoBox.item = clone;
this._abstractBox.item = clone;
// The clone's item.id is null which equals to _lastRenderItemID, so we need to force render
this._infoBox._forceRenderAll();
this._abstractBox._forceRenderAll();
itembox.item = item.clone();
// The item.id is null which equals to _lastRenderItemID, so we need to force render it
itembox._forceRenderAll();
}
async merge() {
// Update master item with any field alternatives chosen in the boxes
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
Zotero.CollectionTreeCache.clear();
// Update master item with any field alternatives from the item box
let json = this._masterItem.toJSON();
// Exclude certain properties that are empty in the cloned object, so we don't clobber them
const { relations: _r, collections: _c, tags: _t, ...keep } = this._infoBox.item.toJSON();
const { relations: _r, collections: _c, tags: _t, ...keep } = itembox.item.toJSON();
Object.assign(json, keep);
this._masterItem.fromJSON(json);
await lazy.mergeItems(this._masterItem, this._otherItems);
Zotero.Items.merge(this._masterItem, this._otherItems);
}
}
customElements.define("duplicates-merge-pane", DuplicatesMergePane);

View file

@ -38,7 +38,9 @@
class EditableText extends XULElementBase {
_input;
_resizeObserver;
_ignoredWindowInactiveBlur = false;
_focusMousedownEvent = false;
@ -74,30 +76,7 @@
});
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');
}
@ -158,14 +137,6 @@
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 ?? '';
}
@ -209,23 +180,6 @@
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');
}
@ -245,27 +199,6 @@
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') {
@ -278,13 +211,6 @@
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;
@ -319,7 +245,6 @@
this.removeEventListener('keydown', this._captureAutocompleteKeydown, true);
}
let oldInput = this._input;
let focused = this.focused;
let selectionStart = this._input?.selectionStart;
let selectionEnd = this._input?.selectionEnd;
@ -343,22 +268,14 @@
this._input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
}
if (oldInput) {
EditableText._resizeObserver.unobserve(oldInput);
}
// Only nowrap fields can overflow horizontally; textareas wrap
this._resizeObserver?.disconnect();
if (this.noWrap) {
EditableText._resizeObserver.observe(this._input);
this._resizeObserver = new ResizeObserver(this._handleInputResize);
this._resizeObserver.observe(this._input);
}
}
this._input.readOnly = this.readOnly;
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;
}
this._input.placeholder = this.placeholder;
if (this._input.tagName == "textarea") {
// Reset to initial state
@ -464,9 +381,6 @@
this._ignoredWindowInactiveBlur = false;
return;
}
this.cancelled = false;
this._clearValue = false;
let valueBeforeFocus = this.value;
this.dispatchEvent(new CustomEvent('focus'));
@ -486,17 +400,7 @@
}
if (!('initialValue' in this._input.dataset)) {
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("");
}
this._input.dataset.initialValue = this._input.value;
}
};
@ -506,22 +410,12 @@
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;
@ -542,14 +436,9 @@
}
}
else if (event.key === 'Escape') {
if (this.multipleValues) {
this.cancelled = true;
}
else {
let initialValue = this._input.dataset.initialValue ?? '';
this.setAttribute('value', initialValue);
this._input.value = initialValue;
}
let initialValue = this._input.dataset.initialValue ?? '';
this.setAttribute('value', initialValue);
this._input.value = initialValue;
this._input.blur();
}
};
@ -608,10 +497,10 @@
}
};
_isOverflowing() {
_handleInputResize = () => {
// Very small floating-point-error allowance
const EPSILON = 0.001;
return (
this.classList.toggle('overflowing',
// 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
@ -624,7 +513,7 @@
&& this._getContentWidth() > this._input.getBoundingClientRect().width + EPSILON
)
);
}
};
focus(options) {
// If the window isn't active, the focus event won't fire yet,

View file

@ -1,389 +0,0 @@
/*
***** 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 *****
*/
{
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(`
<vbox>
<groupbox id="file-rename-settings-section-main">
<checkbox id="auto-rename-files"
data-l10n-id="file-renaming-auto-rename-files"
native="true"
/>
<vbox class="indented-pref" aria-labelledby="file-renaming-file-types" role="group">
<label id="file-renaming-file-types" data-l10n-id="file-renaming-file-types"/>
<hbox
id="file-renaming-file-types-box"
class="indented-pref"
>
<checkbox
data-l10n-id="file-renaming-file-type-pdf"
data-content-type="application/pdf"
native="true"
/>
<checkbox
data-l10n-id="file-renaming-file-type-epub"
data-content-type="application/epub+zip"
native="true"
/>
<checkbox
data-l10n-id="file-renaming-file-type-image"
data-content-type="image/"
native="true"
/>
<checkbox
data-l10n-id="file-renaming-file-type-audio"
data-content-type="audio/"
native="true"
/>
<checkbox
data-l10n-id="file-renaming-file-type-video"
data-content-type="video/"
native="true"
/>
</hbox>
</vbox>
<checkbox id="rename-linked-files" class="indented-pref"
data-l10n-id="file-renaming-rename-linked"
preference="extensions.zotero.autoRenameFiles.linked"
native="true"
/>
</groupbox>
<groupbox id="file-rename-settings-section-instructions">
<label data-l10n-id="file-renaming-format-instructions" />
<separator class="thin" />
<label data-l10n-id="file-renaming-format-instructions-example"
data-l10n-args='${JSON.stringify({ example: "{{ title truncate=\"50\" }}" })}' />
<separator class="thin" />
<label data-l10n-id="file-renaming-format-instructions-more">
<label
is="zotero-text-link"
href="https://www.zotero.org/support/file_renaming"
data-l10n-name="file-renaming-format-help-link"
/>
</label>
<separator class="thin" />
</groupbox>
<groupbox id="file-rename-settings-section-template">
<html:label
for="file-renaming-format-template"
id="file-renaming-format-template-label"
>
<html:h2 data-l10n-id="file-renaming-format-template" />
</html:label>
<html:textarea
aria-labelledby="file-renaming-format-template-label"
id="file-renaming-format-template"
rows="8"
/>
<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:div>
<label
id="file-renaming-template-invalid"
class="is-hidden"
data-l10n-id="file-renaming-template-invalid"
/>
</groupbox>
</vbox>
`);
static get observedAttributes() {
return [
'auto-rename-enabled',
'file-types',
'format-template',
'rename-linked-enabled',
'rename-linked-hidden',
'readonly',
];
}
get autoRenameEnabled() {
return this.autoRenameToggleCheckbox.checked;
}
set autoRenameEnabled(val) {
this.autoRenameToggleCheckbox.checked = val;
this.updateDisabled();
}
get renameLinkedEnabled() {
return this.renameLinkedCheckbox.checked;
}
set renameLinkedEnabled(val) {
this.renameLinkedCheckbox.checked = val;
}
get enabledFileTypes() {
let enabledTypes = new Set(
(this._enabledFileTypes).split(',').filter(Boolean)
);
for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) {
if (checkbox.checked) {
enabledTypes.add(checkbox.dataset.contentType);
}
else {
enabledTypes.delete(checkbox.dataset.contentType);
}
}
return [...enabledTypes].join(',');
}
set enabledFileTypes(types) {
this._enabledFileTypes = types;
let enabledTypes = new Set(
(this._enabledFileTypes).split(',').filter(Boolean)
);
for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) {
checkbox.checked = enabledTypes.has(checkbox.dataset.contentType);
}
}
get formatTemplate() {
return this.formatTemplateTextarea.value;
}
set formatTemplate(val) {
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;
let renameLinkedEnabled = this.renameLinkedEnabled;
let formatTemplate = this.formatTemplate;
this.dispatchEvent(new CustomEvent("change", {
detail: {
autoRenameEnabled,
enabledFileTypes,
renameLinkedEnabled,
formatTemplate
},
bubbles: true,
cancelable: true
}));
};
handleTemplateInput = () => {
let formatString = this.formatTemplateTextarea.value;
// 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;
}
// 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();
};
handleTemplateBlur = () => {
let formatString = this.formatTemplateTextarea.value;
if (formatString.replace(/\s/g, '') === '') {
this.formatTemplateTextarea.value = DEFAULT_ATTACHMENT_RENAME_TEMPLATE;
this.updatePreview();
this.handleChange();
}
else {
this.updatePreview();
}
};
handleRenameToggle = () => {
this.autoRenameEnabled = this.autoRenameToggleCheckbox.checked;
this.handleChange();
};
updateDisabled = () => {
let readonly = this.getAttribute('readonly') === 'true';
for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) {
checkbox.disabled = readonly || !this.autoRenameEnabled;
}
this.autoRenameToggleCheckbox.disabled = readonly;
this.renameLinkedCheckbox.disabled = readonly || !this.autoRenameEnabled;
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 preview = Zotero.Attachments.getFileBaseNameFromItem(item, { formatString, attachmentTitle });
this.querySelector('#file-renaming-format-preview').innerText = `${preview}.${ext}`;
};
async init() {
this.sectionMain = this.querySelector('#file-rename-settings-section-main');
this.sectionInstructions = this.querySelector('#file-rename-settings-section-instructions');
this.sectionTemplate = this.querySelector('#file-rename-settings-section-template');
this.autoRenameToggleCheckbox = this.querySelector('#auto-rename-files');
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';
this.formatTemplate = this.getAttribute('format-template') ?? '';
this.renameLinkedCheckbox.hidden = this.getAttribute('rename-linked-hidden') === 'true';
this.autoRenameToggleCheckbox.addEventListener("command", this.handleRenameToggle);
this.fileTypesCheckboxes.addEventListener("command", this.handleChange);
this.renameLinkedCheckbox.addEventListener("command", this.handleChange);
this.formatTemplateTextarea.addEventListener("input", this.handleTemplateInput);
this.formatTemplateTextarea.addEventListener("blur", this.handleTemplateBlur);
this._itemsView = Zotero.getActiveZoteroPane()?.itemsView;
if (this._itemsView) {
this._itemsView.onSelect.addListener(this.updatePreview);
}
this.updatePreview();
}
disconnectedCallback() {
super.disconnectedCallback();
this._itemsView?.onSelect.removeListener(this.updatePreview);
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this.sectionMain) return;
switch (name) {
case 'auto-rename-enabled':
this.autoRenameEnabled = newValue === 'true';
break;
case 'file-types':
this.enabledFileTypes = newValue ?? '';
break;
case 'format-template':
this.formatTemplate = newValue ?? '';
this.updatePreview();
break;
case 'rename-linked-enabled':
this.renameLinkedCheckbox.checked = newValue === 'true';
break;
case 'rename-linked-hidden':
this.renameLinkedCheckbox.hidden = newValue === 'true';
break;
case 'readonly':
this.updateDisabled();
break;
}
}
getActiveItem() {
let selectedItem = Zotero.getActiveZoteroPane()?.getSelectedItems()?.[0];
if (selectedItem) {
if (selectedItem.isRegularItem() && !selectedItem.parentKey) {
return [selectedItem, DEFAULT_EXT, ''];
}
if (selectedItem.isFileAttachment() && selectedItem.parentKey) {
let ext = Zotero.Attachments.getCorrectFileExtension(selectedItem);
let parentItem = Zotero.Items.getByLibraryAndKey(selectedItem.libraryID, selectedItem.parentKey);
return [parentItem, ext ?? DEFAULT_EXT, selectedItem.getField('title')];
}
}
return null;
}
makeMockItem() {
this.mockItem = new Zotero.Item('journalArticle');
this.mockItem.libraryID = Zotero.Libraries.userLibraryID;
this.mockItem.setField('title', 'Example Title: Example Subtitle');
this.mockItem.setCreators([
{ firstName: 'Jane', lastName: 'Doe', creatorType: 'author' },
{ firstName: 'John', lastName: 'Smith', creatorType: 'author' }
]);
this.mockItem.setField('shortTitle', 'Example Title');
this.mockItem.setField('publicationTitle', 'Advances in Example Engineering');
this.mockItem.setField('volume', '9');
this.mockItem.setField('issue', '1');
this.mockItem.setField('pages', '34-55');
this.mockItem.setField('date', '2018');
this.mockItem.setField('DOI', '10.1016/1234-example');
this.mockItem.setField('ISSN', '1234-5678');
this.mockItem.setField('abstractNote', 'This is an example abstract.');
this.mockItem.setField('extra', 'This is an example Extra field.');
this.mockItem.setField('accessDate', '2020-01-01');
this.mockItem.setField('url', 'https://example.com');
this.mockItem.setField('libraryCatalog', 'Example Library Catalog');
return this.mockItem;
}
}
customElements.define('file-renaming-settings', FileRenameSettings);
}

View file

@ -41,8 +41,6 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
<panel type="arrow" align="top">
<html:div class="panel-container">
<html:div class="panel-text"></html:div>
<html:button class="dismiss-button" hidden="hidden"
data-l10n-id="general-got-it"></html:button>
</html:div>
</panel>
`);
@ -74,23 +72,11 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
if (this.getAttribute("noautohide") == 'true'
&& !this.hasAttribute('forward')) {
let dismiss = () => {
if (this._pref) {
Zotero.Prefs.set(this._pref, true);
}
let listener = () => {
this.panel.removeEventListener("click", listener);
this.panel.hidePopup();
};
let dismissButton = this.querySelector('.dismiss-button');
dismissButton.hidden = false;
dismissButton.addEventListener("click", dismiss);
this.panel.addEventListener('popupshown', () => {
dismissButton.focus();
});
this.panel.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === "Escape") {
dismiss();
}
});
this.panel.addEventListener("click", listener);
}
}
@ -98,7 +84,6 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
* @param {Object} [options]
* @param {String} [options.text] Text to use in place of firstRunGuidance.<about>
* @param {DOMElement} [options.forEl] Anchor node
* @param {Object} [options.l10nArgs] Localization arguments for text in the panel
* @param {Boolean} [options.force] Show even if already shown
*/
async show(options) {
@ -136,10 +121,10 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
if (!useLastText) {
if (!text) {
text = await document.l10n.formatValue("first-run-guidance-" + about, options.l10nArgs || {});
text = await document.l10n.formatValue("first-run-guidance-" + about);
}
text = text.split("\n");
var descriptionNode = this.querySelector('.panel-text');
var descriptionNode = document.querySelector('.panel-text');
while (descriptionNode.hasChildNodes()) {
descriptionNode.removeChild(descriptionNode.firstChild);
@ -158,49 +143,14 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
var f = () => {
if (this.hasAttribute("foregroundonly") && Services.ww.activeWindow != window) return;
// 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.documentGlobal.frameElement;
if (iframe) {
let iframeRect = iframe.getBoundingClientRect();
let forElRect = forEl.getBoundingClientRect();
let proxy = document.createElement('div');
proxy.style.position = 'fixed';
proxy.style.left = (iframeRect.left + forElRect.left) + 'px';
proxy.style.top = (iframeRect.top + forElRect.top) + 'px';
proxy.style.width = forElRect.width + 'px';
proxy.style.height = forElRect.height + 'px';
proxy.style.pointerEvents = 'none';
document.documentElement.appendChild(proxy);
forEl = proxy;
this.panel.addEventListener('popuphidden', () => {
proxy.remove();
}, { once: true });
}
}
if (this.getAttribute('noautohide') === 'true') {
this.panel.setAttribute('noautohide', 'true');
}
else {
this.panel.removeAttribute('noautohide');
}
this.panel.openPopup(forEl, position || "after_start",
x ? parseInt(x, 10) : 0, y ? parseInt(y, 10) : 0);
// On Win/Linux, the arrow appears disconnected from the panel if too close to the edge
const padding = Zotero.isMac ? 5 : 10;
const anchorOffset = getAnchorOffset(forEl, this.panel, padding);
const anchorOffset = getAnchorOffset(forEl, this.panel);
this.panel.style.setProperty('--anchor-x', `${anchorOffset}px`);
if (pref) {
if (this.getAttribute('noautohide') === 'true') {
this._pref = pref;
}
else {
Zotero.Prefs.set(pref, true);
}
Zotero.Prefs.set(pref, true);
}
};

File diff suppressed because it is too large Load diff

View file

@ -24,12 +24,32 @@
*/
{
const { ItemPaneContainerBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneContainerBase.mjs",
{ global: "current" }
);
const waitFrame = async () => {
return waitNoLongerThan(new Promise((resolve) => {
requestAnimationFrame(resolve);
}), 30);
};
const waitFrames = async (n) => {
for (let i = 0; i < n; i++) {
await waitFrame();
}
};
class ItemDetails extends ItemPaneContainerBase {
const waitDOMUpdate = async (timeout = 50) => {
return new Promise((resolve) => {
requestIdleCallback(resolve, { timeout });
});
};
const waitNoLongerThan = async (promise, ms = 1000) => {
return Promise.race([
promise,
Zotero.Promise.delay(ms)
]);
};
class ItemDetails extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<hbox id="zotero-view-item-container" class="zotero-view-item-container" flex="1">
<html:div class="zotero-view-item-main">
@ -68,18 +88,6 @@
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
*/
@ -108,20 +116,20 @@
this._tabID = tabID;
}
get collectionTreeRows() {
return this._collectionTreeRows;
get tabType() {
return this.getAttribute('tabType');
}
set tabType(tabType) {
this.setAttribute('tabType', tabType);
}
set collectionTreeRows(collectionTreeRows) {
this._collectionTreeRows = collectionTreeRows;
get collectionTreeRow() {
return this._collectionTreeRow;
}
get supportsPinning() {
return true;
}
get supportsReorder() {
return true;
set collectionTreeRow(collectionTreeRow) {
this._collectionTreeRow = collectionTreeRow;
}
get pinnedPane() {
@ -143,12 +151,47 @@
this._savePinnedPane();
}
get _minScrollHeight() {
return parseFloat(this._paneParent.style.getPropertyValue('--min-scroll-height') || 0);
}
set _minScrollHeight(val) {
this._paneParent.style.setProperty('--min-scroll-height', val + 'px');
}
/**
* Convenience getter that delegates to our <item-pane> / <context-pane>
* parent. This exists because the sidenav controls us, not the <item-pane>,
* but needs to get/set its collapsed state.
* @returns {boolean}
*/
get _collapsed() {
let parentPane = this.closest('item-pane, context-pane');
if (!parentPane) {
return false;
}
return parentPane.collapsed;
}
/**
* Convenience setter for collapsed state. See above.
* @param {boolean} val
*/
set _collapsed(val) {
let parentPane = this.closest('item-pane, context-pane');
if (!parentPane) {
return;
}
parentPane.collapsed = val;
}
get sidenav() {
return super.sidenav;
return this._sidenav;
}
set sidenav(sidenav) {
super.sidenav = sidenav;
this._sidenav = sidenav;
sidenav.container = this;
// Manually update once and further changes will be synced automatically to sidenav
this.forceUpdateSideNav();
}
@ -250,12 +293,7 @@
box.tabID = this.tabID;
box.tabType = this.tabType;
box.item = item;
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;
}
box.collectionTreeRow = this.collectionTreeRow;
// Discard hidden panes
if (box.hidden && box.discard) {
box.discard();
@ -299,9 +337,9 @@
if (!this.isPaneVisible(box.dataset.pane)) {
continue;
}
await this._waitNoLongerThan(box.asyncRender(), 500);
await waitNoLongerThan(box.asyncRender(), 500);
// Make sure the layout is updated for next isPaneVisible check
await this._waitDOMUpdate();
await waitDOMUpdate();
}
}
@ -393,6 +431,22 @@
}
};
getPane(id) {
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]`);
}
getEnabledPane(id) {
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]:not([hidden])`);
}
getPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]'));
}
getEnabledPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]:not([hidden])'));
}
getVisiblePanes() {
let panes = this.getPanes();
let visiblePanes = [];
@ -489,29 +543,111 @@
}
}
_beforeScrollToPane(_pane) {
// Suspend the intersection observer for the duration of the scroll so
// the pane we're scrolling toward (and any panes that swing through
// the viewport on the way) don't trigger lazy renders mid-flight.
this._toggleIntersectionObserver(false);
}
async scrollToPane(paneID, behavior = 'smooth') {
let panes = this.getEnabledPanes();
let paneIndex = panes.findIndex(elem => elem.dataset.pane == paneID);
let pane = panes[paneIndex];
if (!pane) return null;
async _afterScrollToPane(_pane, panes, paneIndex) {
// After the scroll settles, async-render the pane we landed on and
// any visible panes below it, then re-enable the intersection observer
let scrollPromise;
// If the itemPane is collapsed, just remember which pane needs to be scrolled to
// when itemPane is expanded.
if (this._collapsed) {
this._lastScrollPaneID = paneID;
return null;
}
// If the pane is already at the top, no need to scroll
if (Math.abs(pane.getBoundingClientRect().top - this._paneParent.getBoundingClientRect().top) < 1) {
return true;
}
// Temporarily disable intersection observer to prevent unwanted rendering
this._toggleIntersectionObserver(false);
// The pane should always be at the very top
// If there isn't enough stuff below it for it to be at the top, we add padding
// We use a ::before pseudo-element for this so that we don't need to add another level to the DOM
this._makeSpaceForPane(pane);
if (behavior == 'smooth') {
this._disableScrollHandler = true;
scrollPromise = this._waitForScroll();
scrollPromise.then(() => this._disableScrollHandler = false);
}
else {
// Wait for the next DOM update to make sure the height is updated before rendering
scrollPromise = waitDOMUpdate();
}
pane.scrollIntoView({ block: 'start', behavior });
pane.focus();
await scrollPromise;
// Check current and following panes for async render
for (let i = paneIndex; i < panes.length; i++) {
let nextPane = panes[i];
// Stop at the first pane no longer in view
// Stop if the pane is not visible anymore
if (!this.isPaneVisible(nextPane.dataset.pane)) {
break;
}
if (nextPane.asyncRender) {
await nextPane.asyncRender();
// Wait for the next DOM update to make sure the layout is updated
await this._waitDOMUpdate();
await waitDOMUpdate();
}
}
// Re-enable intersection observer
this._toggleIntersectionObserver(true);
return true;
}
_makeSpaceForPane(pane) {
let oldMinScrollHeight = this._minScrollHeight;
let newMinScrollHeight = this._getMinScrollHeightForPane(pane);
if (newMinScrollHeight > oldMinScrollHeight) {
this._minScrollHeight = newMinScrollHeight;
}
}
_getMinScrollHeightForPane(pane) {
let paneRect = pane.getBoundingClientRect();
let containerRect = this._paneParent.getBoundingClientRect();
// No offsetTop property for XUL elements
let offsetTop = paneRect.top - containerRect.top + this._paneParent.scrollTop;
return offsetTop + containerRect.height;
}
async _waitForScroll() {
let scrollPromise = Zotero.Promise.defer();
let lastScrollTop = this._paneParent.scrollTop;
const checkScrollStart = () => {
// If the scrollTop is not changed, wait for scroll to happen
if (lastScrollTop === this._paneParent.scrollTop) {
requestAnimationFrame(checkScrollStart);
}
// Wait for scroll to end
else {
requestAnimationFrame(checkScrollEnd);
}
};
const checkScrollEnd = async () => {
// Wait for 3 frames to make sure not further scrolls
await waitFrames(3);
if (lastScrollTop === this._paneParent.scrollTop) {
scrollPromise.resolve();
}
else {
lastScrollTop = this._paneParent.scrollTop;
requestAnimationFrame(checkScrollEnd);
}
};
checkScrollStart();
// Abort after 3 seconds, which should be enough
return Promise.race([
scrollPromise.promise,
Zotero.Promise.delay(3000)
]);
}
async blurOpenField() {

View file

@ -40,11 +40,8 @@
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"/>
`);
@ -55,8 +52,6 @@
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");
@ -64,14 +59,6 @@
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;
}
@ -87,12 +74,12 @@
this._data = data;
}
get collectionTreeRows() {
return this._collectionTreeRows;
get collectionTreeRow() {
return this._collectionTreeRow;
}
set collectionTreeRows(val) {
this._collectionTreeRows = val;
set collectionTreeRow(val) {
this._collectionTreeRow = val;
}
get itemsView() {
@ -113,12 +100,12 @@
}
get mode() {
return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt"][this._deck.selectedIndex];
return ["message", "item", "note", "duplicates"][this._deck.selectedIndex];
}
/**
* Set mode of item pane
* @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt"} type view type
* @param {"message" | "item" | "note" | "duplicates"} type view type
*/
set mode(type) {
this.setAttribute("view-type", type);
@ -139,26 +126,10 @@
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
else if (this.data.length === 1) {
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();
@ -170,7 +141,7 @@
renderStatus = this.renderItemPane(item);
}
}
// No items selected or multiple, but includes some irregular items
// Zero or multiple items selected
else {
renderStatus = this.renderMessage();
}
@ -179,7 +150,7 @@
notify(action, type) {
if (type == 'item' && action == 'modify') {
if (this.collectionTreeRows?.[0]?.isFeedsOrFeed()) {
if (this.collectionTreeRow && this.collectionTreeRow.isFeedsOrFeed()) {
this.updateReadLabel();
}
}
@ -204,12 +175,9 @@
return true;
}
async renderItemPane(items) {
async renderItemPane(item) {
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") {
@ -218,21 +186,20 @@
requestIdleCallback(resolve, { timeout: 50 });
});
}
this._itemDetails.editable = this.editable;
this._itemDetails.tabID = "zotero-pane";
this._itemDetails.tabType = "library";
this._itemDetails.item = items[0];
this._itemDetails.extraItems = items.slice(1);
this._itemDetails.collectionTreeRows = this.collectionTreeRows;
this._itemDetails.item = item;
this._itemDetails.collectionTreeRow = this.collectionTreeRow;
this._itemDetails.render();
if (this.hasAttribute("collapsed")) {
if (this.getAttribute("collapsed") == "true") {
return true;
}
if (items[0].isFeedItem) {
if (item.isFeedItem) {
let lastTranslationTarget = Zotero.Prefs.get('feeds.lastTranslationTarget');
if (lastTranslationTarget) {
let id = parseInt(lastTranslationTarget.substr(1));
@ -251,7 +218,7 @@
// if (!item.isTranslated) {
// item.translate();
// }
ZoteroPane.startItemReadTimeout(items[0].id);
ZoteroPane.startItemReadTimeout(item.id);
}
return true;
}
@ -262,7 +229,7 @@
let count = this.data.length;
// Display duplicates merge interface in item pane
if (this.collectionTreeRows[0].isDuplicates()) {
if (this.collectionTreeRow.isDuplicates()) {
if (!this.editable) {
if (count) {
msg = Zotero.getString('pane.item.duplicates.writeAccessRequired');
@ -277,7 +244,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.objectRowCount;
let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.rowCount;
// Initialize the merge pane with the selected items
this._duplicatesPane.setItems(this.data, displayNumItemsOnTypeError);
@ -292,7 +259,7 @@
if (count) {
let key;
// In the trash, we have to check the object type
if (this.collectionTreeRows[0].isTrash()) {
if (this.collectionTreeRow.isTrash()) {
if (this.data.every(x => x instanceof Zotero.Collection)) {
key = 'item-pane-message-collections-selected';
}
@ -312,8 +279,8 @@
msg = { l10nId: key, l10nArgs: { count } };
}
else {
let count = this.itemsView.objectRowCount;
if (this.collectionTreeRows[0].isTrash()
let count = this.itemsView.rowCount;
if (this.collectionTreeRow.isTrash()
&& this.itemsView._rows?.some(
x => x.ref instanceof Zotero.Collection || x.ref instanceof Zotero.Search
)) {
@ -331,16 +298,6 @@
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);
@ -358,7 +315,7 @@
}
// My Publications buttons
var isPublications = this.collectionTreeRows[0].isPublications();
var isPublications = this.collectionTreeRow.isPublications();
// Show in My Publications view if selected items are all notes or non-linked-file attachments
var showMyPublicationsButtons = isPublications
&& this.data.every((item) => {
@ -374,13 +331,13 @@
// Trash button
let nonDeletedItemsSelected = this.data.some(item => !item.deleted);
if (this.collectionTreeRows[0].isTrash() && !nonDeletedItemsSelected) {
if (this.collectionTreeRow.isTrash() && !nonDeletedItemsSelected) {
container.renderCustomHead(this.renderTrashHead.bind(this));
return;
}
// Feed buttons
if (this.collectionTreeRows[0].isFeedsOrFeed()) {
if (this.collectionTreeRow.isFeedsOrFeed()) {
container.renderCustomHead(this.renderFeedHead.bind(this));
this.updateReadLabel();
return;
@ -391,11 +348,6 @@
return;
}
if (this._isBatchEditEnabled && this.data.length > 1) {
container.renderCustomHead(this.renderBatchEditHead.bind(this));
return;
}
container.renderCustomHead();
}
@ -461,7 +413,7 @@
renderAnnotationsHead(data) {
let { doc, append } = data;
let button = doc.createXULElement("button");
button.disabled = !this.collectionTreeRows.every(o => o.editable);
button.disabled = !this.collectionTreeRow.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');
@ -474,28 +426,6 @@
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;
@ -605,25 +535,6 @@
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
@ -632,7 +543,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 && !this._isBatchEditEnabled)
else if (!this.data.length || this.data.length > 1
|| this.data[0] instanceof Zotero.Collection || this.data[0] instanceof Zotero.Search) {
mode = "message";
}
@ -684,7 +595,7 @@
}
handleResize() {
if (this.hasAttribute("collapsed")) {
if (this.getAttribute("collapsed")) {
this.removeAttribute("width");
this.removeAttribute("height");
}
@ -725,28 +636,18 @@
if (this.previousElementSibling.localName === "splitter") {
this.previousElementSibling.setAttribute("state", "open");
}
this._sidenav.container = this._duplicatesPane;
break;
}
case "annotations": {
this._deck.selectedIndex = 4;
break;
}
case "batch-edit-prompt": {
this._deck.selectedIndex = 5;
break;
}
}
let isViewingItem = type == "item";
let isViewingDuplicates = type == "duplicates";
if (previousViewType != "item" && isViewingItem) {
this._itemDetails.forceUpdateSideNav();
}
// Switch sidenav back to item-details when leaving duplicates mode
if (!isViewingDuplicates && this._sidenav.container !== this._itemDetails) {
this._sidenav.container = this._itemDetails;
}
this._sidenav.toggleDefaultStatus(!isViewingItem && !isViewingDuplicates);
this._itemDetails.sidenav.toggleDefaultStatus(!isViewingItem);
}
}
customElements.define("item-pane", ItemPane);

View file

@ -1,257 +0,0 @@
/*
***** 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 *****
*/
/**
* Base class for elements that host a vertical stack of [data-pane] sections
* controlled by an <item-pane-sidenav>.
*
* Subclasses must set this._paneParent (the scrollable element directly
* containing the [data-pane] children) in their init() before any of the
* inherited container methods are called.
*
* Pin support and pane reordering are opt-in -- subclasses override
* pinnedPane, changePaneOrder(), and initPaneOrder if they want them.
* scrollToPane() handles smooth scrolling and adds bottom padding when
* necessary so the target pane can sit at the very top of the container;
* subclasses with extra per-scroll bookkeeping (intersection observers, async
* render of following panes, etc.) can override the _beforeScrollToPane() and
* _afterScrollToPane() hooks.
*/
export class ItemPaneContainerBase extends XULElementBase {
_paneParent = null;
connectedCallback() {
super.connectedCallback();
this.classList.add('item-pane-container-root');
}
get sidenav() {
return this._sidenav;
}
set sidenav(sidenav) {
this._sidenav = sidenav;
sidenav.container = this;
}
get tabType() {
return this.getAttribute('tabType');
}
set tabType(val) {
this.setAttribute('tabType', val);
}
get _collapsed() {
let parentPane = this.closest('item-pane, context-pane');
return parentPane ? parentPane.collapsed : false;
}
set _collapsed(val) {
let parentPane = this.closest('item-pane, context-pane');
if (parentPane) parentPane.collapsed = val;
}
/**
* Whether this container supports user-driven pinning of a pane.
* The sidenav and section context menus honor this when deciding whether
* to expose the pin/unpin UI. Subclasses that implement `pinnedPane`
* should override this to return true.
*/
get supportsPinning() {
return false;
}
/**
* Whether this container supports user-driven pane reordering (drag in
* the sidenav, reorder menuitems in the sidenav and section context
* menus). Subclasses that implement changePaneOrder() and initPaneOrder()
* should return true.
*/
get supportsReorder() {
return false;
}
/**
* Pin support is opt-in. Default: not supported.
*/
get pinnedPane() {
return null;
}
set pinnedPane(_val) {}
getPane(id) {
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]`);
}
getEnabledPane(id) {
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]:not([hidden])`);
}
getPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]'));
}
getEnabledPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]:not([hidden])'));
}
async _waitFrame() {
return this._waitNoLongerThan(new Promise((resolve) => {
requestAnimationFrame(resolve);
}), 30);
}
async _waitFrames(n) {
for (let i = 0; i < n; i++) {
await this._waitFrame();
}
}
async _waitDOMUpdate(timeout = 50) {
return new Promise((resolve) => {
requestIdleCallback(resolve, { timeout });
});
}
async _waitNoLongerThan(promise, ms = 1000) {
return Promise.race([promise, Zotero.Promise.delay(ms)]);
}
get _minScrollHeight() {
return parseFloat(this._paneParent.style.getPropertyValue('--min-scroll-height') || 0);
}
set _minScrollHeight(val) {
this._paneParent.style.setProperty('--min-scroll-height', val + 'px');
}
_makeSpaceForPane(pane) {
let oldMinScrollHeight = this._minScrollHeight;
let newMinScrollHeight = this._getMinScrollHeightForPane(pane);
if (newMinScrollHeight > oldMinScrollHeight) {
this._minScrollHeight = newMinScrollHeight;
}
}
_getMinScrollHeightForPane(pane) {
let paneRect = pane.getBoundingClientRect();
let containerRect = this._paneParent.getBoundingClientRect();
// No offsetTop property for XUL elements
let offsetTop = paneRect.top - containerRect.top + this._paneParent.scrollTop;
return offsetTop + containerRect.height;
}
async _waitForScroll() {
let scrollPromise = Zotero.Promise.defer();
let lastScrollTop = this._paneParent.scrollTop;
const checkScrollStart = () => {
// If the scrollTop is not changed, wait for scroll to happen
if (lastScrollTop === this._paneParent.scrollTop) {
requestAnimationFrame(checkScrollStart);
}
// Wait for scroll to end
else {
requestAnimationFrame(checkScrollEnd);
}
};
const checkScrollEnd = async () => {
// Wait for 3 frames to make sure not further scrolls
await this._waitFrames(3);
if (lastScrollTop === this._paneParent.scrollTop) {
scrollPromise.resolve();
}
else {
lastScrollTop = this._paneParent.scrollTop;
requestAnimationFrame(checkScrollEnd);
}
};
checkScrollStart();
// Abort after 3 seconds, which should be enough
return Promise.race([
scrollPromise.promise,
Zotero.Promise.delay(3000)
]);
}
/**
* Called immediately before the scroll begins, after we've decided we will scroll.
*/
_beforeScrollToPane(_pane) {}
/**
* Called after the scroll has settled.
*/
async _afterScrollToPane(_pane, _panes, _paneIndex) {}
async scrollToPane(paneID, behavior = 'smooth') {
let panes = this.getEnabledPanes();
let paneIndex = panes.findIndex(elem => elem.dataset.pane == paneID);
let pane = panes[paneIndex];
if (!pane) return null;
// If the container is collapsed, just remember which pane needs to be
// scrolled to when the container is expanded.
if (this._collapsed) {
this._lastScrollPaneID = paneID;
return null;
}
// If the pane is already at the top, no need to scroll
if (Math.abs(pane.getBoundingClientRect().top - this._paneParent.getBoundingClientRect().top) < 1) {
return true;
}
this._beforeScrollToPane(pane);
// The pane should always be at the very top.
// If there isn't enough stuff below it for it to be at the top, we add
// padding via the --min-scroll-height CSS variable so we don't need
// to add another level to the DOM.
this._makeSpaceForPane(pane);
let scrollPromise;
if (behavior == 'smooth') {
this._disableScrollHandler = true;
scrollPromise = this._waitForScroll();
scrollPromise.then(() => this._disableScrollHandler = false);
}
else {
// Wait for the next DOM update to make sure the height is updated before rendering
scrollPromise = this._waitDOMUpdate();
}
pane.scrollIntoView({ block: 'start', behavior });
pane.focus();
await scrollPromise;
await this._afterScrollToPane(pane, panes, paneIndex);
return true;
}
changePaneOrder() {}
initPaneOrder() {}
}

View file

@ -26,11 +26,6 @@
"use strict";
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
const PREF_HEADER_MODE = 'itemPaneHeader';
const PREF_BIB_ENTRY_STYLE = 'itemPaneHeader.bibEntry.style';
const PREF_BIB_ENTRY_LOCALE = 'itemPaneHeader.bibEntry.locale';
@ -70,10 +65,6 @@
_editable = true;
get _renderDependencies() {
return [this._tabID, this._item?.id, this.extraItems?.length ?? 0];
}
get item() {
return this._item;
}
@ -154,8 +145,8 @@
event.preventDefault();
let menupopup = ZoteroPane.buildFieldTransformMenu({
target: this.titleField,
onTransform: (newValues) => {
this._setTransformedValue(newValues[0]);
onTransform: (newValue) => {
this._setTransformedValue(newValue);
},
});
@ -201,15 +192,9 @@
if (newValue.toLowerCase().startsWith(shortTitleVal.toLowerCase())) {
this._item.setField('shortTitle', newValue.substring(0, shortTitleVal.length));
}
await this._item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
count: 1
}
});
await this._item.saveTx();
}
async save() {
if (!this.editable) {
return;
@ -219,13 +204,7 @@
throw new Error('Item has not been added to library');
}
this._item.setField(this._titleFieldID, this.titleField.value);
await this._item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
count: 1
}
});
await this._item.saveTx();
}
this._forceRenderAll();
}
@ -247,12 +226,6 @@
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;
@ -343,7 +316,7 @@
_getCSLEngine(style, locale) {
// Create Engine if not cached (first run with this style)
if (this._cslEngineStyleID !== style.styleID || this._cslEngineLocale !== locale) {
this._cslEngine = style.getCiteProc(locale, 'html', { cache: true });
this._cslEngine = style.getCiteProc(locale, 'html');
this._cslEngineStyleID = style.styleID;
this._cslEngineLocale = locale;
this._bibEntryCache.clear();
@ -438,7 +411,7 @@
let menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('data-l10n-id', 'item-pane-header-' + headerMode);
menuitem.setAttribute('type', 'radio');
menuitem.toggleAttribute('checked', headerMode === selectedMode);
menuitem.setAttribute('checked', headerMode === selectedMode);
menuitem.addEventListener('command', () => {
Zotero.Prefs.set(PREF_HEADER_MODE, headerMode);
});

View file

@ -23,14 +23,162 @@
***** END LICENSE BLOCK *****
*/
"use strict";
class ItemPaneSectionElementBase extends XULElementBase {
get item() {
return this._item;
}
set item(item) {
this._item = item;
if (this._handleItemChange) this._handleItemChange();
}
get editable() {
return this._editable;
}
set editable(editable) {
this._editable = editable;
this.toggleAttribute('readonly', !editable);
}
get tabID() {
return this._tabID;
}
set tabID(tabID) {
this._tabID = tabID;
}
get tabType() {
return this._tabType;
}
set tabType(tabType) {
this._tabType = tabType;
this.setAttribute('tabType', tabType);
}
get collectionTreeRow() {
return this._collectionTreeRow;
}
set collectionTreeRow(collectionTreeRow) {
this._collectionTreeRow = collectionTreeRow;
}
_syncRenderPending = false;
_asyncRenderPending = false;
/** Controlled by parent element */
skipRender = false;
get open() {
return this._section?.open || false;
}
set open(val) {
if (this._section) {
this._section.open = val;
}
}
get collapsible() {
return this._section.collapsible;
}
set collapsible(val) {
this._section.collapsible = !!val;
}
connectedCallback() {
super.connectedCallback();
if (!this.render && !this.asyncRender) {
Zotero.warn("Pane section must have method render or asyncRender.");
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._section) {
this._section.removeEventListener("toggle", this._handleSectionToggle);
this._section = null;
}
this._resetRenderedFlags();
}
initCollapsibleSection() {
this._section = this.querySelector('collapsible-section');
if (this._section) {
this._section.addEventListener("toggle", this._handleSectionToggle);
}
}
_handleSectionToggle = async (event) => {
if (event.target !== this._section || !this._section.open) {
return;
}
await this._forceRenderAll();
};
get _renderDependencies() {
return [this._tabID, this._item?.id];
}
/**
* @param {"sync" | "async"} [type]
* @returns {boolean}
*/
_isAlreadyRendered(type = "sync") {
let key = `_${type}RenderDependencies`;
let pendingKey = `_${type}RenderPending`;
let itemIDKey = `_${type}RenderItemID`;
let oldDependencies = this[key];
let newDependencies = this._renderDependencies;
let isPending = this[pendingKey];
let isRendered = Zotero.Utilities.arrayEquals(oldDependencies, newDependencies);
if (this.skipRender) {
if (!isRendered) {
this[pendingKey] = true;
}
// Skip render
return true;
}
if (!isPending && isRendered) {
return true;
}
this[key] = newDependencies;
this[pendingKey] = false;
this[itemIDKey] = this.item?.id;
return false;
}
_resetRenderedFlags() {
// Clear cached flags to allow re-rendering
delete this._syncRenderDependencies;
delete this._syncRenderItemID;
delete this._asyncRenderDependencies;
delete this._asyncRenderItemID;
}
async _forceRenderAll() {
this._resetRenderedFlags();
if (this.hidden || this.skipRender) {
this._syncRenderPending = true;
this._asyncRenderPending = true;
return;
}
if (this.render) this.render();
if (this.asyncRender) await this.asyncRender();
}
}
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class ItemPaneCustomSection extends ItemPaneSectionElementBase {
_hooks = {};

View file

@ -1,178 +0,0 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2024 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 *****
*/
export class ItemPaneSectionElementBase extends XULElementBase {
get item() {
return this._item;
}
set item(item) {
this._item = item;
if (this._handleItemChange) this._handleItemChange();
}
get editable() {
return this._editable;
}
set editable(editable) {
this._editable = editable;
this.toggleAttribute('readonly', !editable);
}
get tabID() {
return this._tabID;
}
set tabID(tabID) {
this._tabID = tabID;
}
get tabType() {
return this._tabType;
}
set tabType(tabType) {
this._tabType = tabType;
this.setAttribute('tabType', tabType);
}
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRows(collectionTreeRows) {
this._collectionTreeRows = collectionTreeRows;
}
_syncRenderPending = false;
_asyncRenderPending = false;
/** Controlled by parent element */
skipRender = false;
get open() {
return this._section?.open || false;
}
set open(val) {
if (this._section) {
this._section.open = val;
}
}
get collapsible() {
return this._section.collapsible;
}
set collapsible(val) {
this._section.collapsible = !!val;
}
connectedCallback() {
super.connectedCallback();
if (!this.render && !this.asyncRender) {
Zotero.warn("Pane section must have method render or asyncRender.");
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._section) {
this._section.removeEventListener("toggle", this._handleSectionToggle);
this._section = null;
}
this._resetRenderedFlags();
}
initCollapsibleSection() {
this._section = this.querySelector('collapsible-section');
if (this._section) {
this._section.addEventListener("toggle", this._handleSectionToggle);
}
}
_handleSectionToggle = async (event) => {
if (event.target !== this._section || !this._section.open) {
return;
}
await this._forceRenderAll();
};
get _renderDependencies() {
return [this._tabID, this._item?.id];
}
/**
* @param {"sync" | "async"} [type]
* @returns {boolean}
*/
_isAlreadyRendered(type = "sync") {
let key = `_${type}RenderDependencies`;
let pendingKey = `_${type}RenderPending`;
let itemIDKey = `_${type}RenderItemID`;
let oldDependencies = this[key];
let newDependencies = this._renderDependencies;
let isPending = this[pendingKey];
let isRendered = Zotero.Utilities.arrayEquals(oldDependencies, newDependencies);
if (this.skipRender) {
if (!isRendered) {
this[pendingKey] = true;
}
// Skip render
return true;
}
if (!isPending && isRendered) {
return true;
}
this[key] = newDependencies;
this[pendingKey] = false;
this[itemIDKey] = this.item?.id;
return false;
}
_resetRenderedFlags() {
// Clear cached flags to allow re-rendering
delete this._syncRenderDependencies;
delete this._syncRenderItemID;
delete this._asyncRenderDependencies;
delete this._asyncRenderItemID;
}
async _forceRenderAll() {
this._resetRenderedFlags();
if (this.hidden || this.skipRender) {
this._syncRenderPending = true;
this._asyncRenderPending = true;
return;
}
if (this.render) this.render();
if (this.asyncRender) await this.asyncRender();
}
}

View file

@ -112,15 +112,6 @@
set container(val) {
if (this._container == val) return;
this._container = val;
// When changing containers, hide all data-pane buttons up front; they'll
// be unhidden during render() for panes present in the new container.
// Otherwise, buttons for panes from the previous container would linger.
if (val) {
for (let button of this.querySelectorAll('.btn[data-pane]')) {
if (button.dataset.pane === 'context-notes') continue;
button.parentElement.hidden = true;
}
}
this.render();
}
@ -197,10 +188,6 @@
}
isPanePinnable(id) {
// The active container can opt out of pin support entirely
if (!this.container?.supportsPinning) {
return false;
}
if (['context-notes', 'context-all-notes', 'context-item-notes'].includes(id)) {
return false;
}
@ -212,10 +199,6 @@
}
isPaneOrderable(paneID) {
// The active container can opt out of reordering entirely
if (!this.container?.supportsReorder) {
return false;
}
let orderable
// Built-in or orderable custom sections
= this._builtInPanes.includes(paneID) || Zotero.ItemPaneManager.isSectionOrderable(paneID);
@ -840,24 +823,13 @@
this._contextMenuTarget = paneID;
let isPinnable = this.isPanePinnable(paneID);
let pinHidden = !isPinnable || this.pinnedPane == paneID;
let unpinHidden = !isPinnable || this.pinnedPane != paneID;
this.querySelector('.zotero-menuitem-pin').hidden = pinHidden;
this.querySelector('.zotero-menuitem-unpin').hidden = unpinHidden;
this.querySelector('.zotero-menuitem-pin').hidden = !isPinnable || this.pinnedPane == paneID;
this.querySelector('.zotero-menuitem-unpin').hidden = !isPinnable || this.pinnedPane != paneID;
this.querySelector('.zotero-menuitem-pin-separator').hidden = !isPinnable;
let canReorder = !!this.container?.supportsReorder;
let moveUpHidden = !this.isPaneMovable(paneID, 'up');
let moveDownHidden = !this.isPaneMovable(paneID, 'down');
let resetHidden = !canReorder || !this.isOrderChanged();
this.querySelector('.zotero-menuitem-reorder-up').hidden = moveUpHidden;
this.querySelector('.zotero-menuitem-reorder-down').hidden = moveDownHidden;
this.querySelector('.zotero-menuitem-reorder-reset').hidden = resetHidden;
// Don't open an empty popup
if (pinHidden && unpinHidden && moveUpHidden && moveDownHidden && resetHidden) {
return;
}
this.querySelector('.zotero-menuitem-reorder-up').hidden = !this.isPaneMovable(paneID, 'up');
this.querySelector('.zotero-menuitem-reorder-down').hidden = !this.isPaneMovable(paneID, 'down');
this.querySelector('.zotero-menuitem-reorder-reset').hidden = !this.isOrderChanged();
this.querySelector('.context-menu')
.openPopupAtScreen(event.screenX, event.screenY, true);

View file

@ -28,11 +28,6 @@
import { getCSSIcon } from 'components/icons';
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class LibrariesCollectionsBox extends ItemPaneSectionElementBase {
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-libraries-collections" data-pane="libraries-collections" extra-buttons="add">
@ -66,7 +61,7 @@ import { getCSSIcon } from 'components/icons';
}
get _renderDependencies() {
return [...super._renderDependencies, this.collectionTreeRows?.map(o => o.id).join(',')];
return [...super._renderDependencies, this.collectionTreeRow?.id];
}
init() {
@ -148,17 +143,14 @@ import { getCSSIcon } from 'components/icons';
Zotero.getString('pane.items.removeFromOther', [obj.name])
)) {
contextItem.removeFromCollection(obj.id);
contextItem.saveTx({
undoAction: 'undo-action-remove-from-collection',
undoActionArgs: { count: 1 }
});
contextItem.saveTx();
}
});
row.append(remove);
}
let isCurrent = this.tabType === 'library'
&& this.collectionTreeRows.map(o => o.id).includes(obj.treeViewID);
&& this.collectionTreeRow?.id == obj.treeViewID;
box.classList.toggle('current', isCurrent);
// Disable clicky if this is a context row or we're already in the library/collection it points to

View file

@ -70,10 +70,6 @@
}
}
get value() {
return super.value;
}
set value(value) {
if (!this.itemCount) {
this._preconnectedValue = value;

View file

@ -25,10 +25,6 @@
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
let { countWords } = ChromeUtils.importESModule("resource://zotero/allfaz.mjs").default;
class NoteBox extends ItemPaneSectionElementBase {

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