Compare commits

..

23 commits
main ... 8.0.4

Author SHA1 Message Date
Dan Stillman
eb712a0250 Update translators 2026-03-06 16:56:12 -05:00
Dan Stillman
28d5fa3d20 Update deployment script for 8.0 branch 2026-03-06 15:36:20 -05:00
Dan Stillman
8236ed8361 Update translators 2026-03-06 15:35:07 -05:00
Dan Stillman
1e810b2a36 Disable Better BibTeX <9.0
https://forums.zotero.org/discussion/comment/508650/#Comment_508650
2026-03-06 15:34:09 -05:00
Dan Stillman
6325ec1247 Use our own blocked-plugin message that doesn't mention Mozilla 2026-03-06 15:33:56 -05:00
Dan Stillman
d3b3ec5b46 Update version 2026-03-05 23:37:30 -05:00
Adomas Venčkauskas
a6aa02e1ab Auto-recover ALL-CAPS macOS corrupted word field codes.
Closes zotero/zotero-word-for-mac-integration#38
2026-03-05 23:30:40 -05:00
Tom Najdek
fe33482ab9 Fix tag splitting for strings containing Unicode characters. Fix #5807 2026-03-05 23:30:01 -05:00
Dan Stillman
1485dc25eb HTTP.request(): Don't try to output responseText if not text response 2026-03-05 23:27:32 -05:00
Dan Stillman
10c74d33c9 WebDAV: Compute fresh Digest auth headers for each request
The previous approach of capturing and replaying the Authorization header
from an initial request worked for Basic auth but broke with Digest auth,
where the header includes the HTTP method and URI in the hash. This caused
each replayed request to be rejected with a 401, which Firefox wouldn't
retry because the request already included Authorization.

Instead, split _channelAuthorization into _basicAuthHeader and
_digestParams. For Basic auth, the header is still replayed as-is. For
Digest, the challenge parameters (realm, nonce, qop, etc.) are cached
and a fresh Authorization header is computed per request.

_getAuthorizationHeaders() now takes method and URI parameters so it can
compute the correct Digest response hash for each request.
2026-03-05 23:27:18 -05:00
Abe Jellinek
5110a15e58 Fix item type becoming 'undefined' when menu is dismissed with Escape
By adding a missing getter override. If a setter is overridden, the
corresponding getter also needs to be overridden, or it will implicitly
return undefined.

Fixes #5793
2026-03-05 23:26:57 -05:00
Dan Stillman
9c7dff4961 Upload build-{os}.json manifests to deployment server
Needed by zotero/client-downloads@4b2060361, which reads build
metadata from these files instead of hardcoding it.
2026-03-05 23:25:21 -05:00
Dan Stillman
1205349330 Fix WebDAV verification failure on OPTIONS/PROPFIND auth type mismatch
`checkServer()` was capturing the Authorization header from OPTIONS and
explicitly setting it on PROPFIND via `setRequestHeader()`. If the auth
type didn't match what the server required for PROPFIND (e.g., Basic vs.
Digest, though maybe other things too), the explicit header could
prevent Firefox from negotiating the correct auth scheme on the 401
challenge.

To fix, move `onAuthorizationHeader()` from OPTIONS to PROPFIND and
don't pass captured auth to PROPFIND, letting Firefox handle the auth
transparently.

https://forums.zotero.org/discussion/129665/webdav-error-for-zotero-8-0-3
2026-03-05 23:24:07 -05:00
Dan Stillman
6d5007a078 Fix incorrect bold text in file-renaming banner 2026-03-05 23:23:52 -05:00
Dan Stillman
24a87ad8a2 Build deployment ZIP for 8.0 branch 2026-02-07 14:13:45 -05:00
Dan Stillman
66bc7b70f3 Update locales from Transifex 2026-02-07 12:58:10 -05:00
Dan Stillman
6f60f860a0 Update note-editor submodule 2026-02-06 15:58:04 -05:00
Dan Stillman
9a1bac563e Update locales from Transifex 2026-02-06 15:47:39 -05:00
Dan Stillman
d31fafea98 Update CSL locales 2026-02-06 15:34:42 -05:00
abaevbog
5aa1c700cc Preference to hide context annotation rows (#5315)
hideContextAnnotationRows pref, accessible via View > Hide Non-Matching Annotations,
will hide non-matching context annotation rows.
Enabled by default.

Fixes zotero#5264
2026-02-06 15:34:32 -05:00
Dan Stillman
4174955345 Update version 2026-02-04 10:00:32 -05:00
Dan Stillman
2d4ef19f41 Use PROPFIND instead of OPTIONS to cache WebDAV credentials
Some WebDAV servers allow unauthenticated OPTIONS requests, so the
Authorization header capturing added in 089701eca8 wouldn't work.

PROPFIND with Depth: 0 reliably requires authentication while only
returning properties of the directory itself.

https://forums.zotero.org/discussion/comment/506993/#Comment_506993
2026-02-04 09:59:56 -05:00
Abe Jellinek
dc54d82712 Scaffold: Fix repeated reload prompts 2026-02-04 09:59:56 -05:00
913 changed files with 25600 additions and 96458 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/8.0' || 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.7.0esr"
GECKO_VERSION_LINUX="140.7.0esr"
GECKO_VERSION_WIN="140.7.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="8405765d48481431a0361704723c870b2c1c2b9e18d77794841927f4aa3bf04c"
custom_components_hash_win_arm64="c96e591b86b0f98d85b29bc7e2202d4ea1371a4321ffe76960f7ae7643bf7ff4"
custom_components_hash_win32="0e2a14ae6e8db4ed56db810e651aaf4f80b7a2e1da0b18b2d8ecddab84174d0a"
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,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

@ -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="8.0"
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

@ -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"
@ -138,13 +127,13 @@ done
"$SCRIPT_DIR/upload_builds" $CHANNEL $VERSION
# Upload file lists and build info for each platform
channel_deploy_path="$DEPLOY_PATH/manifests/$CHANNEL"
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]+')
SHORT_VERSION="${VERSION:0:3}"
DETAILS_URL="https://www.zotero.org/support/${SHORT_VERSION}_changelog"
for i in `seq 0 1 $((${#PLATFORMS}-1))`
do
@ -159,9 +148,38 @@ do
done
rsync -rv "$TEMP_DIR/version_info/" $DEPLOY_HOST:"$channel_deploy_path/$VERSION/"
rm -rf "$STAGE_DIR"/*
# 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)
architectures="mac"
;;
w)
architectures="win32 win-x64 win-arm64"
;;
l)
architectures="linux-i686 linux-x86_64 linux-arm64"
;;
esac
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
# Add version to incremental lists
echo
echo "Build $VERSION uploaded successfully."
echo "To deploy, run on the deploy server:"
echo " ./deploy $CHANNEL $VERSION $PLATFORMS"
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"/*

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,45 @@ 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
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
# Add zotero.ftl to the addons page for our own localized strings
replace_line 'href="toolkit\/about\/aboutAddons.ftl"' 'href="toolkit\/about\/aboutAddons.ftl" \/>\n <link rel="localization" href="zotero.ftl"' \
chrome/toolkit/content/mozapps/extensions/aboutaddons.html
# Use our own localized string for blocked plugin notification
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.js"
replace_line 'details-notification-hard-blocked-\$\{typeSuffix\}' 'plugins-blocked-plugin' $file
# TODO: Use our own strings, but for now fix the bundled English ones
replace_line 'Manage Your Extensions' 'Manage Your Plugins' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line '= Extensions' '= Plugins' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line 'Add-on' 'Plugin' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line 'add-ons' 'plugins' localization/en-US/toolkit/about/aboutAddons.ftl
replace_line 'Select add-on to install' 'Select plugin to install' localization/en-US/toolkit/about/aboutAddons.ftl
# Hide Recommendations tab in sidebar and recommendations in main pane
replace_line 'function isDiscoverEnabled\(\) \{' 'function isDiscoverEnabled() {return false;' chrome/toolkit/content/mozapps/extensions/aboutaddonsCommon.js
replace_line 'pref\("extensions.htmlaboutaddons.recommendations.enabled".+' 'pref("extensions.htmlaboutaddons.recommendations.enabled", false);' greprefs.js
# Hide Report option
@ -472,7 +424,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 +454,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 +469,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 +520,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 +606,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 +722,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 +738,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"
@ -159,17 +157,15 @@ for version in "$FROM" "$TO"; do
mkdir "$CACHE_DIR"
fi
# Build archive list (no Linux arm64 before Zotero 8, no Linux i686 from Zotero 11)
# Build archive list (no Linux arm64 for Zotero 7)
ARCHIVES=(
"$MAC_ARCHIVE"
"$WIN32_ARCHIVE"
"$WIN_X64_ARCHIVE"
"$WIN_ARM64_ARCHIVE"
"$LINUX_X86_ARCHIVE"
"$LINUX_X86_64_ARCHIVE"
)
if [ "$TO_MAJOR_VERSION" -lt 11 ]; then
ARCHIVES+=("$LINUX_X86_ARCHIVE")
fi
if [ "$MAJOR_VERSION" -ge 8 ]; then
ARCHIVES+=("$LINUX_ARM64_ARCHIVE")
fi
@ -239,13 +235,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 +257,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,11 +269,8 @@ 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
# Zotero 7 has no Linux arm64 build
LINUX_BUILDS_TO_UNPACK=("$LINUX_X86_ARCHIVE" "$LINUX_X86_64_ARCHIVE")
if [ "$MAJOR_VERSION" -ge 8 ]; then
LINUX_BUILDS_TO_UNPACK+=("$LINUX_ARM64_ARCHIVE")
fi
@ -301,14 +284,9 @@ for version in "$FROM" "$TO"; do
done
if [ "$MISSING" -eq 0 ]; then
# Unpack architectures in parallel
UNPACK_PIDS=""
for build in "${LINUX_BUILDS_TO_UNPACK[@]}"; do
(tar -xf "$build" && rm "$build") &
UNPACK_PIDS="$UNPACK_PIDS $!"
done
for pid in $UNPACK_PIDS; do
wait $pid
tar -xf "$build"
rm "$build"
done
INCREMENTALS_FOUND=1
else
@ -323,21 +301,12 @@ 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 () {
@ -2012,33 +2007,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

@ -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() {
@ -2020,6 +1580,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 +1609,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 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 +1648,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 +1688,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 +1698,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 +1726,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 +1737,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 +1839,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 +1864,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 +1879,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()
@ -593,7 +584,7 @@
}
if (show) {
reindexButton.removeAttribute('hidden');
reindexButton.setAttribute('hidden', false);
}
else {
reindexButton.setAttribute('hidden', true);
@ -765,13 +756,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));
@ -423,18 +418,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 +449,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 +487,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 +530,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 +625,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 +683,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">

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) {
@ -614,87 +594,6 @@
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);
}
}
@ -139,7 +125,7 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
text = await document.l10n.formatValue("first-run-guidance-" + about, options.l10nArgs || {});
}
text = text.split("\n");
var descriptionNode = this.querySelector('.panel-text');
var descriptionNode = document.querySelector('.panel-text');
while (descriptionNode.hasChildNodes()) {
descriptionNode.removeChild(descriptionNode.firstChild);
@ -158,34 +144,6 @@ 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);
@ -195,12 +153,7 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
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;
@ -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

@ -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 {

View file

@ -28,11 +28,6 @@
import { getCSSItemTypeIcon } from 'components/icons';
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class NotesBox extends ItemPaneSectionElementBase {
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-notes" data-pane="notes" extra-buttons="add">

View file

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

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