Compare commits

..

757 commits
8.0.0 ... main

Author SHA1 Message Date
Dan Stillman
1d1d32a211 Update builds to Firefox 153.1.0esr
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
This also fixes a broken reader with existing profiles due to a Cache
API DB schema version bump in 140.14.0esr that wasn't in 153.0esr.
2026-08-27 15:41:27 -04:00
Dan Stillman
c2052cfff1 Don't add items in imported collections to the import collection
Items are added to the collection created for an import as they're
saved, before the imported collection hierarchy exists, so an item only
in a subcollection ended up in the top collection as well as its own.
Remove those once the hierarchy has been created.

When everything imported belongs to a single top-level collection, that
collection is used for the import rather than being nested inside a
collection named after the file.

https://forums.zotero.org/discussion/133174/
2026-08-27 14:15:19 -04:00
Dan Stillman
61174e2158 Export selected collections as collections
Exporting a collection included only its subcollections, so an item
directly in the collection came through with no collection at all, and
an item in both the collection and a subcollection came through in only
the subcollection.

Exporting a selection of multiple collections exported a flat list of
items with no collections at all.

We now include the selected collection(s), with one exception: if a
saved search is also selected, we export a flat item list, since a
search can't be exported as a collection.
2026-08-27 14:15:19 -04:00
Dan Stillman
e29d8f151e Try to fix Safari extension breakage after app updates
Forcing a Gatekeeper assessment seems to fix the extension when it's
broken. General theory: the system does an assessment while the app is
doing an in-place update, calculates a signature mismatch between the
parent app and the appex (or within one of the bundles?), and caches
that forever, so forcing a reassessment fixes it.
2026-08-27 11:45:03 -04:00
Dan Stillman
8e4ec83abd Rename _checkClientVersion() to _handleClientVersionChange()
The function runs upgrade tasks and records the current version.
2026-08-27 11:38:47 -04:00
Dan Stillman
3b93d33b35 Fix WebDAV downloads with non-ASCII characters in the password
HTTP.download() built the Basic auth header with btoa(), which throws
on code points above 255, so every file download failed immediately
with a TypeError. Requests that go through XMLHttpRequest were
unaffected, since Necko builds the header itself, UTF-8 encoded.

https://forums.zotero.org/discussion/133454/synchronization-error-after-upgrading-to-10-0-1
2026-08-27 10:46:00 -04:00
Dan Stillman
0c9ba2d05c Fix corruption of non-ASCII characters in encrypted credentials
Mozilla's OSKeyStore.encrypt() encodes the string as UTF-8 before
encrypting, but its decrypt() returns the decrypted bytes as a binary
string without decoding them, so a WebDAV password containing non-ASCII
characters came back mojibake and authentication failed.

https://forums.zotero.org/discussion/133465/problem-login-into-webdav-server-with-10-0-1
2026-08-27 10:45:45 -04:00
Dan Stillman
b6efbe880e Pass save options through in Zotero.Collection::removeItems()
addItems() passes its options to Zotero.Item::save(), but removeItems()
dropped everything but skipEditCheck, so callers couldn't batch the
resulting notifications.
2026-08-26 15:24:57 -04:00
Dan Stillman
ff93139cce Localize the search syntax's operators and other words
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Build, Upload (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
Operators can be typed as the Advanced Search shows them, which every
locale already translates, and new keyword messages cover the join
words, "no"/"has", the units of a relative date, and the range forms.

Each range form is given as an example with its two ends filled in, so
that a locale can say it its own way and each form keeps its own words
(e.g., no "between 1970 to 2000").
2026-08-26 15:19:44 -04:00
Dan Stillman
4a250cd20f Support date and count ranges in the search syntax
"year is between 1970 and 2000", "year:1970-2000", "1970..2000", and
"1970 to 2000" all match values within the range, inclusive of both
ends. Ends can be a year, a month ("added between 2024-02 and
2024-06"), a day ("date:2020-03-01..2020-03-15"), or a count ("number
of tags between 2 and 5").
2026-08-26 15:17:55 -04:00
Mynacol
d153397151
Use versioned libc.so instead of /bin/ln on Linux (#6030)
* Fix js-ctypes-based symlinking on Linux by using `libc.so.6` instead of `libc.so` in `OS.File.unixSymlink()` and `Zotero.File.createSymlink()`
* Use that instead of `/bin/ln`, which doesn't exist on NixOS
* Replace `/bin/ln` with `Zotero.File.createSymlink()` in symlinked-database test

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-08-26 13:02:48 -04:00
Dan Stillman
5d20c692e8 Don't use FSEvents when storage isn't on a local volume
FSEvents is backed by a per-volume journal that only local volumes
have. On a network mount the stream is created and started
successfully but never delivers events, so the watcher would report
that nothing had changed for as long as it was used. Check the volume
with statfs() and fall back to scanning.
2026-08-26 10:36:54 -04:00
Dan Stillman
a1ea7037c2 Fix locally missing attachments never being downloaded
Since f21e1b2d32, a full local file scan no longer runs periodically and
on every manual sync, so locally missed attachments stayed marked for
upload and were skipped as unavailable instead of being downloaded.
"Reset File Sync History" marked every attachment for upload, including
files that had never been downloaded, so the forced download check added
in 404fc41b88 found nothing to download.

Missing files are now marked for download when the upload queue is
filled, and in at-sync-time mode they're downloaded in the same sync.
The reset marks them for download directly, and downloads are no longer
skipped just because there were no remote storage changes.

https://forums.zotero.org/discussion/133414/
2026-08-26 10:36:54 -04:00
Dan Stillman
6d8198bbfa Don't offer a nonexistent file when dragging an attachment
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Build, Upload (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
getFilePath() and getFile() return a path whether or not the file
exists, so dragging an attachment that hadn't been downloaded handed
the drop target a path that didn't exist. Missing files also went
unreported by the drag data provider.
2026-08-25 15:25:33 -04:00
Dan Stillman
56eb77b704 Fix dragging attachments to File Explorer
Firefox 140.14 in Zotero 10.0 made the drag transferable's data
principal null for chrome-initiated drags, so the file-promise stream
that File Explorer used couldn't be created and the drop failed with
"Unspecified error". On Windows the promise was just a file:// URL for
the attachment itself, so hand over the file directly instead, and
force a copy so that File Explorer doesn't move it out of storage.

https://forums.zotero.org/discussion/133399/
https://bugzilla.mozilla.org/show_bug.cgi?id=2054665
2026-08-25 15:25:33 -04:00
Dan Stillman
e69ee69218 Fix Software Update window stuck on "Applying update…"
If an update had already finished downloading when the download page was
shown, the page waited for an update-staged notification even when
staging wasn't possible -- e.g., a default Windows installation in
Program Files, which isn't writable -- so it never advanced past
"Applying update…". It now checks whether staging is actually in
progress.

Not yet tested in an updatable build
2026-08-25 11:09:20 -04:00
Adomas Venčkauskas
baa47e3bb9 Remove bibliography when last citation is deleted. Closes #3474 2026-08-25 15:55:11 +03:00
Dan Stillman
753dbf557a Fix stuck "Loading items" at startup
Some checks are pending
CI / Build, Upload (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
The collections tree selects a row as soon as it's initialized, which
raced with the items tree's initialization. If the collections tree won,
onCollectionSelected() threw on the missing items view and no items were
ever loaded.

https://forums.zotero.org/discussion/133379/
https://forums.zotero.org/discussion/133380/
2026-08-24 10:39:10 -04:00
Dan Stillman
786cdea884 Remove dead transaction rollback path
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
CI / Test () (push) Has been cancelled
CI / Test (macOS NFS) (push) Has been cancelled
CI / Test (Windows arm64) (push) Has been cancelled
CI / Test (Windows x64) (push) Has been cancelled
rollbackAllTransactions() called transactionInProgress() and
rollbackTransaction(), neither of which has existed since nested
transaction support was removed in 14d435b8d8, so it would have thrown
had either of its two callers still been reachable. Both are in code
long since replaced: Zotero.Sync.Server, which uses the synchronous
Zotero.DB.columnQuery(), and an error handler in Zotero.Sync.Storage
that nothing calls.

Drop it along with _transactionNestingLevel, _transactionRollback, and
_shutdown, which nothing reads.
2026-08-22 15:09:37 -04:00
Dan Stillman
f2a42bec15 Retry note saves that time out waiting for the database
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Any error while saving a note prompted the user to restart Zotero, even
a transaction timeout caused by a long-running operation elsewhere.
Nothing has been written when the wait times out, so retry, unless newer
note content has been handed to the editor in the meantime.

https://forums.zotero.org/discussion/133298/
2026-08-21 16:31:18 -04:00
Dan Stillman
070ae8b615 Don't hold the database to optimize the full-text index
Editing a note flagged it stale, and the background drain indexed it and
then ran an FTS5 'optimize' -- a single statement that rewrites the
content index and can hold the shared database for over a minute --
because the queue was empty again. Note saves waiting on the connection
hit the transaction timeout and told the user to restart Zotero.

Merge the index in bounded steps instead, so no statement runs long
enough to keep other queries waiting, and only after enough items have
been indexed to be worth it.

https://forums.zotero.org/discussion/133298/
2026-08-21 16:31:18 -04:00
Dan Stillman
e8055dfdf2 Open the database only once when callers arrive concurrently
_getConnectionAsync() checked for an existing connection and then awaited
several filesystem operations before assigning one, so callers arriving in
that window each opened their own. Only the last was kept, and the rest
stayed open and unreachable, holding a mozStorage thread apiece until
shutdown.
2026-08-21 16:28:36 -04:00
Dan Stillman
9e45191b22 Stop observing idle after the database is closed permanently
A permanently closed connection kept its idle observer, so it went on
being notified and attempting backups for the life of the process. This
affects plugin databases, which are closed permanently when the plugin
shuts down.
2026-08-21 16:28:35 -04:00
Dan Stillman
7243648ed7 Register the DB backup idle observer only once per connection
The observer was added on every open with no matching removal, so each
reopen left behind another registration that received its own idle
notification. On macOS, where the periodic backup closes and reopens the
connection, the registrations accumulated and multiplied the work done
on each idle.

Addresses #6027
2026-08-21 16:28:35 -04:00
Dan Stillman
85e1cbc75c Don't back up an unchanged database
currentDBTime and lastBackupTime became Date objects when the backup was
made asynchronous in 2014, so comparing them with == has tested object
identity and returned false ever since. The interval check that follows
measures from the backup file's mtime, which a copy inherits from the
database file, so an unchanged database was backed up on every idle.

Fixes #6027
2026-08-21 16:28:35 -04:00
Dan Stillman
ad98e84d24 Fix Any Field searches at a non-item result level
Any Field expands to a generic 'field' condition, which the cross-level
code treated as matching only on top-level items. At an attachment
result level it therefore matched attachments whose parent item had the
value, instead of attachments with the value themselves. Since the
condition stands in for every field, it's now treated as matching at any
level those fields live at.
2026-08-21 12:56:05 -04:00
Dan Stillman
77a3a8815e Add a query syntax to the quick search
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Zotero.SearchQuery turns a query like `by:smith after:2020 tag:"to read"
crispr` or `creator is smith and (tag is foo or bar)` into a
Zotero.Search, matching whatever text is left over using the current
search mode. Anything that doesn't look like a clause is free text, so a
DOI or a title with a colon in it is matched literally.

The search box syntax-highlights the parts of recognized conditions and
offers autocomplete for condition names, for the values of conditions
that have a fixed set of them (like item type), and for tags and
creators from the selected libraries.
2026-08-20 16:09:20 -04:00
Dan Stillman
766cb079a0 Only defer the quick search while a quotation mark is unclosed
Searching was deferred whenever the query contained a double quotation
mark, with an items-pane message to press Enter. Quoted phrases are no
longer expensive now that Everything mode uses full-text search, so
defer only while an unclosed quote means the phrase is still being
typed, and search as soon as it's closed. Enter still forces a search
mid-quote.
2026-08-20 16:09:20 -04:00
Dan Stillman
0ee3e4052e Don't prompt to save an unchanged saved search
https://forums.zotero.org/discussion/133302/zotero-10-questions-about-search
2026-08-20 15:38:13 -04:00
Dan Stillman
0746c665a9 Don't match dates without a sortable year in "is before" searches
Values without a parsable year are stored with a 0000-00-00 sort key,
and the guard meant to exclude them from comparisons checked the full
multipart value, which is always greater than '0000-00-00'.
2026-08-20 13:19:23 -04:00
Dan Stillman
73f4273e2d Make Original Date a date field
Update the global schema to 45, resolve a field's date type through its
base-field mapping in ItemFields.isDate() (to cover priorityDate), and
convert stored values of date-type fields to multipart dates on schema
upgrade.
2026-08-20 12:57:46 -04:00
Dan Stillman
04796dff22 Use date operators for all date-type item fields in searches
Only Date and Accessed were routed to the datefield condition, so other
date fields (e.g., Filing Date) offered only text operators and
couldn't be compared as dates. Text operators are still accepted on
date fields so existing saved searches keep loading and running.
2026-08-20 12:57:46 -04:00
Dan Stillman
5f978c71ca Detect Windows-on-ARM in dir_build under an emulated shell
The GitHub runner image installs the x64 build of Git for Windows, so
its bash and everything run from it execute under x64 emulation on
Windows on ARM, where uname -m reports x86_64 and the staged build got
the wrong architecture. uname -s still reports the machine, as
MINGW64_NT-10.0-26200-ARM64, so use that instead. If the image ever
installs a native arm64 Git for Windows, uname -m will be correct and
this can go.
2026-08-20 12:34:41 -04:00
Dan Stillman
c220866d9a Use a platform-appropriate path in file access error test
The hardcoded POSIX path isn't valid on Windows.
2026-08-20 12:34:41 -04:00
Dan Stillman
6ce33bcd81 Use forward slashes in ZIP entry names on Windows
The ZIP format requires forward slashes, and nsIZipWriter stores
whatever it's given, so entries for files in subdirectories were
written with backslashes.
2026-08-20 12:34:41 -04:00
Dan Stillman
f5a2985a34 Handle unparseable paths in getClosestDirectory()
On Windows, stat rejects a POSIX-style path with
NS_ERROR_FILE_UNRECOGNIZED_PATH rather than a not-found error.
2026-08-20 12:34:41 -04:00
Dan Stillman
cdb13af13b Wait for the browser process in Windows test runs
zotero.exe is the Mozilla launcher process, which spawns the browser
process and exits, so the test runner checked for the success file and
cleaned up the profile while the app was still starting, and passing
runs were reported as failures that ended at arbitrary points.
2026-08-20 12:34:41 -04:00
Dan Stillman
32640e2019 Wait for test success file to be written before quitting
The write is asynchronous, and on Windows the app quit before it
finished, making passing test runs report failure.
2026-08-20 12:34:40 -04:00
Dan Stillman
22b3daf845 Don't require rsync in build.sh
rsync isn't available on Windows, so fall back to cp for directory
copies.
2026-08-20 12:34:40 -04:00
Dan Stillman
aa49b48915 Don't use rsync and find in prepare_build
Neither is reliably available on Windows, so copy the source tree with
Python when rsync is missing and delete the CSL locale support files
with a directory walk.
2026-08-20 12:34:40 -04:00
Dan Stillman
c7cffdd26c Move prebuilt reader/note-editor files without the shell
The shell glob in the mv breaks on Windows paths, so every Windows
build silently fell back to building the submodules from source.
2026-08-20 12:34:40 -04:00
Dan Stillman
da705c36ec Add a timeout to the SMB and NFS setup steps in CI
The apt mirrors regularly hang for many minutes, which was taking out
random jobs until the job timeout.
2026-08-20 12:34:40 -04:00
Dan Stillman
b930ddb863 Add Windows test jobs for Gecko and network-filesystem changes
Boots the Windows x64 and arm64 builds, which use custom-built Firefox
components rather than stock Mozilla binaries, and runs 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, which previously required manual Windows testing.
2026-08-20 12:34:40 -04:00
Dan Stillman
847dffbf18 Update CI to Node 24
Include the version in the node_modules cache keys so that a version
change starts fresh caches.
2026-08-20 12:34:40 -04:00
Dan Stillman
96972db8c6 Don't install xvfb in Linux CI
xvfb comes with the GitHub runner image.
2026-08-20 12:34:39 -04:00
Dan Stillman
dac1ad489f Update utilities submodule
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
And add test for citing date strings wrapped in double quotes

https://forums.zotero.org/discussion/133277/
2026-08-19 15:28:34 -04:00
Dan Stillman
ed8049dffc Run network-filesystem tests only for relevant changes, and on macOS
Run the smb/nfs test shards and a new macOS NFS job only when the
changes touch the Gecko version or the network-filesystem database
handling, plus on manual and monthly scheduled runs, via a 'changes'
job feeding the test matrix. The macOS job uses the native NFS server
with a loopback mount and runs the real code path that crashed on
network data directories on macOS.
2026-08-19 11:13:15 -04:00
Dan Stillman
e1759daa00 Show in-window message in style preview instead of progress popup
Since 30b5bf4bb9, the popup opens as a dependent window of the main
window, so it appeared behind the Style Preview window and, on Windows,
brought the main window to the front.

https://forums.zotero.org/discussion/133296/
2026-08-19 10:54:56 -04:00
Dan Stillman
4e532a17a2 Skip symlink tests on filesystems without symlink support
CIFS mounts can't create real symlinks without special mount options,
so skip the symlinked-database and broken-symlink tests when a created
symlink doesn't exist or isn't visible as one.
2026-08-19 09:37:38 -04:00
Dan Stillman
ad1bec62f2 Use offline database backups on network filesystems on Linux
The online backup API writes the backup file through SQLite's default
VFS, whose locking can hang on network filesystems -- its lock upgrades
conflict with the SMB byte-range lock mapping on CIFS mounts and retry
forever -- so fall back to an offline backup, which copies the file
with the connection closed. Extends getFileSystemInfo() to Linux using
statfs f_type magic numbers.
2026-08-19 09:37:38 -04:00
Dan Stillman
18f6fa50d5 Add CI test shards with the data directory on SMB and NFS shares
Adds 'smb' and 'nfs' entries to the test matrix that run the DB and
file test suites with the test data directory on a loopback CIFS or
NFS mount, covering the network-filesystem database handling from
bba85a3939. The test harness places the data directory under TMPDIR,
so connection initialization itself runs against the share, which is
where network-filesystem failures (startup crashes and hangs) occur.
The step timeout turns a startup hang into a failure.
2026-08-19 09:37:37 -04:00
Martynas Bagdonas
db1216d3e2 Fix annotations not appearing when imported while reader is opening
Fixes https://forums.zotero.org/discussion/133270/bug-embedded-pdf-annotations-and-highlights-fail-to-render-in-zotero-reader
2026-08-19 10:06:31 +03:00
Dan Stillman
b6837a3a57 Fix doubled Index Statistics numbers after searching in preferences
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
The preferences search feature moves label value attributes into text
content for highlighting, so stats refreshes that set the value
attribute rendered the new number in front of the stale text.

https://forums.zotero.org/discussion/comment/516909/#Comment_516909
2026-08-18 17:42:34 -04:00
Dan Stillman
bba85a3939 Fix database access on network filesystems on macOS and Linux
On macOS, SQLite chooses locking methods based on the filesystem
containing the database, and network filesystems (e.g., SMB, NFS),
read-only volumes, and filesystems without byte-range locking get
methods without shared-memory support, which WAL requires. Opening a
database with an adjacent WAL file on those crashes -- Mozilla's VFS
wrapper hides the missing shared-memory methods from SQLite's WAL
support check -- so the first Zotero 10 run converted the database to
WAL and every launch after that segfaulted during connection
initialization.

On macOS, mirror SQLite's method selection and use a rollback journal
when shared memory isn't available, converting an existing WAL database
before opening it: an empty WAL by reverting the header format versions
in place, and a non-empty WAL by replaying it into a temporary copy on
local disk that replaces the database file only after passing an
integrity check. A WAL file next to an already-converted database
(e.g., from an interrupted conversion) goes through the same
conversion. Also use openNotExclusive during integrity checks and
corruption recovery, which otherwise fail on SMB shares with an I/O
error from the exclusive open lock.

This corrects 22055d92b7, which passed openNotExclusive on all
platforms for an open failure described as affecting macOS and Linux,
and expected locking_mode=EXCLUSIVE to keep the WAL index in heap
memory with no -shm file. Neither claim held up: mozStorage opens the
WAL while initializing the connection, before any pragma can run, so
the index uses shared memory unless the exclusive VFS is in use, and
the exclusive open works on Linux CIFS mounts -- where non-exclusive
access instead made SQLite's lock-upgrade sequence trip over the SMB
byte-range lock mapping, leaving startup hung and the database never
created. So pass openNotExclusive only on macOS. On Linux this restores
unix-excl, which performs all locking under a single held lock and
keeps the WAL index in heap memory; Windows has no distinct exclusive
VFS and is unaffected.

https://forums.zotero.org/discussion/133258/
2026-08-18 14:38:37 -04:00
Dan Stillman
5757396197 Fix changelog URL generation for two-digit major versions
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
The short version was derived from the first three characters of the
version string, so 10.0 produced a detailsURL of "10._changelog".
2026-08-17 12:10:35 -04:00
Dan Stillman
5a6c9245f0 Fix locale update script under rpl 2.x
rpl 2.1.0 guesses each file's character encoding instead of assuming
UTF-8, and aborts on some locale files, leaving the script's later
steps unrun. Pin the encoding, and use -exec + so a failure in one of
these passes actually stops the script.

Also drop the ⏎ replacement, which hasn't matched anything since 2013
and would now insert real newlines rather than literal \n, truncating
.properties entries.
2026-08-17 09:57:58 -04:00
Abe Jellinek
844bd87824 Update reader submodule
Some checks failed
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
2026-08-14 13:41:03 -04:00
Dan Stillman
e0f9710c46 Update utilities submodule
edtf.js is replaced with a built-in parser in date.js, so it no longer
needs to be loaded at startup.
2026-08-14 10:44:23 -04:00
Maximilian Schmidt
b8fdee865a
Fix zero local data object versions (#6016)
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
2026-08-13 11:23:05 -04:00
Dan Stillman
29a4d9d140 Skip focus traversal tests when the pane window can't be activated
The traversal waits on focus/blur events, which Gecko fires only while
the window is active, so a window that can't be activated -- another
app focused during a local run, or intermittent deactivation in CI --
timed out after 10 seconds instead of reporting that it couldn't run.
2026-08-13 10:43:51 -04:00
Dan Stillman
472a0bf856 Update utilities submodule
And add a test for citing a spelled-out BCE date, which citeproc-js
rendered as AD.
2026-08-13 10:01:09 -04:00
Tom Najdek
68bec97114
Add support for importing from encrypted Mendeley Desktop DB (#6019) 2026-08-13 09:33:41 -04:00
Dan Stillman
4a6a8220ff Fix error from header element in updater window
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
attributeChangedCallback() runs before the content has been rendered.
2026-08-12 17:18:52 -04:00
Dan Stillman
f53d4b5eca fx153: Fix update window getting stuck on "Looking for changes"
Firefox 153 removed the wizard's extra1 and extra2 buttons, so onLoad()
threw while caching their labels and never got to starting the update
check or adding the page handlers. Create the buttons ourselves instead.
2026-08-12 17:18:52 -04:00
Dan Stillman
1d6158dfd8 Remove Linux i686 build support 2026-08-12 16:36:41 -04:00
Dan Stillman
5df473cf96 Fix grep warnings about stray backslashes during MAR builds 2026-08-12 16:29:20 -04:00
Dan Stillman
e7767c5bfa Don't build Linux i686 updates for Zotero 11
i686 builds are gone as of Zotero 11, so a full build failed trying to
package a linux-i686 MAR from a staging directory that no longer exists.
Skip the architecture entirely, and don't download it for older FROM
versions when building incrementals.
2026-08-12 16:29:20 -04:00
Dan Stillman
8d01216e5c Fix flaky search tests that used common words
Tests asserted exact search results for words like "one" and "two", so
a leftover item from an earlier test whose random title contained one
of the words as a substring could match and fail the assertion. Use
distinctive random strings instead.
2026-08-12 14:24:22 -04:00
Dan Stillman
8ac1273acb Support citing EDTF dates in the Date field and Extra
EDTF dates -- ranges ("2021/2026"), uncertain/approximate dates
("2004-06~"), and BCE dates ("-0429") -- and common equivalent notations
("1995-1996", "2021-22", "~1995", "ca. 1995", "429 BCE") are now passed
to citeproc-js as CSL date ranges, circa flags, and negative years.
Previously, such dates were mangled or dropped entirely unless entered as
CSL variables in Extra.

CSL date variables in Extra get the same parsing.

Other date handling in the client doesn't understand EDTF yet: the y/m/d
indicator in the date field doesn't reflect EDTF parsing, date searches
only match a range by its start date, and BCE dates still can't be
sorted.

Addresses #637
2026-08-12 14:11:26 -04:00
Dan Stillman
399a63ade2 Fix CI failures from items-in-view count updates
The debounced count update from fe35127f50 went through itemSelected()
and fired on any notifier change, so a pending timer could bump the
itemSelected spy after a test reset it, failing "shouldn't select a
modified item" in CI. Emit rowCountChange only when the row count
actually changed, and re-render the item pane message directly.
2026-08-12 13:25:34 -04:00
Dan Stillman
d9d52de516 Check that local app/ matches commit being built before deploying
The build-and-deploy scripts run scripts and config from the local
checkout but build source files from the tip of the remote branch, so
a stale or wrong-branch checkout could silently build with the wrong
Gecko version or omni patches.
2026-08-12 12:46:30 -04:00
Dan Stillman
a9e765e37f Remove 8.0 release build script 2026-08-12 12:46:30 -04:00
Abe Jellinek
fdec96911c Update reader submodule
Some checks are pending
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Test (shard 1) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
2026-08-11 13:42:41 -06:00
Abe Jellinek
ad7159834c Reader: Add support code for popup position persistence 2026-08-11 13:42:20 -06:00
Adomas Venčkauskas
e5508e870c Actually return 400 and throw when multipart body is malformed.
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Closes #6009
2026-08-10 16:16:00 +03:00
Dan Stillman
896582ab32 Hash files incrementally in md5Async() instead of blocking main thread
Some checks are pending
CI / Build, Upload (push) Waiting to run
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
md5Async() read and hashed the entire file synchronously on the main
thread. Read the file via an input stream pump instead, with file I/O
off the main thread and hashing done a segment at a time.
2026-08-09 22:11:42 -04:00
Dan Stillman
c1cdd038a2 Select item explicitly in item-pane refresh test
The test relied on the automatic selection of a newly created item,
which can lose the race with a view switch (e.g., from another library
selected by a previous test), leaving the info pane empty.
2026-08-09 22:09:02 -04:00
Dan Stillman
fe35127f50 Update items-in-view count when rows change without a selection
Notifier events that added or removed rows (e.g., items downloaded
during a sync) didn't update the no-selection message in the item pane,
so the count went stale until the selection changed. The row provider
now emits a rowCountChange event, and the pane re-renders the count,
debounced, when nothing is selected.
2026-08-09 14:49:21 -04:00
Dan Stillman
b8cbdc8c95 Document in-memory state handling for transaction rollbacks 2026-08-09 14:33:22 -04:00
Dan Stillman
04321fc627 Restore cached user name on transaction rollback
Zotero.Users.setName() updated the in-memory cache even if the
transaction was later rolled back, so a retry (e.g., of a failed sync
download batch) would skip rewriting the users row.
2026-08-09 14:29:48 -04:00
Dan Stillman
3630b904d7 Process downloaded objects in batched transactions
Saving each downloaded object in its own transaction made large
download syncs extremely slow, since every object required a separate
disk commit. Objects are now saved in batches sized by the existing
notifier batch ramp, and a failed batch is reprocessed individually so
that an error still rolls back only that object's save. The cached
parent item is reloaded on rollback, since it would otherwise keep
collections added via newParentItemCollections in memory and the retry
would skip writing them.
2026-08-09 14:22:21 -04:00
Dan Stillman
646fbfae65 Don't treat error in commit callback as a rollback
An error thrown from a commit callback rejected executeTransaction()
even though the transaction had been committed, so callers would treat
saved data as rolled back, and rollback callbacks (e.g., the notifier
reset) ran against committed data. Commit-callback errors are now
logged instead, and any error after a successful commit skips rollback
callbacks and is marked with 'committed' on the error object.
2026-08-09 14:22:21 -04:00
Dan Stillman
46603ca4eb Discard queued commit callbacks on transaction rollback
If a transaction was rolled back, callbacks queued via
addCurrentCallback('commit') stayed queued and ran after the next
successful commit, potentially applying in-memory cache updates for
rolled-back data.
2026-08-09 14:22:20 -04:00
Dan Stillman
1d279fba5b Add executeTransactionWithForcedRollback() test helper
Runs a function within a transaction that's rolled back at the end,
with rollback callbacks guaranteed to have run by the time it returns.
Replaces try/catch blocks in existing tests that threw and caught a
marker string from executeTransaction() to force a rollback.
2026-08-09 14:22:20 -04:00
Dan Stillman
45867cea54 Update reader submodule
Some checks failed
CI / Build, Upload (push) Has been cancelled
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
2026-08-07 21:02:49 -04:00
Dan Stillman
c87328ada9 Fix leading space in creator autocomplete values
the all-creators mode (fieldmode 2) concatenated
firstname || ' ' || lastname, so a two-field creator without a first
name produced ' last', which advanced search fields inserted with the
space.
2026-08-07 11:03:57 -04:00
Dan Stillman
8f6dc583b8 Skip startup full-text index purge and optimize when unnecessary
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
purgeOrphanedContent() ran on every startup, unlike the other
data-object purges, which are gated on the purge.* prefs.
startQueueDrain() also ran an FTS5 'optimize' even when the queues were
empty.

Gate the former on purge.items and the latter on having indexed
something.
2026-08-06 15:06:40 -04:00
Dan Stillman
3af8cea1af Name annotation types by type alone in the Advanced Search
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
The menu labeled Annotation Type listed "Highlight annotation" and "Image
Annotation", from the strings the reader announces annotations with. Use
the short names, which existed for two of the six.
2026-08-05 15:02:21 -04:00
windingwind
09249bfb73
fx153: Interface should load with nsIID instead of string (#6008) 2026-08-05 14:18:32 -04:00
Dan Stillman
8fb2870f93 Restore dxcompiler.dll on Windows for WebGPU and leave WebGPU pref on
mozinference (which is currently CPU-only) is better for many tasks, but
plugins may want WebGPU for some features (e.g., chatbots), so just
follow Firefox, which currently enables it by default for Windows and
Apple Silicon macOS. Adds 5.8 MB compressed to the Windows installer.
2026-08-05 14:10:04 -04:00
Dan Stillman
9da57a9fe3 Search: Allow binding a group whose conditions match at any level
Binding is meaningful for a condition that matches at every level -- a
tag bound to an attachment means the tag is on the attachment -- but a
group carrying one lost the binding as soon as the search was
serialized, so "items with an attachment tagged foo" couldn't be built.
2026-08-05 13:14:37 -04:00
Dan Stillman
b83342d8e0 Include numeric cell values in row accessibility labels
The row label builder skipped values that weren't strings, so a numeric
cell -- a note count, or a pre-1000 year (which is parsed with
parseInt()) -- announced only its column
name.

Also switch to formatColumnName() while we're here, so row labels
resolve the column name the same way the header does.
2026-08-05 13:09:58 -04:00
Dan Stillman
2f90893e59 Fix clearing the previous sort column's persisted direction
The guard tested the newly clicked column's settings entry while the
delete targeted the previously sorted column's, so the old direction
survived when the new column had no entry, and clicking threw when the
old column had none.
2026-08-05 13:02:24 -04:00
Dan Stillman
8052b8f9e2 Fix system-handler detection for PDFs/EPUBs on Windows 11
Windows 11 now stores default-app choices for protected file types
(e.g., .pdf) in a new UserChoiceLatest registry key [1], so the manual
registry lookup returned a stale value -- typically Edge -- instead of
the user's actual choice. Resolve the handler via nsIMIMEService, which
queries the effective association from the OS shell, and return false
for Microsoft Store apps, which have no launchable executable, so that
the file is opened via ShellExecute (albeit without a page number).

https://forums.zotero.org/discussion/133026/default-pdf-reader-stuck-on-edge

[1] https://kolbi.cz/blog/2025/04/20/userchoicelatest-microsofts-new-protection-for-file-type-associations/
2026-08-05 11:56:45 -04:00
Dan Stillman
aaeb9c6c9a fx153: Don't bundle unused Firefox runtime files
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Skip crashhelper (all platforms -- the crash reporter is disabled) and
desktop-launcher and nmhproxy.exe (Windows).

Also skip dxcompiler.dll (Windows, 22 MB), which is loaded only by
WebGPU, now also disabled via pref. Nothing renders with WebGPU, and
local ML -- including any future plugin API -- should go through
Firefox's inference libraries (mozinference/onnxruntime), which are
compiled CPU-only.
2026-08-04 14:42:40 -04:00
Dan Stillman
adc5648d29 fx153: Restore pass-through clicks on toolbarbutton dropmarkers
XULButtonElement only opens a type=menu button's menu when the button
is the event's original target, which worked before because xul.css
gave .toolbarbutton-menu-dropmarker 'pointer-events: none'. That rule
was removed along with the dropmarker element, so clicks on our
recreated dropmarker did nothing.
2026-08-04 14:42:39 -04:00
Dan Stillman
300305a9fb fx153: Set menu icons via --menuitem-icon for in-content menus
The menu icon element is now an <html:img> that renders the 'image'
attribute or 'content: var(--menuitem-icon)', and 'list-style-image' is
ignored, so iconic menuitems showed broken-image placeholders on
Windows and Linux. list-style-image is kept for native macOS menus,
which still read it.
2026-08-04 14:42:39 -04:00
Dan Stillman
97de8858fe fx153: Use font glyphs for Windows titlebar buttons
Firefox 153 removed the chrome://browser/skin/window-controls/ SVGs and
now draws the caption buttons with Segoe Fluent Icons glyphs (falling
back to Segoe MDL2 Assets on Windows 10), which broke the Windows build.
Port the new browser.css button styling and stop keeping the removed
directory in build.sh.
2026-08-04 14:42:06 -04:00
Dan Stillman
770f44568c Update Windows build to Firefox 153.0esr 2026-08-04 12:55:27 -04:00
Dan Stillman
a2a419c6da Match field values without regard to case or accents for 'is'
An exact-match condition compared with SQLite's case-sensitive '=' and
skipped the normalized shadow columns, so 'publication is "review of
finance"' missed "Review of Finance" while every other kind of search
matched it.
2026-08-03 18:59:01 -04:00
Dan Stillman
0ae2e42ca2 Use proper accent color for the selected autocomplete row
In an autocomplete popup, focus remains in the text field, so the
inactive selection color was being used instead.
2026-08-03 18:59:01 -04:00
Dan Stillman
1dffe21de0 fx153: Don't embed source-repo info in custom Windows builds
Some checks failed
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
Better fix than d5aacafe3f -- we don't need source-repo info in our
builds at all, so just disable MOZ_INCLUDE_SOURCE_INFO rather than
synthesizing it.
2026-08-03 18:59:01 -04:00
Dan Stillman
d5aacafe3f fx153: Stamp the git source repo and changeset for Windows packaging
Firefox's official-build source-repo.h generator only auto-detects
Mercurial, so building from our git checkout left the file empty and
packaging failed with "no preprocessor directives found". Derive
MOZ_SOURCE_REPO and MOZ_SOURCE_CHANGESET from git in the mozconfig so
configure records them for any invocation, including a manual
./mach package.
2026-08-03 18:32:11 -04:00
Dan Stillman
ea30bbd2af Update version to 11.0 2026-08-03 14:14:05 -04:00
Dan Stillman
72115c3cb4 Set Rust default toolchain before adding the target
Some checks failed
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
app/win/build added the cross-compilation target to whatever toolchain
was default before switching to RUST_VERSION, so after a Rust version
bump the new toolchain lacked the aarch64/i686 target and the arm and
win32 builds failed at configure. Set the default first.
2026-08-03 13:06:24 -04:00
Dan Stillman
6f3e4b5499 fx153: Update Mozilla patch for Windows 2026-08-03 12:33:59 -04:00
Dan Stillman
e4ee85296c fx153: Update Rust version for Mozilla builds 2026-08-03 12:09:02 -04:00
Dan Stillman
b5ceb6fa65 fx153: Don't assign oncommand attribute strings to .oncommand
Bug 1974578 added oncommand to GlobalEventHandlers for the Invoker
Commands API, so the assignment in _initImportedNodesPostInsert() -- an
inert expando write until now -- started setting the WebIDL event
handler, and [LegacyTreatNonObjectAsNull] turns a string into null. That
wiped the handlers compiled when the pane fragment was imported, leaving
every inline oncommand handler in the preferences panes dead.

importNode() compiles the attribute handlers itself, so the loop can
just be removed.
2026-08-03 11:48:42 -04:00
Dan Stillman
35a70117b0 fx153: Restore the toolbarbutton dropmarker
Bug 1980856 removed both wantdropmarker support and the rule that gave
.toolbarbutton-menu-dropmarker its image, once Firefox's last consumer
stopped using it, so the New Item, Add Attachment, and New Note buttons
lost their chevrons, along with the ones in advanced search, the feed
settings dialog, and Scaffold.

Draw it with a background, since a <dropmarker> doesn't render
list-style-image, and use chevron-6 to match the quick search dropmarker
and the menu marker on our own buttons. The rule also moves out of the
toolbarbutton block, since a plain <button> can host one too.
2026-08-03 11:48:42 -04:00
Dan Stillman
030cd3dc5e fx153: Drop the hover background on native search clear buttons
It runs into the field's focus ring, and we don't use hover effects on
comparable buttons.
2026-08-03 11:48:42 -04:00
Dan Stillman
503bb03aec fx153: Hide the native clear button in search fields
Firefox now creates a clear button for <input type="search"> in chrome
documents regardless of layout.forms.input-type-search.enabled (bug
1655503), which doubled up with the one we draw ourselves.
2026-08-03 11:48:42 -04:00
Dan Stillman
3430ad241b fx153: Keep our own Assets.car in the Mac app
Firefox 153 ships an asset catalog with its own AppIcon, which the merge
from Firefox's Resources copied over ours, so the app got the Firefox
icon.
2026-08-03 11:48:42 -04:00
Dan Stillman
00cad2ced0 Remove 32-bit Linux build support
Mozilla stopped publishing linux-i686 builds after Firefox 144, so there's
no 153 runtime to build against.
2026-08-03 11:48:42 -04:00
Dan Stillman
2bd9342188 Don't inject a platform wizard stylesheet on macOS
There's no mac wizard.css -- the shared one from the global list is all
macOS needs, as on Linux -- so this only produced a missing-file warning
whenever a <wizard> was created.
2026-08-03 11:48:42 -04:00
Dan Stillman
d43dc2fa37 Fail the xulrunner download on an HTTP error
Without -f, curl saves the error page and the download appears to succeed,
so a missing build fails later with a confusing "Unrecognized archive
format" from tar.
2026-08-03 11:48:42 -04:00
Dan Stillman
78d21617af fx153: Hide the about:addons sidebar
The sidebar customizations all broke: the category buttons are
moz-page-nav-buttons now rather than button[name="..."], and the Settings
and Support links moved out of <sidebar-footer> into a secondary nav. The
sidebar also has a heading now that duplicates the main pane's "Manage
Your Plugins", so hide the whole column instead of the pieces.
2026-08-03 11:48:41 -04:00
Dan Stillman
7793b469c9 fx153: Hide the Help button label on macOS
-moz-mac-help-button draws the question mark itself, and the Help label is
now painted over it at the bottom of the settings panes. Firefox hides the
label the same way for the Page Info help button (bug 2009625).
2026-08-03 11:48:41 -04:00
Dan Stillman
8110ab4eea Add a newline after the Zotero prefs in the built prefs file
Babel strips the trailing newline from defaults/preferences/zotero.js, so
the platform-specific prefs appended after it shared a line with the last
Zotero pref.
2026-08-03 11:48:41 -04:00
Dan Stillman
45e38322e1 fx153: Keep backup-constants.mjs
SelectableProfile.sys.mjs now statically imports BackupService, which
statically imports chrome://browser/content/backup/backup-constants.mjs,
so startup failed once SelectableProfileService was instantiated as a
command-line handler.
2026-08-03 11:48:41 -04:00
Dan Stillman
2eba3398e9 fx153: Restore Settings in the macOS application menu
nsMenuBarX now looks for a menuitem with the id menu_settings on Ventura
and later, falling back to menu_preferences only on older versions, so
declare both the way Firefox does.
2026-08-03 11:48:41 -04:00
Dan Stillman
01b7d0667c fx153: Move to nsIPKCS11Token for the NSS key database
Bug 2037682 removed nsIPK11TokenDB in favor of getting the internal key
token directly from @mozilla.org/security/internalkeytoken;1, and bug
2043434 replaced initPassword()/needsUserInit with changePassword().
2026-08-03 11:48:41 -04:00
Dan Stillman
a32dd2b959 fx153: Don't freeze built-ins in the translation sandbox
Bug 2017957 made Cu.Sandbox freeze built-ins by default for
system-principal sandboxes, so the translation framework's assignments to
Promise silently failed and loading the framework threw on
Zotero.Promise.method.
2026-08-03 11:48:41 -04:00
Dan Stillman
6fbe5e4135 fx153: Match XUL boolean attributes in selectors without a value
Bug 2008041 made disabled, checked, hidden, collapsed, and selected
boolean attributes, so their value is empty and [disabled="true"] no
longer matches. Nothing sets any of them to "false", so matching on
presence alone is equivalent.
2026-08-03 11:48:40 -04:00
Dan Stillman
567cc2d43a fx153: Set XUL hidden/collapsed as boolean attributes
Bug 2008041's change to boolean attributes also covers hidden and
collapsed, whose UA selectors became [hidden] and [collapsed], so
setAttribute('hidden', false) now hides the element. Switch the setters
that can be passed a falsy value to toggleAttribute(), read them with
hasAttribute(), and match the [collapsed=true] selectors in our own
stylesheets to the new presence-only form.
2026-08-03 11:48:40 -04:00
Dan Stillman
f245f5c2b2 fx153: Set XUL checked/disabled as boolean attributes
Bug 2008041 made them html-style boolean attributes, so presence alone
means true and setAttribute('disabled', false) now disables the element.
Switch the sites that can be passed a falsy value to toggleAttribute(),
read them with hasAttribute(), and drop a disabled="false" from markup.
Sites that only ever pass true are left alone.
2026-08-03 11:48:40 -04:00
Dan Stillman
bcd8ad20ec fx153: Use command events for checkbox changes
Bug 2009806 moved XUL checkbox click handling into C++ and dropped the
CheckboxStateChange event, so listen for command instead. The event now
only fires on user interaction, so the tests have to click the checkboxes
rather than assign to .checked.
2026-08-03 11:48:40 -04:00
Dan Stillman
eac3838f00 fx153: Use ChromeUtils.predictRemoteTypeForURI() in HiddenBrowser
Bug 2011326 moved remote type prediction into C++ and removed
E10SUtils.predictOriginAttributes() and getRemoteTypeForURI(). The
resulting TypeError meant nothing ever loaded in the hidden browser.
2026-08-03 11:48:40 -04:00
Dan Stillman
bca6537e6d fx153: Allow eval() in the parent process
Translators are evaluated with the system principal, which worked until
now only because nsContentSecurityUtils::IsEvalAllowed() exempted any
profile with "JS hacks" present -- which for us meant
xpinstall.signatures.required being false. Bug 2038660 dropped that
exemption, so ask for eval explicitly.
2026-08-03 11:48:40 -04:00
Dan Stillman
5ea6243384 fx153: ownerGlobal -> documentGlobal
Bug 2033243 renamed the attribute and moved it from EventTarget to Node.
2026-08-03 11:48:40 -04:00
Dan Stillman
3bb3b4f2c9 fx153: Move off the removed synchronous nsILoginManager methods
findLogins() now throws, and removeLogin()/modifyLogin()/removeAllLogins()
are Async-suffixed and return promises. Without this, Zotero couldn't read
the stored API key and showed the corrupted-logins-database error on
startup.

There's no synchronous read left, so Zotero.Sync.Runner.enabled -- which is
consulted from places that can't await -- now reads a value that
hasCredentials() caches.
2026-08-03 11:48:39 -04:00
Dan Stillman
1c005c7e21 fx153: Re-enable chrome: DTD loading
Bug 2043845 put the FORCE_ALLOWED_DTD content policy type behind a pref
that's off for everything but Thunderbird, so NS_NewChannel() refused the
load with NS_ERROR_CONTENT_BLOCKED and the XML parser silently skipped the
entity. Custom elements whose markup uses DTD entities then failed to
parse with "not well-formed XML".
2026-08-03 11:48:39 -04:00
Dan Stillman
14b43c9559 fx153: Disable Firefox's baseline CSP for chrome: documents
Bug 2038660 applies "script-src chrome: resource: moz-src:" to every
chrome: document, which blocks inline <script>s and inline event
handlers, so the main window loaded but ran none of its scripts.

We should move our inline scripts and event handlers into separate files
so that we can drop the pref.
2026-08-03 11:48:39 -04:00
Dan Stillman
b7232f130f fx153: Don't remove the Pocket actor, removed from Firefox 2026-08-03 11:48:39 -04:00
Dan Stillman
c192127ab1 fx153: Take over <search-textbox>, removed from Firefox
Mozilla removed the widget and its stylesheet in bug 1968916 without a
drop-in replacement, so bring both into Zotero. The two rules that lived
in Firefox's UA sheets (text-shadow and -moz-window-dragging) move to
_search.scss, and the aria-autocomplete removal that fetch_xulrunner
used to patch in is now part of the widget itself.
2026-08-03 11:48:39 -04:00
Dan Stillman
d701c43862 fx153: Restore autocomplete popup sizing from the anchor element
Firefox now sizes autocomplete popups to their content (bug 2045828), so
add the --panel-width calculation back instead of adjusting it.
2026-08-03 11:48:39 -04:00
Dan Stillman
06848ce17f fx153: Update about:addons patches for the ES module split
aboutaddons.js and aboutaddonsCommon.js were split into
aboutaddons-utils.mjs plus per-component modules under components/, and
the addon details template moved out of aboutaddons.html into
addon-details.mjs.

The unsigned-addon warning is now suppressed through Firefox's own
isUnsignedWarningMessageDisabled(), which is otherwise gated on
MOZILLA_OFFICIAL.
2026-08-03 11:48:38 -04:00
Dan Stillman
408179ad09 fx153: Anchor the actor-removal patterns in ActorManagerParent
AboutTranslations and the PictureInPicture actors moved out of the
JSWINDOWACTORS literal into conditional assignments after it.

Anchor every pattern to the start of the line while we're here, since
'PictureInPicture: {' matched the MozTogglePictureInPicture event name in
the new file and silently deleted everything through the next '  },'.
2026-08-03 11:48:38 -04:00
Dan Stillman
242bbccda5 Update Mac and Linux builds to Firefox 153.0esr 2026-08-03 11:48:38 -04:00
Dan Stillman
fdd3434349 Update English strings for Transifex 2026-08-03 11:12:15 -04:00
Dan Stillman
c9d5aa3c8e Use general strings for file renaming prompt buttons
The invalid-template prompt had its own strings for "Go Back" and
"Reset to Default".
2026-08-03 11:08:55 -04:00
Dan Stillman
d54327a045 Fix retracted-item icon spacing in items list
Some checks failed
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
The gap after the icon came from whitespace baked into the legacy
cross.png, so it disappeared when the icon switched to the
edge-to-edge cross.svg and the title abutted the icon.
2026-08-01 10:42:29 -04:00
Mynacol
b5ea455c1f
Fix release build on x86 mac (#5926)
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
While trying to [fix](https://github.com/NixOS/nixpkgs/pull/519431) another issue in the Zotero package for nixpkgs, I discovered a new bug. When setting a non-`source` update channel, the build fails on MacOS with x86 architectures.

The build fails with:
```
source not found twice in ChannelPrefs

source

stringWithCString:encoding:
```
Which definitely means it is caused by the `strings` command [here](https://github.com/zotero/zotero/blob/main/app/mac/set-channel-prefs-channel#L29).

When manually executing the strings command on the file, I can repeat getting only one `source` result on x86 mac, while getting two on arm64 or linux. After digging around, I tried with the flag `-arch all`, and both `source` values are found on x86 mac. Full command: `strings -n 3 -arch all $binary`.
2026-07-31 13:41:33 -04:00
Adomas Venčkauskas
9c03a93e00
Fix tree multi-selection collapse/expand behaviour (#5983)
Closes #5974.

Additional fixes for broken item tree behaviour when multiple items are
selected, and changing focus with ctrl/cmd-arrow keys.

Aligned Collection Tree/Virtualized Tree collapse/expand behaviour when
multiple containers are selected, one of them is focused, and arrow key
left-right is pressed, to the behaviour in Item Tree - now all of them
are collapsed/expanded.

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-07-31 13:25:01 -04:00
Martynas Bagdonas
a7cae87be8
Improve undo/redo in the note editor (#3398)
- Fix "redo" command not enabled in the note editor
- Centralize note editor undo and redo commands
- Update note editor submodule

Co-authored-by: Tom Najdek <tom@doppnet.com>
2026-07-31 14:22:53 +02:00
Dan Stillman
4ec5ba9c27 Don't fail translation on open-access lookup error 2026-07-30 20:59:07 -04:00
Dan Stillman
7abcf23ef7 Move PMC lookup after open-access lookup
The PMC lookup in #5906 was added before the OA lookup, but the OA
lookup often returns a PMC URL of its own (including, at least now, for
the DOI given in the PR), with a direct file URL rather than a page to
scrape, so try it first and fall back to the PMCID-derived URL only when
it turns up nothing.
2026-07-30 20:56:28 -04:00
Dan Stillman
8bebaf0ead Fix open-access lookup for PMCID items during in-app translation
Since #5906, the 'oa' resolver list can start with a plain URL object
for items with a PMCID, but _getOpenAccessPDFURLs() and
saveOpenAccessAttachment() called resolvers[0](), which threw for those
items -- skipping the lookup silently in the first case and failing the
save in the second.
2026-07-30 20:55:16 -04:00
Dan Stillman
80658e928e Handle download challenge on PubMed Central
PMC now returns a proof-of-work challenge page in place of the PDF.
2026-07-30 16:49:59 -04:00
Dan Stillman
4388f6da20 Drop redundant charset extraction in blobToText()
readAsText() already reads the charset from the blob's type when no
encoding is passed, so pulling it out first had no effect. The regex
was also missing a quantifier and captured a single character, which
readAsText() discarded as an unrecognized label.
2026-07-30 15:49:43 -04:00
Dan Stillman
9ed00c843d Don't update read status when using Show File
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Show File triggered a file 'open' notification, so revealing a file
moved its item to the top of Recently Read. Use a separate 'reveal'
event, which file syncing still observes to queue a modification check.

https://forums.zotero.org/discussion/133006/
2026-07-30 13:43:41 -04:00
Dan Stillman
08880f7a0e Update citeproc.js to include Juris-M/citeproc-js#286
Ignore unknown style-level attributes instead of failing to load the
style, so that additive style-level attributes can be adopted in future
CSL versions without breaking deployed processors [1].

[1] https://discourse.citationstyles.org/t/faster-csl-specification-updates/2050
2026-07-30 09:43:01 -04:00
Tom Najdek
c39f16891f Extend the template engine and add template validation (#5989)
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
- Fix multiple potential scenarios causing a template engine crash
 - Add support for specifying string literals in the template engine
 - Validate `if/else/elseif` order and matching clause closures
 - Validate to ensure every `{{` is properly closed with a matching `}}`
 - When a template is invalid, display a warning, do not offer batch-renaming tools, do not update synced setting
 - When a template is invalid, prompt the user to fix or reset the template when

Closes #5965
2026-07-29 13:30:12 -04:00
Tom Najdek
efcfaec5d4 Move templating engine to a standalone, lazy-loaded module (#5989) 2026-07-29 13:30:03 -04:00
Dan Stillman
dc6d55a137 Remove collectionTreeRow from the plugin menu context
Menu plugins reading it acted on one arbitrary row of the selection.
Reading it now throws and names collectionTreeRows, which the context
has already provided since multi-collection selection landed.

The context now copies property descriptors rather than values, since
copying values would evaluate the throwing collectionTreeRow getter for
every menu.
2026-07-29 13:22:58 -04:00
Dan Stillman
783da0e66e Don't offer Remove from Collection if saved search is selected
Collections and saved searches can be selected together, and the
search's items don't need to be in any of the collections, which would
result in search-only items being moved to the trash.
2026-07-29 13:12:47 -04:00
Dan Stillman
1d97f6448e Replace ItemTree#collectionTreeRow with a validated view mode
The view-wide branches (trash, duplicates, feeds, Recently Read) read
the first selected row, which multi-collection selection preserved with
a getter rather than updating. setCollectionTreeRows() now derives the
kind of view the selection adds up to, throwing if the rows disagree,
and those branches test it.
2026-07-29 13:12:47 -04:00
Dan Stillman
5d1a2cba32 Remove the singular collection tree selection getters
Multi-collection selection left .collectionTreeRow and similar in place
to reduce breakage, but that would just leave plugins and other callers
potentially broken when multiple rows were selected. All singular
getters now throw and say what to use instead. getSelectedLibraryIDs()
was added to replace getSelectedLibraryID().
2026-07-29 13:12:47 -04:00
Dan Stillman
e45871fa9d Don't treat a selected saved search as a collection
Collections and saved searches can be selected together, so a
collection-item change called getDescendents() on rows that don't have
it and compared search IDs against collection IDs.
2026-07-29 12:55:41 -04:00
Dan Stillman
c92c75eb36 Mark read attachments in every selected library
Recently Read rows can span libraries, but read attachments were marked
as matches for the first row's library only, leaving the rest as grayed
context rows.
2026-07-29 12:55:40 -04:00
Dan Stillman
3da869b2e6 Keep library headers in place when the items list is re-sorted
Header and spacer rows stayed in the row list during a sort and were
ordered as if they were items, so a column sort could move them out of
position.
2026-07-29 11:33:15 -04:00
Dan Stillman
85a33e1586 Don't treat library header and spacer rows as items
Code walking the items list assumed every row was an object, so the
headers and spacers shown whenever more than one row is selected got
picked up as items: getSortedItems() passed them to export and report
generation, restoring from the trash called item methods on them, and
the item pane counted them in "N items in this view".
2026-07-29 11:33:14 -04:00
Dan Stillman
3bf74a3e46 Local API: Actually exempt file uploads from Zotero-Server-ID
The requireServerIDOnWrite flag was added to the base endpoint class but
never set on the upload receiver, so uploads sent without the header
were still rejected with 428.
2026-07-29 10:18:45 -04:00
Dan Stillman
ae98136ec8 Save uploaded objects to the sync cache on an unchanged response
The cache-existence check didn't await getCacheObject(), so the
promise was always truthy and no cache object was ever saved for
objects the server reported as unchanged. Without a cached version,
later remote changes to those objects produced avoidable conflicts.
2026-07-29 00:06:42 -04:00
Dan Stillman
2fb8d69a93 Auto-resolve lastRead conflicts without a cached version
lastRead conflicts were auto-resolved only when a cached version of
the object was available, so differing read times could still trigger
the conflict resolution window.
2026-07-28 23:56:29 -04:00
Abe Jellinek
681c48f1c2 Local API: Don't validate Zotero-Server-ID for file uploads
Some checks are pending
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
https://github.com/urschrei/pyzotero/issues/344#issuecomment-5108140508
2026-07-28 12:34:40 -06:00
Dan Stillman
caeffa9f41 Citation dialog: Hide the citation preview until the citation has items
Show the preview area and its toggle button only once there is
something to preview, instead of showing a "Preview" placeholder in an
empty dialog.
2026-07-28 12:29:59 -04:00
Dan Stillman
b66519cb4c Citation dialog: Hide the list layout divider when the list is empty
With nothing in the list, the layout's divider sat directly above the
bottom area's divider, showing a doubled line below the input field.
2026-07-28 12:18:21 -04:00
Dan Stillman
db5e7edb49 Citation dialog: Show number of items the + button will add in a tooltip
The citation dialog's + column now has a tooltip that reflects how
many items a click will add based on the current selection, e.g.,
"Add 3 Items to Citation".
2026-07-28 12:18:13 -04:00
Dan Stillman
7925b4e9ac Citation dialog: Add all selected items on + click in Library mode
Clicking the + button on a row that is part of a multi-item selection
now adds all selected items to the citation, matching the existing
list-mode behavior. Clicking + on an unselected row still adds only
that item.
2026-07-28 12:16:41 -04:00
Dan Stillman
93d66e3999 Citation dlg: Don't enter locator-typing mode after adding multiple items
With focus moving back to the input after a multi-item add, a typed
number was applied as a page locator to every just-added item. It now
starts a search instead. Single-item adds still enable the typed-locator
shortcut.
2026-07-28 12:16:11 -04:00
Dan Stillman
938c414e0c Citation dialog: Fix post-add scroll crash that left focus in the items list
_scrollItemTreeToRow() parsed the row index from the wrong rowID
segment, so it threw on every call, and adding items from the items list
never returned focus to the input as intended.
2026-07-28 12:16:01 -04:00
Dan Stillman
8526a022ec Move local API authorization prompt button labels to general strings 2026-07-28 10:04:02 -04:00
Tom Najdek
75b78b410b
Ensure emoji detection works as intended with Unicode 17 implementation (#6004) 2026-07-28 10:02:35 -04:00
Abe Jellinek
a37a9e782a Add Zotero-Server-ID, require for writes, validate when provided
Every local API response includes a stable per-database server ID so
that clients can partition cached data, especially local object
versions, by Zotero instance. Write requests must include the ID, and
when it's provided on any request it must match the current server.

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-07-28 09:57:44 -04:00
Abe Jellinek
77f2432103 Local API: Support write requests
Add support for item, collection, and search writes (POST/PUT/PATCH/
DELETE), tag deletion, full-text writes, and the three-phase file
upload flow. Writes require a local API key granted by the user via a
confirmation dialog (POST /api/local/authorize) and follow the web
API's concurrency model, with If-Unmodified-Since-Version and
per-object version preconditions checked against local versions.

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-07-28 09:57:44 -04:00
Abe Jellinek
9dd17a212e Track data object versions locally
Add a clientVersion column for items, collections, searches, and
libraries, incremented once per library per transaction on every
object save or deletion. The local API reports these versions instead
of synced versions -- in object JSON, format=versions, since=
filtering, and Last-Modified-Version -- since synced versions don't
reflect local changes and are 0 for unsynced objects. Group metadata
responses keep reporting the synced group version, which has no local
counterpart.

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-07-28 09:57:43 -04:00
Dan Stillman
12114f6a86 Fix binary file corruption in multipart/form-data parsing
_decodeMultipartData() trimmed each part body, mangling file content that
begins or ends with a whitespace byte (e.g., a PDF ending in a newline).
2026-07-28 09:57:43 -04:00
Dan Stillman
ce20a5f228 Ship legacy Safari App Extension alongside the web extension on Mac
Developer ID-signed Safari web extensions load only in Safari 18.4 and
later -- older Safari blocks them at the code-signing layer -- so macOS
11 and 12 users, whose Safari versions top out at 16.6 and 17.6, lost
the connector with the web extension conversion.

Embed the prebuilt legacy App Extension ($SAFARI_APP_EXTENSION, set by
the deploy scripts) at Contents/PlugIns/ZoteroSafariExtension.appex with
the historical bundle identifier, and move the web extension to
Contents/PlugIns/ZoteroSafariWebExtension.appex with a new
.SafariWebExtension identifier. The web extension's
SFSafariAppExtensionBundleIdentifiersToReplace key causes Safari
versions that can load it to hide the App Extension and migrate its
enabled state, while older Safari shows only the App Extension.

Also remove the removed-files entries for the App Extension's
resources, which would otherwise delete them on update.

https://forums.zotero.org/discussion/132925/
2026-07-28 07:24:27 -04:00
Dan Stillman
91d7e73d26 Strip com.apple.FinderInfo xattrs from files in Mac disk images
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
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. codesign --verify --strict rejects FinderInfo as
detritus, and it can cause Safari to fail to load the web extension.
(Reported by a user with Safari 17.6 on macOS 12. No other reports from
the beta, so maybe not universal.) The Safari App Extension apparently
wasn't affected, since it shipped with the same attributes for years.

Convert the hybrid image to a read-write image, mount it, strip the
attributes from its files, and compress from that. The volume header is
untouched, so the open-folder flag that makes Finder open the volume
window on mount is preserved.

Only fresh installs from the DMG were affected. The updater writes fresh
files without the attributes, so copies updated in place were clean.
(Notably, this is the reverse of the post-update extension breakage for
which the standard advice has always been to delete Zotero.app and
redownload. Following that advice is exactly what resulted in a broken
copy here.)

https://forums.zotero.org/discussion/132925/
2026-07-27 15:31:18 -04:00
Dan Stillman
566115dc7c Use a fixed menu padding on Windows and Linux
Some checks failed
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
The toolkit's default menuitem padding is em-based, so it balloons
with the UI font size in menulist dropdowns (e.g., the item type
menu), which inherit that font, forcing long lists to scroll. macOS
uses a fixed value on all menus and is unaffected. Match that on
Windows and Linux, slightly roomier, so every menu stays compact
regardless of font size.

https://forums.zotero.org/discussion/132797/request-for-compact-view-in-dropdown-menus
2026-07-24 13:30:36 -04:00
Dan Stillman
512a2444d8 Add timeout to HiddenBrowser::getPageData()
The queries wait for the document to be ready, so a page that never
finished loading would hang the caller -- e.g., full-text indexing --
forever.
2026-07-24 13:11:50 -04:00
Dan Stillman
9eea7d9a0c Disable JavaScript when indexing HTML files
Page scripts otherwise run during indexing and can hang it. JavaScript
was disabled here in 2a7f31813e for the same reason, but that was lost
in the fx-compat rewrite (13adfd131c).

https://forums.zotero.org/discussion/132901/
2026-07-24 13:11:50 -04:00
Dan Stillman
3a54a5ecb1 Fix allowJavaScript option in HiddenBrowser
allowJavaScript: false didn't disable scripts for a system-principal
document, such as a blob: URL created from chrome code, which runs
scripts regardless of the allowJavascript flag. Sandbox the document
instead, forcing a null principal and blocking script execution.
2026-07-24 13:11:50 -04:00
Dan Stillman
0b32e93cb1 Batch retraction refresh notifications instead of one per item
The retraction checker fired a separate 'refresh' notification for
every matched item on each check, including already-known retractions
that hadn't changed. At startup this produced a burst of events that
forced observers like the item tree to rebuild repeatedly. Batch each
operation's changes into a single notification, and notify only for
items whose retraction state actually changed.
2026-07-24 10:56:36 -04:00
Dan Stillman
a81af0c268 Don't show Extra migration progress window unnecessarily
The candidate scan counted items whose Extra content parsed as fields
but couldn't actually be migrated (e.g., fields already set on the
item), so the window could appear on every global schema update without
changing anything.

Also use a dedicated headline instead of "Upgrading database…", and show
the window only if the migration is still running after 500 ms.
2026-07-23 23:21:01 -04:00
Martynas Bagdonas
ea4b301f73 Avoid SDT test fixture updates on version bumps
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
2026-07-23 20:24:21 +03:00
Dan Stillman
4f825ad50c Don't throw getting the filename of a stored file with a corrupt path
A stored file's path is normally 'storage:<filename>', which is parsed
as a string, but some ancient libraries apparently have relative paths
('../.../foo.pdf') that fell through to PathUtils.filename() and threw
NS_ERROR_FILE_UNRECOGNIZED_PATH, breaking syncing.

Resolve stored-file leaves as strings regardless of prefix and reserve
PathUtils.filename() for linked files, whose paths are genuine absolute
paths.

https://forums.zotero.org/discussion/132861/zotero-sync-issue-id-1987012678
2026-07-23 11:12:12 -04:00
Dan Stillman
6fb30346e6 Bundle Safari web extension instead of Safari App Extension
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
The connector is now a Safari web extension, so the bundled appex is a
static wrapper stub whose placeholder resources are replaced at build
time with a connector build via $SAFARI_EXT_RESOURCES. The stub only
needs to be rebuilt when the native wrapper project changes, not for
connector releases.
2026-07-22 15:11:23 -04:00
Dan Stillman
a4b39b91f3 Exclude trashed items from tag autocomplete suggestions
https://forums.zotero.org/discussion/132875/some-tag-suggestions-appearing-capitalised
2026-07-22 11:46:52 -04:00
Dan Stillman
146520aed5 Reload quick search when changing the search mode
Changing the quick-search mode dropdown didn't re-run the current
search, since the filter was applied without a mode and the unchanged
search text made setSearch() report no change.

Regression from 5ca1fbb167

https://forums.zotero.org/discussion/132873/beta-quick-search-isnt-reloaded-when-changing-search-mode
2026-07-22 11:14:23 -04:00
Dan Stillman
cddb19bf36 Only reject and strip actual directory paths in stored-file filenames
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
ee68452b treated any backslash as a directory separator, in both the
attachmentPath setter and the userdata 128 migration. But backslashes
are technically valid on Linux/macOS and appear in real filenames (due
to LaTeX in titles, etc.), so that would wrongly throw on or truncate
them. Only a forward slash (never valid in a filename) or a Windows
absolute path (drive-letter or UNC prefix) reliably indicates a leaked
directory path, so treat just those as paths and leave bare backslashes
alone. We should make sure we're running getValidFileName() everywhere
to avoid saving those.
2026-07-21 16:02:40 -04:00
Dan Stillman
e81e72dccf Fix negated Title/Creator/Year and Any Field advanced searches
These conditions expanded into an OR-group across their underlying
fields, so a "does not contain"/"is not" operator matched almost every
item: any item missing one of the fields satisfied that field's negated
condition. Use an AND-group for negative operators, so the value must be
absent from every field.

https://forums.zotero.org/discussion/132835/
2026-07-21 14:15:45 -04:00
Dan Stillman
40974e493d Fix Cmd/Ctrl-A not selecting all libraries when feeds are visible
Selecting all libraries pulled in feed rows, which belong to a different
visibility group and so can't be shown together, collapsing the
selection back to a single row. Constrain the all-libraries scope to the
current selection's visibility group. Also preventDefault() the shortcut
so it no longer falls through to the cmd_selectAll key command, which
threw NS_ERROR_FAILURE.

https://forums.zotero.org/discussion/132843/beta-cannot-select-all-libraries
2026-07-21 14:04:00 -04:00
Dan Stillman
621c0fbb91 Fix storageLocal test broken by ee68452b0
The download-rename test used a backslash as its sample invalid
character, which now throws in the attachmentFilename setter. Use a
colon instead, which still tests download-time filtering.
2026-07-21 11:08:42 -04:00
Dan Stillman
ee68452b05 Disallow slashes in stored-file attachment paths
Someone ended up (via a plugin, presumably) with stored-file attachments
with a full path after 'storage:', which broke file syncing. Throw when
setting a stored-file path containing a slash, and strip paths from
existing filenames in a schema update step. No particular reason to
think that the file with that basename will exist in the storage dir,
but at least it will be looking for the right file and not be totally
broken.

Separately, the dataserver will clean up filenames with full paths and
block going forward.

https://forums.zotero.org/discussion/132822/reference-sychronization-error
2026-07-21 10:40:59 -04:00
Dan Stillman
5877952954 Add accented stop-words to abbreviations word list
Accented stop-words weren't matched by the existing unaccented
entries (e.g., "fur"), so automatic journal abbreviation kept and
capitalized them: "Jahrbuch für Heimatkunde" became "Jahrb. Für
Heimatkunde" instead of "Jahrb. Heimatkunde".

https://groups.google.com/g/zotero-dev/c/uP18QEKe2JU/m/AsoGWxd9AwAJ
2026-07-20 22:52:51 -04:00
Tom Najdek
ccb592ba68
Advanced Search: Use dedicated WCAG-compliant colors for a warning (#5990)
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
2026-07-20 23:37:23 +02:00
Abe Jellinek
ddcdea3795 Read Aloud: Reword highlight granularity to "Highlight current" 2026-07-20 13:43:39 -04:00
Abe Jellinek
14c6167a4c Update reader submodule 2026-07-20 10:50:56 -04:00
Abe Jellinek
5a785c4e0f Read Aloud: Support highlight granularity customization & timestamps 2026-07-20 10:50:56 -04:00
Dan Stillman
ccbeab61e2 Stop full-text index queue drains that aren't making progress
Some checks failed
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
If queued items can't be indexed and removed for any reason, the
startup and background drains would otherwise run forever. Stop after
three passes without the queues shrinking; the items are picked up
again on the next trigger.
2026-07-18 14:33:21 -04:00
Dan Stillman
59c1801a10 Fix looping re-extraction of full-text queue items with no text
If a queued attachment was missing its cache file and re-extraction
produced no text, nothing was recorded in the index state, so the
content-index queue selected the same items forever and the startup
drain never finished.

https://forums.zotero.org/discussion/132808/
2026-07-18 14:33:13 -04:00
Dan Stillman
0dc256166a Check content-index drain time budget between items
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
The budget was only checked after each 50-item chunk, so a chunk of
large documents could tie up the main thread for longer than maxTime.
2026-07-17 14:07:38 -04:00
Dan Stillman
8437de1a4f Clarify Index Statistics labels
Say "Attachments indexed"/"Partially indexed" instead of
"Indexed"/"Partial", reverse "File or full-text content not
available", and list notes last so the attachment rows are contiguous.
2026-07-17 13:58:42 -04:00
Abe Jellinek
4a7c359ae2
Add Clear All Last Read Dates… to Recently Read context menu (#5879) 2026-07-17 13:52:37 -04:00
Dan Stillman
f171e7b5d8 Clear read-only attribute on Windows if file removal fails
A stale read-only file in the temp directory caused the bundled styles
update to fail at every startup, which also broke Quick Copy and note
translators. Try to clear it automatically in
Zotero.File.removeIfExists().

https://forums.zotero.org/discussion/132781/
https://forums.zotero.org/discussion/132798/
2026-07-17 13:19:25 -04:00
Martynas Bagdonas
c233fcf5f0 Fix reopening reader tabs during queued close notifications
Fixes https://forums.zotero.org/discussion/comment/515471/#Comment_515471
2026-07-17 20:11:49 +03:00
Dan Stillman
30b5bf4bb9 Keep progress popups from floating over other apps' windows
The progress popup was opened with alwaysontop, which is system-wide,
so a long-running popup (e.g., full-text indexing) stayed on top of
other apps' windows after switching away from Zotero. Open it as a
dependent window instead, which keeps it above only the Zotero window.
2026-07-17 12:45:24 -04:00
Dan Stillman
d739fed03e Tweak wording in full-text indexing progress window 2026-07-17 12:41:30 -04:00
Dan Stillman
bcfa43b7e9 Fix routing of search terms mixing CJK and non-ASCII words
A term mixing CJK with non-ASCII words (e.g., Cyrillic plus Japanese)
was routed to the CJK index with only its CJK characters, matching
every document that contained those. Treat any non-CJK letter or digit
as making a term mixed-script, which falls back to a literal scan of
the cached text.
2026-07-17 12:41:30 -04:00
Dan Stillman
0ce289a7fe Use a word index for full-text content search
Replace the trigram FTS5 index for attachment content with a unicode61
word index, so terms match whole words with the final token as a prefix
("archive" matches "archives", but "ion" doesn't match "condition"), as
in the pre-FTS5 word index. A multi-word phrase gets adjacent-token
candidates from the index and is then verified against the cached text
of just those items, since FTS5 ignores what separates adjacent tokens;
the verification treats whitespace and hyphen runs as equivalent
(they're frequently extraction layout or styling) but requires other
punctuation to match literally. Notes keep the trigram index and CJK
matching is unchanged; the index database version is bumped so the
index is rebuilt.

Follow-up to #5979
2026-07-17 12:41:19 -04:00
Dan Stillman
228799774f Sign both per-arch JNA .jnilibs in LibreOffice plugin during Mac build
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
JNA 5.x ships per-architecture macOS native libraries instead of a
single com/sun/jna/darwin/ directory, so the notarization re-signing
step failed with "filename not matched".

This step can be removed entirely once the plugin ships a jna.jar
without the macOS natives, which are never loaded.
2026-07-16 12:54:47 -04:00
Dan Stillman
f8f83cceac Update global schema (locales) 2026-07-16 12:29:53 -04:00
Dan Stillman
10b1dbb785 Update locales from Transifex 2026-07-16 12:28:02 -04:00
Dan Stillman
994d557e92 Say "Migrating" instead of "Updating" user data tables in debug output
"Updating" appears all over debug output, so this makes schema
migrations easier to search for.
2026-07-16 11:46:47 -04:00
Dan Stillman
fb20a84ab7 Handle stale WAL files in corrupted-database recovery
SQLite replays a leftover -wal file (e.g., from a force-quit) into
whatever file next occupies the database path, so copying a backup
over zotero.sqlite produced a corrupted-database error, and the
automatic restore recreated the same mismatch and failed every time.

When corruption is detected, check if the database file is valid without
its journal files, and if so, save a verified copy and restart, swapping
the copy in at the next startup before the database is reopened. (The
copy is made before shutdown because SQLite automatically checkpoints
the WAL into the database file when the last connection closes, which
would write the stale WAL data into the file.) Otherwise, move journal
files along with the .damaged file to clear them from the main path
before restoring from the automatic backup or creating a new database.

Since a mismatched WAL can cause subtle data damage without errors, also
run a full integrity check at startup after an unclean shutdown. Skip
the explicit close-time WAL checkpoint once corruption has been flagged,
and truncate the WAL during idle maintenance to limit stale WAL data.
2026-07-16 11:43:31 -04:00
Dan Stillman
a2c7e0c00e Support saving and restoring main-window progress meter state
showZoteroPaneProgressMeter() now returns a token capturing the
previous message, meter mode and position, and owner, and
restoreZoteroPaneProgressMeter() restores that state if no other
operation has changed the display since, with nested tokens restoring
in reverse order.
2026-07-16 11:43:31 -04:00
Dan Stillman
6c5d160895 Fix citing and locale selection for locales without a CSL locale
The CSL locales repository replaced locales-sr-RS.xml with script
variants, so citing with a stored or default 'sr-RS' locale loaded no
terms and failed with an et-al error. Resolve unavailable locales to
the closest available CSL locale (sr-Cyrl-RS for sr-RS) when rendering
and in locale selectors.

https://forums.zotero.org/discussion/comment/515598/#Comment_515598
2026-07-16 11:28:21 -04:00
Dan Stillman
d3b04e7748 Date forced backups from the backup time
Backup copies preserve the database file's mtime, and the backup
interval is measured from the backup file's mtime, so a forced
pre-update backup of a long-idle database could be rotated out a day
early. Regular backups still keep the database mtime, which the
unchanged-database check relies on.
2026-07-15 22:48:08 -04:00
Dan Stillman
404fb4c3ae Fix backup before user-initiated DB repair
The backup before repairing integrity-check errors from the Advanced
pane has thrown a TypeError since backUpDatabase() started requiring an
options object in 8.0, aborting the repair. Also force the backup so a
recent automatic backup can't cause it to be skipped.
2026-07-15 22:43:24 -04:00
Dan Stillman
d78cceac71 Don't repeat forced pre-update backup after a failed schema update
If a schema update committed the userdata upgrade but kept failing in a
later step, each restart forced another rotation backup of the
now-upgraded database, deleting the pre-upgrade backup after numBackups
restarts. Record the pending target versions in the database when
making a forced backup and skip the backup while they're unchanged, so
the pre-update backup survives the normal rotation period.
2026-07-15 22:42:26 -04:00
Dan Stillman
cae3ab6160 Fix layout of main-pane progress bar with text label
E.g., "Upgrading database…"
2026-07-15 18:24:21 -04:00
Dan Stillman
73c45f8ec9 Show "Upgrading database…" based on upgrade duration
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Replace the manually maintained minorUpdateFrom flag, which was easy to
forget to update when adding a slow upgrade step and couldn't account
for database size or hardware. The message is now shown for any upgrade
still running after 500 ms.
2026-07-15 17:58:43 -04:00
Dan Stillman
ea2ee7391d Force a regular backup before all DB upgrades, and drop versioned backups
Versioned backups (zotero.sqlite.<version>.bak) were kept until the
next userdata upgrade, potentially bloating the data directory by
gigabytes, while minor upgrades made no backup at all. Now any userdata
upgrade, integrity check, or global schema update forces a rotation
backup. Since versioned backups ignored backup.numBackups, setting that
to 0 now fully disables backups, including before upgrades.
2026-07-15 17:43:29 -04:00
Dan Stillman
02fb0e92ed Rework the Index Statistics preferences pane
Show indexing progress as a bar (items in the search index out of those
that will be) that resolves to "up to date", alongside
Indexed/Partial/Notes counts and a "File or full-text content not
available" count for attachments with no local file or full-text
content. While the pane is open, trigger indexing directly so it
advances as the user watches.

Remove the Rebuild Index and Clear Index buttons. Rebuild Index marked
all content unsynced and re-uploaded it, triggering a server reindex and
re-download on other devices, and the per-item reindex paths now cover
what needed it -- raising a length limit re-extracts only the affected
items, and unindexed items are indexed automatically. Clear Index wiped
the local index and extracted-text cache files, a destructive action
that shouldn't be exposed.
2026-07-15 15:36:40 -04:00
Dan Stillman
3ec407d861 Add full-text searching of note content
Note content is indexed into fulltext.sqlite, making note searches
accent- and case-insensitive and matching the note's plain text rather
than its HTML markup. To avoid re-indexing on every auto-save, a save
flags the note for background indexing, and searches match a flagged
note from its normalized text in memory until it's indexed.

Closes #378
2026-07-15 15:36:40 -04:00
Dan Stillman
5ade25f5f5 Drop the unused savedSearchConditions required column (#5962)
The condition `required` flag was removed in #5962, but the column was
kept so older clients could still read the database. The full-text
search changes bump the userdata compatibility version, locking out
those clients, so the column can now be dropped.
2026-07-15 15:36:40 -04:00
Dan Stillman
7c2a1d127d Add full-text content search via FTS5
Index attachment content into a contentless trigram FTS5 table in a
separate, attached fulltext.sqlite, normalized so matching is accent-
and case-insensitive. For content containing CJK characters, a companion
'ascii'-tokenized table holds bigrams so 1-2 character CJK queries, which
the trigram tokenizer can't match, still work. The extracted text still
lives in the .zotero-ft-cache files, so the index is fully derived and
rebuildable.

Use the FTS index for the fulltextContent condition, falling back to the
cached-text scan for queries too short to index, and point quick
search's content matching at the FTS index in place of the now-removed
word index. (One side effect: quick search now matches attachment
content by substring rather than by word.)

Already-extracted content is migrated into the index at startup, slowing
down on active usage. A background queue then extracts not-yet-indexed
attachments gradually when Zotero is idle. Attachments with no local
file or full-text content are recorded as missing. Content downloaded
via sync is processed into the index immediately when the sync finishes,
rather than waiting for idle like it did before, so it's searchable
immediately in on-demand file-download mode.

The index DB is tied to the main DB via the local user key and rebuilt
if they don't match (e.g., after a delete-and-resync). We compact it by
running FTS5's 'optimize' command once the indexing queue drains, and we
vacuum the attached database when necessary to reclaim disk space.

Closes #2038, #2044
Addresses #1595
2026-07-15 15:36:40 -04:00
Dan Stillman
e2536ba299 Add DBConnection hooks for auxiliary attached databases
Code that attaches its own database to the main connection needs to hook
into that connection's lifecycle. Add onIdle() to run maintenance (e.g.,
vacuuming) during the main database's idle maintenance, and
addCorruptionHandler() to handle a corruption error for the attached
database (after confirming that the main database is actually corrupt,
so a corrupt attached database doesn't trigger main-database recovery).
2026-07-15 15:36:40 -04:00
Dan Stillman
ae46954167 Add DBConnection.loadExtension()
Load a bundled SQLite extension (e.g., FTS5) by name. mozStorage
disables generic extension loading but allows specific bundled ones.
Extensions are registered per connection, so a loaded extension is
remembered and re-loaded automatically after a reconnect, before
onConnect() callbacks run.
2026-07-15 15:36:40 -04:00
Dan Stillman
da88c63231 Make the collection tree filter accent-insensitive
Addresses #1300
2026-07-15 15:36:40 -04:00
Dan Stillman
984c0a7f8c Make the tag selector filter accent-insensitive
Addresses #1300
2026-07-15 15:36:40 -04:00
Dan Stillman
3bd8d641b8 Make item search accent-insensitive
Search now ignores accents, so "seance" matches "séance" and vice versa.

Text is normalized with Unicode NFKD compatibility decomposition (which
also handles typographic ligatures, superscripts, full-width forms,
etc.) plus a small map for letters NFKD leaves alone (ø, œ, æ, ß, ...)
and the fraction slash, via Z.Utilities.Internal.normalizeForSearch().
The HTML tags we support in item fields are stripped, so markup isn't
matched (#81). Typographic quotes (#29, #1876) and dashes are folded to
ASCII.

Each searchable column gets a normalized shadow column --
itemDataValues.valueNormalized, tags.nameNormalized,
creators.firstNameNormalized/lastNameNormalized, and
itemAnnotations.textNormalized/commentNormalized -- populated at write
time and matched via COALESCE(normalized, raw) LIKE. NULL is stored when
normalizing only changes case, so plain-ASCII values are only stored
once. This covers the contains/doesNotContain/beginsWith operators in
both quick search and Advanced Search.

The new columns are local-only derived data and aren't synced. Older
clients will ignore them, so this doesn't break DB compatibility.
Existing rows are backfilled after the startup sync by
Zotero.Schema.populateNormalizedSearchColumns(), which should only take
a few seconds on most databases.

Closes #29, #81, #1300, #1876
2026-07-15 15:36:40 -04:00
Dan Stillman
d0f8d438b5 Clarify naming of the full-text sync content processor
Rename the sync-delivered full-text content processor's methods and
state to "sync content" (registerSyncContentProcessor,
processSyncedContent, indexSyncedContent, getSyncedContentCacheFile,
etc.), so it reads distinctly from the local index-building queues added
for content search. No behavior change.
2026-07-15 15:36:40 -04:00
Dan Stillman
cf09a0547c Regenerate SDT test pack fixture for pdf processor version 3
The document-worker bump raised SDT_PROCESSOR_VERSIONS.pdf from 2 to 3,
so the test's "valid" pack fixture (embedding version 2) was rejected as
stale-processor during generation, failing "should generate the pack when
missing."
2026-07-15 15:01:22 -04:00
Dan Stillman
c6595d6456 Update CSL locales 2026-07-15 13:08:36 -04:00
Dan Stillman
4f45db5009 Update translators submodule 2026-07-15 12:52:54 -04:00
Dan Stillman
933e207013 Update document-worker submodule 2026-07-15 12:52:00 -04:00
Dan Stillman
0ba89211ce Fix race between lastRead update and content-type fix on file open
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
viewAttachment() fired the 'open' notification before launchFile() saved
a sniffed content type, so AttachmentReadObserver's concurrent lastRead
save could reload the item and discard the pending change, leaving the
attachment with a blank or incorrect content type.
2026-07-14 14:01:07 -04:00
Dan Stillman
bdea584a17 Update LibreOffice submodule 2026-07-14 13:39:07 -04:00
Dan Stillman
1a5e32a7b6 Update document-worker submodule 2026-07-14 11:05:43 -04:00
Dan Stillman
142f77b094 Update reader submodule (SDT) 2026-07-14 11:05:29 -04:00
Dan Stillman
6e35bd1497 Focus the pane window before the focus traversal tests
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
The #focus() Tab/Shift-Tab tests wait on focus/blur events, which fire
only while the window is active. It intermittently loses activation in
CI's Xvfb, so call win.focus() in beforeEach when the window isn't
active.
2026-07-13 17:28:00 -04:00
nexdep
2c6185d8f1
Add Attachment Storage Type search condition (#5875)
---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-07-13 15:57:37 -04:00
Dan Stillman
89880e3284 CI: Record which test drops the main window's activation
Temporary diagnostic for the intermittent shift-tab focus-test timeout.
A central afterEach in runtests.js tracks when
Services.focus.activeWindow stops being the main window; the shift-tab
test logs the last-active and first-inactive test in its timeout
message, to identify what leaves the pane inactive in CI.
2026-07-13 15:27:39 -04:00
Dan Stillman
5a8badae7b Revert "Activate window in focus traversal tests, fixing intermittent timeouts"
This reverts commit 03a610e696.

activate() foregrounds a window via the window manager, which CI's Xvfb
doesn't have, so activateZoteroPane() in beforeEach just hangs there
instead of fixing anything. Restore the temporary open-window logging to
try to catch what leaves the pane window inactive.
2026-07-13 15:15:13 -04:00
Dan Stillman
28867883a1 Don't let embedded note images match searches in the items list
An image pasted into a note is stored as a hidden attachment, so a
matching search condition (e.g., "# of Tags" is 0) would pull the
image's parent item into the results with no visible match.
2026-07-13 14:32:40 -04:00
Dan Stillman
cdb9134537 Add "# of Notes", "# of Attachments", and "# of Annotations" search conditions
Notes and attachments are counted on regular items, and annotations on an
attachment or across a regular item's attachments; other rows are excluded
rather than always matching with a count of 0. Trashed children aren't
counted.
2026-07-13 14:18:59 -04:00
Dan Stillman
7cdd74bd2b Add "# of Tags" advanced search condition
Closes #158
2026-07-13 13:38:22 -04:00
Dan Stillman
24fe50cfd5 Add "is empty"/"is not empty" advanced search operators
Available for text, date, and number fields and creators. Previously only
possible via a doesNotContain hack with an empty value.
2026-07-13 13:38:22 -04:00
Dan Stillman
c181aac7f6 Citation dialog: Fix grouping of selected and open items
The front-most tab's item appeared under Selected Items instead of Open
Documents, and the library tab's actual selection wasn't shown. Selected
Items now always reflects the item tree selection, and an item that's both
selected and open stays under Open Documents (sorted after the front-most
tab) so that suggestions specific to open documents (e.g., the current page
number, planned in #6001) can apply to it. Notes open in tabs now appear
under Open Documents when adding a note.
2026-07-13 11:41:19 -04:00
Dan Stillman
582333397d Citation dialog: Allow typed locator after Cmd/Alt-Tab away and back
https://forums.zotero.org/discussion/132673/
2026-07-12 18:40:06 -04:00
Dan Stillman
5fe4c901ed Update stale downloadUpdate() usage in update dialog
Some checks failed
CI / Test (shard 1) (push) Has been cancelled
CI / Test (shard 2) (push) Has been cancelled
CI / Test (shard 3) (push) Has been cancelled
CI / Test (shard 4) (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
downloadUpdate() is now async, takes a single argument, and returns a
result code rather than a state string, so the "failed" check could
never match.
2026-07-10 15:49:19 -04:00
Dan Stillman
8eb71a588d Fix download progress in update dialog
nsIProgressEventSink's onProgress()/onStatus() no longer take a context
argument.

Fixes #3130
2026-07-10 15:49:18 -04:00
Dan Stillman
dea2fb8491 Fix build_for_deploy dying silently on an empty incrementals file 2026-07-10 15:26:52 -04:00
Dan Stillman
6225931f93 Clear the quick search from every selected collection when prefilling
Opening the Advanced Search from the quick search cleared the search
text only on the first selected row, while the quick search had applied
it to all of them, so with a multi-collection selection the other
collections stayed filtered by the now-invisible text -- both while the
advanced search was open and after closing it.
2026-07-10 11:45:29 -04:00
Dan Stillman
540735785d Prompt before discarding saved-search edits via the Advanced Search toggle
With the saved-search editor open, Cmd/Ctrl-Shift-F replaced it with the
temporary Advanced Search pane and the search field's close button
closed it, in both cases discarding the editor's working copy with no
way to recover the edits. Move the collection-switch save prompt into a
shared helper and show it before replacing or closing the editor.
Choosing Save on a collection switch now continues the switch directly
instead of relying on the save's modify notification to re-sync the view.
2026-07-10 11:45:29 -04:00
Dan Stillman
4188ef0ebc Don't drop an annotationAuthor value while its author menu is loading
onConditionSelected() populates the Author value menu asynchronously,
and until it finished, the row's default textbox was still the visible
value control, so serializing the row (running or saving the search)
replaced the loaded author with an empty value. Serialize the stored
value while the menu is pending, and invalidate the population if a
newer condition selection takes over meanwhile.
2026-07-10 11:45:29 -04:00
Dan Stillman
ccc7d0a5ad Fix focus target when a keyboard removal prunes a group
removeRow() remembered the removed row's index within its own group,
but when the removal emptied the group and pruned it, the index was
applied to the ancestor's children, focusing an unrelated row. Track the
pruned group's slot in its parent instead.
2026-07-10 11:45:29 -04:00
Dan Stillman
3a34c6a18f Run a focused button's action on Enter in Advanced Search
The pane-level Enter handler ran the default action (save in the saved
editor, submit in the temporary pane) regardless of the event target, so
Enter with focus on the Cancel button saved and closed the editor, and
Enter on Clear ran the search. Click the focused button instead, which
the platform otherwise only does for Space.
2026-07-10 11:45:29 -04:00
Dan Stillman
a6c3ad51ef Keep a group's same-entity binding visible when it no longer qualifies
The "in the same annotation" binding menu was hidden as soon as a group
no longer had two conditions at the bound level, but the group's stored
binding was still saved and applied. After switching one of two bound
annotation conditions to another type, the group looked like a plain
"Match all of the following" group but still required a single
annotation to satisfy all of its conditions, and no control was left to
see or clear the binding.

Show the menu whenever the group is bound, drop the binding only once no
condition at its level remains, and refresh the menus before
collectGroup() in updateSearch() so the first save after such an edit
doesn't serialize stale state.
2026-07-10 11:45:28 -04:00
Dan Stillman
1e0cf49a16 Don't refresh the items list when clearing an unset advanced search
setAdvancedSearch() invalidated the row unconditionally, unlike
setSearch()/setTags(), so merely opening or closing the Advanced Search
pane (which applies a null filter) re-queried and rebuilt the items
list. A passed search is still always applied, since its conditions may
have been edited in place, and setFilter() still waits for a refresh
already in flight so that callers see the settled view.
2026-07-10 11:45:28 -04:00
Dan Stillman
cc28fad6da Roll a standalone attachment's annotations up to the attachment itself
_rollUpAnyToLevel() only followed an annotation's parent when the
attachment itself had a parent, so in a search for top-level items, a
tag on a standalone attachment's annotation matched nothing.
2026-07-10 11:45:28 -04:00
Dan Stillman
d95d8f294d Fix serialization of the result level seeded for a migrated childNote
The condition seeded by _loadConditions() used `mode: undefined`, but
toJSON() only omits the "/mode" suffix when the mode is exactly false
(what parseCondition() returns), so a saved search migrated from the
obsolete childNote condition serialized -- and synced -- the condition
as "resultLevel/undefined".
2026-07-10 11:45:28 -04:00
Dan Stillman
c43f028c96 Remove dead code left over from the Advanced Search dialog
onLibraryChange() has no callers since library changes re-render the
pane, and .active/doCommand() belonged to the removed dialog's search
box.
2026-07-10 11:45:28 -04:00
Dan Stillman
8daed576ba Make first top-level joinMode win in combineConditions()
_buildQuery() already uses the first top-level joinMode for the
full-text post-filter; with multiple top-level joinMode conditions (only
creatable via the API), combineConditions() used the last.
2026-07-10 11:45:28 -04:00
Dan Stillman
2c62010c4c Fix misplaced comma 2026-07-10 11:45:28 -04:00
Abe Jellinek
0a67f4967c Read Aloud: Update skip button tooltip text
#5998
2026-07-10 11:17:23 -04:00
Abe Jellinek
64165eabf6 ItemTree#selectItems(): Clear advanced search if item doesn't match
Some checks are pending
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Extends existing handling for tag selection and quicksearch to handle
advanced search as well.

Fixes #5996 (not just for zotero://select but for all selectItems()
callers)
2026-07-09 14:11:36 -04:00
Dan Stillman
243ef0e28f Fix historical comments from 606d8f19ba 2026-07-09 13:12:43 -04:00
Dan Stillman
606d8f19ba Advanced search: Indent subcollections in Collection condition menu
Instead of just prefixing the labels with "-", indent the whole row,
including the icon. This looks better and fixes FAYT on subcollection
names.

https://forums.zotero.org/discussion/132561/
2026-07-09 13:09:24 -04:00
Dan Stillman
03a610e696 Activate window in focus traversal tests, fixing intermittent timeouts
The #focus() Tab/Shift-Tab tests dispatch synthetic keydowns and wait on
a focusin event to sync with the collections search field's async
(setTimeout) focus. Gecko only fires focus/blur events while the window
is active, so when the pane window wasn't active -- it opens in the
background on macOS/Windows and can lose activation on Linux/CI -- the
focusin never fired and the test hung until Mocha's timeout. Activate
the window in beforeEach, as the item pane and tags box focus tests
already do.

Also removes the temporary open-window logging added in e1f19f34f5.
2026-07-09 11:00:24 -04:00
Dan Stillman
c3787495cc Don't show empty-library message for empty search with no groups
hasItems() also now short-circuits with LIMIT 1 rather than counting all rows

https://forums.zotero.org/discussion/132635/zotero-beta-empty-database-message-when-no-results
2026-07-09 10:38:43 -04:00
Dan Stillman
e1f19f34f5 CI TEMP: Debugging for shift-tab focus test timeouts 2026-07-09 10:32:53 -04:00
Dan Stillman
1c9bf88888 Unpack update archives for different architectures in parallel
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
2026-07-08 12:10:46 -04:00
Dan Stillman
39a1c56f6c Cache update diffs and compressed files across builds
Cache patches and compressed files by content hash so that they're reused
across FROM versions and releases. Cache entries are touched when used and
expire after 60 days.

The cache key includes a tag covering the xz/mbsdiff versions and the
compression options, so entries are invalidated when the toolchain changes.
2026-07-08 12:10:45 -04:00
Dan Stillman
d07dd66c21 Parallelize update MAR generation
Run the per-file mbsdiff/xz work in make_incremental_update.sh and
make_full_update.sh through a parallel job pool, largest files first, and
assemble the manifests serially afterward, producing byte-identical MARs.

Set UPDATE_PACKAGING_JOBS to override the number of parallel jobs.
2026-07-08 12:10:45 -04:00
Dan Stillman
3ae3bd41dc itemPaneTest: Scroll to attachments pane instead of relying on pin
The pinned pane doesn't reliably trigger a render of an out-of-view pane in
this state, so scroll to the pane the way the surrounding tests do.
2026-07-08 10:50:32 -04:00
Dan Stillman
4314494455 Include callback source in waitForCallback timeout errors
The stack for these errors is stripped as internal frames, so the error
didn't indicate which wait timed out.
2026-07-08 10:50:32 -04:00
Dan Stillman
f9e711a9b2 itemPaneTest: Wait for window resize in attachments pane beforeEach
Tests that shrink the window to test visibility-based rendering restore the
height on their last line without waiting for it to apply, so the next test
could start rendering while the window was still 100px tall. The attachments
box's async render is skipped for panes outside the viewport and only retried
on scroll, so the pane never rendered and assertions on it failed or timed
out.
2026-07-08 10:50:32 -04:00
Dan Stillman
77a3cd82a9 itemPaneTest: Wait for attachments box render of the correct item
waitForPreviewBoxRender() without an item id returns immediately if the box
still holds the previous item's completed render, so on a slow machine the
row assertions could run before the new item's async render started.
2026-07-08 10:50:32 -04:00
Dan Stillman
49e284bab0 recognizeDocumentTest, translatorsTest: Initialize translators in before()
EPUB recognition translates the EPUB's RDF metadata, and
Zotero.Translators.save() requires translators to be loaded. The full test
run relied on an earlier test file having initialized translators.
2026-07-08 10:50:32 -04:00
Dan Stillman
7d33215a37 recognizeDocumentTest: Restore translate stub in afterEach after failure
Tests restore the stub at the end of the test body, so a failure left
Zotero.Translate.Search.prototype.translate wrapped and retries failed with
"Attempted to wrap translate which is already wrapped".
2026-07-08 10:50:32 -04:00
Dan Stillman
dfd745c5d5 Log test failures that will be retried
The reporter only showed the final failure, so if a retry failed differently
(e.g., from a stub left wrapped by the first failure), the original error was
lost.
2026-07-08 10:50:32 -04:00
Dan Stillman
1fe9cad5f8 citationDialogTest: Initialize styles in before()
Zotero.Cite.getLocatorString() requires Zotero.Styles.init() to have run.
The full test run relied on an earlier test file (bibliographyTest) having
initialized styles.
2026-07-08 10:50:32 -04:00
Dan Stillman
62a431f42b CI: Don't run duplicate jobs for same-repo pull requests
Pushes to branches in this repo already run CI, so run pull_request jobs only
for PRs from forks, which don't trigger push runs here. The push run's checks
still appear on the PR, since checks are attached to the head commit. (This
skips merge-commit testing for same-repo PRs -- the push run tests the branch
as-is.)
2026-07-08 10:50:32 -04:00
Dan Stillman
4c392c4764 CI: Run tests in 4 parallel shards
Each shard job builds and runs a quarter of the test files via runtests.sh -p.
The deployment ZIP upload and utilities tests move to separate jobs. With the
default fail-fast matrix behavior, a failure in one shard cancels the others.
2026-07-08 10:50:32 -04:00
Dan Stillman
bd25413213 runtests.sh: Add -p option to run a shard of the test files (e.g., -p 2/4)
The sorted file list is split into contiguous chunks of roughly equal total
file size, using size as a stand-in for run time, so new test files are
included automatically and slow test files are spread across shards rather
than landing in the same shard by chance. Contiguous chunks preserve the
alphabetical run order of a full run, so files keep the same preceding files
as in a full run except at chunk starts, and a shard can be reproduced locally
by passing its first and last files to -s and -e.
2026-07-08 10:50:32 -04:00
Dan Stillman
094d5cc2b6
Update staged builds in place for faster dev builds (#5994)
After a full build, dir_build saves a manifest of build/ files to
staging/.build-manifest. On subsequent runs, if all changed files are
ones that build.sh copies into omni.ja unmodified (chrome/, components/,
resource/, and test/ when tests are staged), zip just those files into
the staged omni.ja instead of rebuilding, taking rebuilds from ~15
seconds to ~0.3 seconds on an M1 Mac. Files are prescreened by size and
mtime so that only changed files need to be hashed. Zotero .ftl files
are also updated at their localization/<locale>/ paths, and test files
are also copied to the staged tests/ directory.

Any other change triggers an automatic full rebuild: files transformed
by build.sh (defaults/, chrome.manifest, version, translators/, styles/,
mozilla .ftl files, CSL locales), removed files, changes to build inputs
in app/ (detected via a size/mtime fingerprint, with xulrunner runtimes
covered by the hash-* files written by fetch_xulrunner), or requesting
tests or devtools that the staged build doesn't include.

Other changes:

- dir_build no longer takes -q and always skips omni.ja compression and
  optimization, which only matter for distribution builds made via
  build.sh. Use -f (dir_build or build_and_run) to force a full rebuild.
- build_and_run now always rebuilds. -r is deprecated, and -n skips the
  rebuild and just launches the app.
- build_and_run no longer passes -purgecaches. Startup caches are
  invalidated automatically when the BuildID changes, which now happens
  whenever omni.ja is modified (including via add_omni_file), so
  relaunching an unchanged build can use the startup cache.
- build_and_run and runtests.sh invoke js-build directly instead of via
  'npm run', which saves ~270ms of npm overhead per build.
- The Word integration dylib is now ad-hoc-signed by dir_build, and only
  on full rebuilds, since incremental updates don't invalidate the
  existing signature. This also covers test builds, which were never
  signed before.
- dir_build removes broken symlinks left in build/ when source files are
  deleted, which previously broke rsync in prepare_build.
2026-07-08 10:07:58 -04:00
Martynas Bagdonas
225e91c5e7 Tabs: Fix audio status validation
Validate the status argument instead of the unrelated title identifier, so invalid audio status updates fail with the intended error.
2026-07-08 14:52:31 +03:00
Martynas Bagdonas
2b7f46227f Reader: Reliably deactivate the docShells of hidden reader tabs
The reader now releases its rendered pages while its document is
hidden, which requires document.visibilityState to be truthful: it's
driven by docShellIsActive, which also controls event-loop throttling
for background tabs. The flag was maintained by scattered one-off
writes and went stale on the transitions they didn't cover: a tab
opened in the background ran at foreground priority (unthrottled rAF
and timers) with a 'visible' document until the next tab switch, and a
hidden tab kept an active docShell forever after its Read Aloud
playback was paused or stopped — in both cases keeping the reader from
ever releasing memory.

Replace the writes with a single _updateDocShellActivity() that
re-derives the state — active only while the tab is selected or
playing Read Aloud — called from the three places its inputs change:
tab creation, tab selection, and Read Aloud status changes.
2026-07-08 14:51:07 +03:00
Dan Stillman
55eceab3b3 Update English strings for Transifex
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
2026-07-05 11:23:51 -04:00
Dan Stillman
e2db3c7966 Citation dialog: Handle citation processor errors gracefully
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
If the preview fails, log the error and show "Preview unavailable"
instead of breaking the item-add flow with an unhandled rejection.

If io.sort() fails during accept, log and continue unsorted -- the
insert runs the same processor operation, so a real failure triggers the
document-update error dialog instead of a stuck progress window.
Skipping the sort doesn't affect the inserted citation, which the
processor sorts itself. It only determines the stored item order, and
with it the initial bubble order if the citation is edited later -- the
reopened dialog re-sorts once cited data has loaded.
2026-07-03 16:01:50 -04:00
Dan Stillman
cddf0ea237 Update citeproc.js to include Juris-M/citeproc-js#281
Fixes HTML appearing in citations after an error during citation preview

https://forums.zotero.org/discussion/132539/
2026-07-03 15:53:12 -04:00
Dan Stillman
057966000c Advanced search: Don't move focus to the results when running the search
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Behavior regression in #5658

https://forums.zotero.org/discussion/132526/10-0-beta-8-number-of-results-in-the-new-advanced-search
2026-07-02 12:59:25 -04:00
Dan Stillman
e416ea2dfa Fix repeated save prompt after trashing the saved search being edited
Deleting a search in the collections pane moves it to the trash rather
than erasing it, so the check that closes the saved-search editor when
the edited search is deleted didn't catch it. The save-changes prompt
then appeared on the next selection change, and canceling couldn't
restore the removed row, so the prompt reappeared on every selection
until choosing Don't Save. Treat a trashed search like a deleted one
and close the editor without prompting.
2026-07-02 12:44:07 -04:00
Dan Stillman
2f364f2fab Allow creating a saved search with collections or searches selected
Previously a search could be saved only at a library root. Now the Save
Search button is enabled when collections and/or saved searches within
a single editable library are selected, and the selection is added to
the saved search as collection/savedSearch conditions -- 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 instead of
joining the OR. A 'recursive' condition is included per the
recursiveCollections pref.

https://forums.zotero.org/discussion/132528/beta-cannot-create-a-saved-search-from-a-collection
2026-07-02 12:38:52 -04:00
Dan Stillman
a48336350a Remove .swp file committed by accident 2026-07-02 10:54:04 -04:00
Martynas Bagdonas
af704dc119 Update document-worker submodule and report SDT progress 2026-07-02 15:04:31 +03:00
Dan Stillman
26c30d8ecd Allow browsing an attachment's annotations in search results
With "Hide Non-Matching Annotations" enabled, an attachment displayed as
empty and non-expandable if no annotations matched the search, so
searching by any non-annotation condition made it impossible to expand
attachments to browse their annotations. Now only hide the non-matching
annotations when the attachment actually has a matching one.

https://forums.zotero.org/discussion/132519/beta-advanced-search-cannot-expand-annotations-of-search-results
2026-07-01 22:13:38 -04:00
Tom Najdek
95ee1c616c
Fix xulrunner_hash to read the hash from openssl output on LibreSSL/macOS
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Previous command only worked with OpenSSL's labeled output where the hash is the second field; the update also handles LibreSSL (macOS), which prints just the bare hash.
2026-07-01 20:14:30 +02:00
Dan Stillman
40b272c46b Add -b (build-only) flag to beta_build_and_deploy 2026-06-30 17:44:49 -04:00
Dan Stillman
1d8cdb13c1 Update Windows build to Firefox 140.12.0esr 2026-06-30 16:41:38 -04:00
Dan Stillman
163c27ca79 Keep Advanced Search button visible when the item pane is wide
When the item pane is dragged wide, the items pane is squeezed and the
quick search wrapper kept its intrinsic width and overflowed, pushing the
trailing Advanced Search button out under the item pane. Let the wrapper
shrink so the button stays within the pane.

Fixes #5982
2026-06-30 15:57:06 -04:00
Dan Stillman
d28bdd8d6e Update Mac and Linux builds to Firefox 140.12.0esr
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-06-30 15:09:55 -04:00
Abe Jellinek
279e25c57e
Preferences: Scroll first search result into view (#5976) 2026-06-30 14:21:30 -04:00
Dan Stillman
13bd8c9d8d
Group attachment and annotation conditions into Advanced Search submenus (#5981) 2026-06-30 14:20:47 -04:00
Tom Najdek
7f30b0e47d
Respect skipDateModifiedUpdate of the operation when undoing it, fix #5971 2026-06-30 18:23:23 +02:00
Abe Jellinek
f3328b02ee
Item/collection tree: Resize using ResizeObserver, not debounce (#5977) 2026-06-30 08:44:18 -04:00
Adomas Venčkauskas
9cffc1c7c2 Update LibreOffice submodule 2026-06-30 14:35:38 +03:00
Dan Stillman
36d025f6a9 Update item count on every items-view refresh
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
The 'items in this view' count failed to update when the result set
changed without a selection change (e.g., quick search, tag filter, or
sort with nothing selected). The listener that refreshes the item pane
on refresh was registered in 8277277948 to fire only once, on initial
load, so later refreshes never recomputed the count.

This is all a regression from the items tree refactor (cbbff600a6),
which stopped running itemSelected() on every refresh.
2026-06-29 15:12:22 -04:00
Tom Najdek
2d56936bfe
Fix surplus space left behind after editing Extra in batch mode, fix #5969 2026-06-29 18:42:25 +02:00
Tom Najdek
2006cdac6d
Improve the behavior of read-only fields in batch edit mode, fix #5968
- Fix a bug where focusing on a read-only field with a common value across items would clear the field and display a "Multiple" placeholder instead
- Fix read-only date fields always showing "Multiple" in batch edit mode, even when values are genuinely different
- Read-only fields are now focusable in batch edit mode
2026-06-29 17:17:33 +02:00
Dan Stillman
02b19d0cde Match multi-level search condition against any ancestor level for descendant results
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
A field that exists at more than one level (e.g., Title, on both
top-level items and attachments) targeting a descendant result level
only matched the closest ancestor, so searching annotations by Title
found nothing, since it checked the parent attachment's title rather
than the top-level item's. Map down from each ancestor level and union
them, testing the predicate once so its bound parameters aren't
duplicated.

Addresses #5978
2026-06-26 16:53:52 -04:00
Dan Stillman
340ba92513 Show "Search subcollections" only with a Collection condition
The option only affects Collection conditions, so hide it -- and don't
emit the 'recursive' condition -- when the search has none.
2026-06-26 16:29:34 -04:00
Dan Stillman
6c7c661fb1 Advanced search: Separate Saved Search from the Collection condition
Saved searches were folded into the "Collection" condition's value menu
in 2016 (9c52ebdf8b), for reasons I can't totally remember. Give Saved
Search its own condition again.
2026-06-26 16:24:49 -04:00
Dan Stillman
a1f36f750e Advanced search: Fix extra padding on nonexistent icon in hint button
Addresses #5978
2026-06-26 15:55:32 -04:00
Dan Stillman
fe340283c1 Crop Advanced Search label so buttons stay visible when window is narrow
Addresses #5978
2026-06-26 15:55:32 -04:00
Dan Stillman
0e587176e8 Advanced search: Match condition value field height to drop-down menus
Addresses #5978
2026-06-26 15:55:32 -04:00
Dan Stillman
d4507544af Advanced search: Reduce the condition row's control gap to 6px
Addresses #5978
2026-06-26 15:55:32 -04:00
Dan Stillman
98308c65b9 Advanced search: Drop the extra padding on the 'Find' line
Add a bit more inline padding to the whole pane to compensate, so the Find
line and conditions box aren't too close to the left edge.

Addresses #5978
2026-06-26 15:54:49 -04:00
Dan Stillman
f7e67f6643 Advanced search: Match the level-warning text size to the binding hint
Addresses #5978
2026-06-26 15:53:38 -04:00
Dan Stillman
cf7ee984ea Advanced search: Hide obsolete Child Note condition and migrate to Note
Addresses #5978
2026-06-26 15:53:07 -04:00
Dan Stillman
3875f411e5 Advanced search: Show binding hints and warnings on condition select
Not just on value entry, but add an exception for a new row created with
the + button

Addresses #5978
2026-06-26 15:52:34 -04:00
Dan Stillman
680452cc59 Advanced search: Update condition grouping icon and add ungroup button
Addresses #5978
2026-06-26 15:52:16 -04:00
Dan Stillman
3544848e5f Keep an edited search condition value when switching condition types
Addresses #5978
2026-06-26 15:50:58 -04:00
Dan Stillman
b56c153125 Fix Attachment Last Read search condition by matching at attachment level
Addresses #5978
2026-06-26 15:50:17 -04:00
Dan Stillman
2717aafedd Add annotation type, color, and author search conditions
Add annotationType, annotationColor, and annotationAuthor conditions,
each tagged `level: 'annotation'` so the cross-level search logic maps
and negates them correctly. The value fields are drop-down menus: the
annotation types, the reader's color palette, and the library's
existing annotation authors.

Ported from #5839. The PR kept a negated annotation condition (e.g.,
"Annotation Color" "is not" "yellow") from matching every non-annotation
item by checking whether the condition name contained "annotation". This
does the same using the condition's level, which the cross-level logic
already handles, so the existing annotationText and annotationComment
conditions are covered too.

Fixes #5837
2026-06-26 13:08:15 -04:00
Dan Stillman
cb2110e1c1 Move the annotation conditions into the main search menu
Addresses #5978
2026-06-26 12:48:48 -04:00
Dan Stillman
2239dd20a4 Update global schema version for search-condition-group gate (#5962)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-06-25 16:14:38 -04:00
Dan Stillman
60807d552c Remove the search condition required flag (#5962)
The flag forced a condition to be ANDed even in "any" mode, but
condition groups now express that directly. It was never exposed in the
search UI and nothing seems to have been using it.

addCondition()/updateCondition() now throw if passed a truthy
`required`. Not dropping the column now to preserve DB compatibility.
2026-06-25 16:14:38 -04:00
Dan Stillman
ccf6f18643 Prefill Advanced Search from the quick search (#5962)
Opening Advanced Search from a non-empty quick search reproduces the
current quick search mode as editable conditions, one per word (or quoted
phrase) joined with "all":

- Title/Creator/Year: a single Title, Creator, Year condition per word,
  with the result level set to item (the mode matches only top-level items)
- All Fields & Tags: a single Any Field condition per word
- Everything: Any Field + Full Text Content as an "any" group per word
  (relies on grouped full-text composing correctly in SQL)

The Title/Creator/Year mode needs a condition to map to, so add a "Title,
Creator, Year" search condition that expands to the same field set as the
quick search mode (title, publication title, short title, court, year,
citation key, creator), mirroring how Any Field matches All Fields & Tags.
Like Any Field, it expands at query-build time, so the saved search stores
a single condition and its sub-fields don't need their own entries in the
condition menu.
2026-06-25 16:14:38 -04:00
Dan Stillman
786b85bf2d Expand Any Field in place so it nests correctly (#5962)
An Any Field condition expands into field/tag/note/creator conditions for
the term. Splice the expansion in right after the condition so it stays at
the same nesting depth, rather than appending it to the end of the
processing queue, which would emit it at the top level instead of within
its group. (Top-level Any Field is unaffected.)
2026-06-25 16:14:38 -04:00
Dan Stillman
0529c9a579 Match Any Field search condition to the All Fields & Tags quick search mode (#5962)
Add annotation text and comments to the conditions the Any Field
condition expands to, matching the fields covered by the All Fields &
Tags quick search mode, as the comment already says is intended. Key
detection and quoted-phrase splitting still differ, since those depend
on the quick search string parsing that Any Field doesn't do.
2026-06-25 16:14:38 -04:00
Dan Stillman
092295da22 Add result-level controls to the Advanced Search builder (#5962)
- Reword the header as one sentence with a result-level menu ("Find
  [attachments] matching [all] of the following:")
- Provide a per-group menu to bind the group's descendant conditions to
  the same attachment, note, or annotation (e.g., one annotation that is
  both red and contains a given word, not two different ones)
- Show a hint that offers to group ungrouped sibling conditions (e.g.,
  two annotation conditions at the top level, to bind them to one
  annotation)
- Show a warning when conditions can't combine at the chosen result
  level (e.g., an annotation condition with a note result level)
- Remove the two legacy checkboxes:
  - "Show top-level items" becomes result level = top-level item and is
    migrated on save
  - "Include parent and child items", which has no result-level
    equivalent, keeps working, stays editable, and round-trips on
    searches that already have it, but it isn't offered on new searches
    and is removed on save if unchecked
2026-06-25 16:14:38 -04:00
Dan Stillman
8b5a77a75e Support cross-level conditions and a result level in search (#5962)
Give a search a result level -- top-level item, attachment, note, or
annotation -- and map every condition to that level, so one search can
mix conditions that match at different levels of the item hierarchy
(e.g., a top-level item with a given author and a red annotation on one
of its PDFs). Each condition carries the level(s) it matches at: a match
is mapped up to an ancestor or down to a descendant, level-agnostic
conditions (tags) roll up to the result level, and fields that exist on
both items and attachments (title, url, accessDate) match natively at
either. The result level is stored as a `resultLevel` marker condition
alongside the join mode.

This also removes the temporary annotation-parent hacks, which the
general cross-level mapping replaces.
2026-06-25 16:14:38 -04:00
Dan Stillman
1341b6d5cc Style Advanced Search conditions as a divided, indented list (#5962) 2026-06-25 16:14:38 -04:00
Dan Stillman
cdc70d1280 Compose grouped full-text conditions in SQL (#5962)
A fulltextContent condition was evaluated as a global post-filter keyed
on the search's top-level join mode -- correct for a top-level
condition, but not for one inside a group, which must combine with its
siblings under the group's own join mode. The new condition grouping UI
allows fulltextContent to be placed within groups, and we need to do so
to prefill the advanced-search pane from an "Everything" quicksearch.

Materialize a grouped fulltextContent into an itemID set and emit it as an
ordinary itemID IN/NOT IN predicate, so combineConditions composes it
under the group's join mode. Top-level fulltextContent keeps the existing
post-filter unchanged.

This also removes the quicksearch full-text post-filter special case. A
quick search puts its full-text in per-word "any" groups, so the
_hasQuicksearch flag was needed to make the post-filter union those
matches rather than intersect them under the top-level "all" join. Now
that the grouped full-text is composed in SQL it never reaches the
post-filter, so the flag is gone.
2026-06-25 16:14:38 -04:00
Dan Stillman
5904f0e4bf Diff saved-search conditions as an ordered list (#5962)
With condition groups, the position of conditions and the pairing of
groupStart/groupEnd markers are meaningful, but conditions were diffed
as an unordered member set (compared by value, with additions appended).
A sync-conflict merge could reorder conditions or add/drop group
markers, corrupting the group structure.

Diff them as a single ordered unit instead, like creators. This changes
how concurrent edits to a search are reconciled: rather than merging the
two sides' conditions member by member, a conflicting edit now resolves
wholesale -- the remote condition list replaces the local one (searches
auto-merge to the remote version); a one-sided change still applies that
side's full list. Discarding one side of a rare simultaneous edit is
acceptable and avoids silently corrupting a grouped search's structure.

This was the only user of SearchConditions.equals(), so remove it.
2026-06-25 16:14:38 -04:00
Dan Stillman
d808988540 Add condition grouping UI to Advanced Search (#5962)
Render the search as a tree of groups: a root group plus nested
search-condition-group elements, each with its own join-mode menu and a
remove control. Each condition row gets a "( )" button that wraps it in
a new group in place, so further conditions can be added to combine with
it under a separate join mode. Switch the builder to rebuild-from-tree --
the DOM is the source of truth and the search's flat conditions (with
groupStart/joinMode/groupEnd markers) are regenerated on each edit, so
the old conditionID-as-index tracking is gone.
2026-06-25 16:14:38 -04:00
Dan Stillman
c01def2a85 Support nested condition groups in saved searches (#5962)
Replace the flat anySQL/quicksearch-block assembly in _buildQuery with a
recursive tree of AND/OR groups, built and reduced by a new
Zotero.Search.combineConditions() helper. groupStart/groupEnd markers
delimit nested groups and a joinMode marker sets each group's mode, so a
saved search can combine conditions with arbitrary nesting and per-group
join modes. The per-condition SQL generation is unchanged.

For example, a search built as

    search.addCondition('joinMode', 'all');
    search.addCondition('title', 'contains', 'foo');
    search.addCondition('groupStart', 'true', '');
    search.addCondition('joinMode', 'any');
    search.addCondition('tag', 'is', 'x');
    search.addCondition('tag', 'is', 'y');
    search.addCondition('groupEnd', 'true', '');

means "title contains 'foo' AND (tag is 'x' OR tag is 'y')". The 'true'
operator on the group markers is an unused placeholder -- they carry no
value, but a condition's operator can't be empty.

The quick search (matching multiple words) and the Any Field condition
previously had their own special handling in the query builder; they now
use the same grouping as everything else, so that code is gone. Behavior
for existing non-grouped searches is unchanged; new tests cover nested
groups and combineConditions() directly.
2026-06-25 16:14:38 -04:00
Abe Jellinek
9a4317daa7 Preferences: Trim search term
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Fixes #5975
2026-06-25 09:35:47 -04:00
Dan Stillman
8f96a6d191 Enable Reset File Sync History for read-only libraries
https://forums.zotero.org/discussion/132375/switching-file-sync-settings-for-group
2026-06-25 09:34:47 -04:00
Dan Stillman
aab912e4ee Support Select All (Cmd/Ctrl-A) in the collection tree
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
A plain select-all would mix uncombinable rows, so scope it to the
current selection's type: a library selection expands to all library
roots; a collection selection to every collection sharing a parent with
a selected one (so multi-level/multi-parent selections expand within
each branches); and a Recently Read selection to Recently Read in every
library. Other rows -- saved searches, Unfiled, Trash, etc. -- have no
useful expansion, so the tree's key handler leaves the selection
untouched instead of clearing it.
2026-06-24 14:23:08 -04:00
Dan Stillman
d7660f2f27
Add local-cache versioning for Read Aloud audio (#5967)
Version Read Aloud cache keys with the server-provided cacheVersion so
a version bump misses stale entries and re-fetches correct audio, prune
obsolete entries once per session, and skip caching responses sent with
Cache-Control: no-store.
2026-06-24 11:21:09 -04:00
Dan Stillman
962e1334c8 Inset item tree focus ring so its top isn't clipped
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Regressed by f4adb452ee, which dropped the items-tree body's top padding
to 0 for sticky section headers; the scroll container's overflow then
clipped the top of the first row's focus ring, which drew outside the
row box.
2026-06-23 09:35:25 -04:00
Tom Najdek
40cb1cfa84
Fix stale itemType in _previousData (#5964)
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
This fixes a problem where, on subsequent item type changes, a stale itemType is reported and the filename is not updated.
2026-06-19 16:34:23 -04:00
Dan Stillman
35317982f5 Make the sticky library header opaque to pointer events
The pinned sticky section header was opaque to paint but had
pointer-events: none, so clicks, drag-starts, and drops fell through to
the item occluded underneath it.

Make the opaque content capture pointer events instead. Clicks no-op
(header rows aren't selectable), drags don't start (header rows aren't
draggable), and drops on the header are rejected so they're a no-op
rather than acting on the list underneath.

Also drop the index > 0 exception when skipping non-selectable rows in
_onSelection(), so clicking a non-selectable row at the top (i.e., the
first library's header) is a true no-op instead of scrolling to the top.

Also skip non-selectable rows in handleActivate(), since the header can
now be double-clicked; without this it would try to open the library as
an item.

Fixes #5960
2026-06-19 16:07:53 -04:00
Dan Stillman
bdf79336f9 Account for sticky section header when scrolling a row into view
Arrow-key navigation scrolled the newly selected row flush with the top
of the view, leaving it hidden behind the pinned sticky section header.
Reserve a row's worth of space at the top so the row lands below the
pinned header.

Fixes #5959
2026-06-19 14:28:40 -04:00
Tom Najdek
e71bd89e0d Add support for undo/redo (#5823)
---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-06-19 14:12:54 -04:00
Tom Najdek
c92148c3ec Add batch editing support for fields (#5823)
---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-06-19 14:12:54 -04:00
Dan Stillman
8d7f5a39f9 Don't include trashed items in Advanced Search outside the trash
#5658 added includeDeleted to the Advanced Search outside the trash, so
trashed items kept matching: trashing a result removed its row, but
re-running the search brought it back. Exclude deleted items by default,
the same as a quick search, and only include them when viewing the trash,
where the scope returns only deleted items.

Fixes #5956
2026-06-19 14:07:43 -04:00
Dan Stillman
ff094330f5 Don't show all trashed collections/searches during a search in trash
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
When viewing the trash, trashed collections and saved searches were appended
to the items list unconditionally, so every advanced search (and quick search)
in the trash matched all of them. Now they're filtered by name during a quick
search and excluded entirely when an advanced search or tag filter is active,
since they can't match item-level conditions or tags.

Fixes #5957
2026-06-19 13:43:53 -04:00
Dan Stillman
4a318617d6 Don't crash on unbalanced {{endif}} in template engine
An {{endif}} without a matching {{if}} popped the base level off the
stack, leaving it empty and throwing "level is undefined" on the next
part. A malformed filename format template could therefore break file
attaching entirely.

https://forums.zotero.org/discussion/132296/pdfs-have-stopped-attaching-after-upgrade-to-zotero-9
2026-06-19 13:05:19 -04:00
Tom Najdek
6092b0335c
Fix auto-renaming not triggering when parent item type changes 2026-06-19 15:04:56 +02:00
Dan Stillman
35c8448582 Advanced search: Move focus to next "-" button when removing row via keyboard 2026-06-19 00:14:06 -04:00
Dan Stillman
e37a7d8f06 Advanced search: Don't run the search on Shift-Enter 2026-06-19 00:14:06 -04:00
Dan Stillman
2e79bbe402 Advanced search: Focus condition drop-down when adding row via keyboard 2026-06-19 00:14:05 -04:00
Dan Stillman
e38f8f78b0 Advanced search: Add find-as-you-type for all conditions
Closes #5953
2026-06-19 00:14:05 -04:00
Dan Stillman
90aa5d33a0 Fix first-column (Title) header label alignment after item tree refactor
The refactor dropped the 8px (COLUMN_PADDING / 2) offset that makes up
for the inline-start padding the first cell omits, so the header label
sat 8px too far left of the item titles.
2026-06-18 23:32:47 -04:00
Dan Stillman
f4adb452ee Keep the sticky items-list section header clear of the scrollbar
Pin the header inside the scrolling body as a zero-height position:
sticky element so its width tracks the body's content box and it lines
up with the rows without any JS geometry. Reserve a scrollbar gutter so
the macOS overlay scrollbar doesn't float over the content (the opaque
header must paint above the rows to occlude them, and so above the
scrollbar, so it can't be put under it). Drop the body's top padding for
the item tree so rows clip exactly where the header pins, and keep the
gap below the column header as a margin outside the scroll area.

Fixes #5958
2026-06-18 23:25:22 -04:00
Dan Stillman
08d875c7f7 Handle multiple-library item selections dropped on a collection or library
Route each item by its own library -- items already in the target
library are added directly (or skipped, for a library root), while
items from other libraries are copied in -- instead of attempting an
invalid cross-library insert. Disallow a move of such a selection
rather than silently copying.

Fixes #5961
2026-06-18 16:13:16 -04:00
Dan Stillman
6d1cd85211 Skip retraction banner during initial sync on a new computer 2026-06-18 14:51:44 -04:00
Dan Stillman
39954dc437 Remove dead collection assignment for window-opened notes (#5954)
The collections passed through Notes.open into the note window were never
read -- the note editor has no collections setter and EditorInstance.collection
is never assigned on this path -- so they had no effect. New notes are still
added to the selected collection(s) directly in newNote().
2026-06-18 14:10:25 -04:00
Dan Stillman
20fd5fe3e2 Move tag selector view settings popup handler out of XHTML (#5954) 2026-06-18 14:09:49 -04:00
Dan Stillman
618fcfa49d Use one combined tag query for a multiple-row selection (#5954) 2026-06-18 14:09:44 -04:00
Dan Stillman
1c76195a7c Show library-aware section headers in the item tree for multi-row selections (#5954)
When multiple collection-list rows are selected, group the combined
items by library under sticky headers (e.g., "My Library", "Group X (2
collections selected)"), or show a single summary header for a multi-row
selection within one library, with blank spacer rows separating
libraries.
2026-06-18 14:09:36 -04:00
Dan Stillman
94c78759ab Add support for sticky section headers to VirtualizedTable (#5954)
When stickySectionHeaders is enabled, the header of the section at the
top of the view is pinned in an overlay that the rows scroll under,
pushed up as the next section's header arrives. Consumers identify
header rows via isSectionHeader. Row striping restarts at each section
header so every section's first row is the same shade.
stickySectionHeaders defaults to false, so existing tables are
unaffected.
2026-06-18 14:09:31 -04:00
Dan Stillman
b80fa996f5 Group the items list by library (#5954)
When the items list contains items from more than one library, group
them by library -- in collections-list order, independent of the active
sort -- with a section heading above each library's items.

Grouping is triggered automatically by an items list spanning more than
one library, not the kind of selection behind it, so any future source
of multi-library items would be separated the same way. Today the
cross-library collection selection is the only such source.
2026-06-18 14:09:17 -04:00
Dan Stillman
15c2c95470 Support multiple-collection selection (#5954)
Allow selecting multiple collections, saved searches, or library roots in the
collection tree -- within a library or across libraries -- and show the union
of their items. The selection is threaded through the pane as an array
(getCollectionTreeRows(), changeCollectionTreeRows(), etc.); the item pane, tag
selector, reports, and export operate on all selected rows.

Adding items (new items and notes, drag-and-drop, the attachment dialog,
import, Add by Identifier) targets every selected collection. Only rows that can
share an items view may be combined: collections, saved searches, and library
roots mix freely, and multiple Recently Read rows can be combined across
libraries, but other special views (Trash, Duplicates, etc.) and rows from
different visibility groups can't be shown together, so a selection mixing them
keeps only the focused row. In-window advanced search runs across all selected
collections.

Advanced search value autocomplete is now scoped to the searched library, fixing
a long-standing TODO where suggestions were drawn from all libraries regardless
of the search scope; for a cross-library selection it spans the selected
libraries.

For a cross-library selection, the tag selector shows the union of tags
(colored tags only when a single library is in scope, since colors are
per-library), and deleting a tag spans all selected libraries while
rename/color/split are disabled. If advanced search is open, the
collection and saved-search conditions are omitted, since each is scoped
to a single library.
2026-06-18 14:05:55 -04:00
Dan Stillman
2ddb4e991b Show sort indicator on default-sorted column and fix first-click reverse
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
On a fresh profile, the default sort column had no explicit sortDirection,
so the first header click set the default direction instead of reversing
the sort, and no sort indicator was shown.
2026-06-17 15:33:37 -04:00
Abe Jellinek
e23a453e5c Recently Read: Show matching children as regular rows
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
In other words, don't show the children that make the parent "recently
read" as context rows.

Children that don't match the Recently Read condition (such as notes,
as well as other child attachments that weren't read since the cutoff)
are still shown as context rows.

And add tests for the new behavior, and clean up an unused local var.
2026-06-16 13:52:14 -04:00
Dan Stillman
4d38970ef0 Center the Advanced Search filter button's focus ring on the field edge
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-06-15 20:38:37 -04:00
Bogdan Abaev
e6f82dabfd fix keyboard nav through advanced search btns+deck (#5955)
- Include the Advanced Search button in the quick-search tab sequence
- Tab: quick-search textbox -> Advanced Search button -> item tree
- Shift-Tab: Advanced Search button -> quick-search textbox
- Shift-Tab: item tree -> Advanced Search button (when the deck is
  closed)
- Shift-Tab: Advanced Search collapse button -> New Item button
- Fix Shift-Tab from the item tree doing nothing when the Advanced
  Search deck is open; now moves focus to the deck's last focusable
  element
- add tooltiptext to expand/collapse and close btns
2026-06-15 20:38:27 -04:00
Dan Stillman
cf876033f6 Fix join-mode menulist clipping at top of Advanced Search 2026-06-15 15:22:16 -04:00
pi-dal
f03004bb9e
fix(attachments): add PMCID fallback for PubMed resolver lookup (#5906)
PubMed saves can include a PMCID even when DOI-based full-text lookup
fails to produce a PDF. In that case the connector asks the desktop
attachment resolver flow for fallback sources, but getFileResolvers()
only considered DOI and URL data, so Zotero never tried the matching
PMC article URL.

Treat PMCID as a valid signal for file lookup eligibility and add a
PubMed Central OA resolver before DOI-based OA lookup. Cover the new
fallback path in attachment resolver tests and the connector
hasAttachmentResolvers endpoint.
2026-06-15 14:57:49 -04:00
Abe Jellinek
80ca898a74 Preferences: Fix search not filtering and not highlighting results
1. 862573eab added references to fileRenaming.ftl strings but didn't
   add a <link> to it, so document.l10n.formatMessages() returned
   nothing. It fell back to Zotero.getString(), which threw an error
   due to a missing string in en-US. We didn't catch that, so the entire
   search process aborted every time.
2. message.attributes is an optional array of { name, value } objects,
   not an object itself, so we were never successfully adding FTL .title
   and .label attributes to the parsed strings.

This fixes the 862573eab regression, and makes search match FTL
attributes as it was always intended to.

Fixes #5947
2026-06-15 13:55:06 -04:00
Dan Stillman
7eef2fb3f0 Don't let a failed database backup block a schema upgrade or startup
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
During a schema upgrade, a failed backup aborted the upgrade and blocked
startup on every launch. Automatic backups are best-effort, so just log
the error and skip the backup instead.

https://forums.zotero.org/discussion/132201/errorunknownerror-could-not-stat-ns-error-generate-failure-44-4350
2026-06-15 11:53:01 -04:00
Dan Stillman
22055d92b7 Fix opening the DB on network filesystems (e.g., SMB) on macOS/Linux
(Confirmed on macOS, but probably Linux too)

Pass openNotExclusive to avoid acquiring an exclusive lock at the OS level
at open time, which fails with an I/O error on network shares. We still set
locking_mode=EXCLUSIVE, so the connection holds an exclusive SQLite lock for
its lifetime and keeps the WAL index in heap memory (no -shm file).

Regression in Zotero 7 (Fx115)

Fixes #4860
2026-06-15 11:16:54 -04:00
Dan Stillman
39d7835472 Don't show post-upgrade banner for source and dev builds 2026-06-15 09:57:45 -04:00
Dan Stillman
e41c788c4c Don't show standalone attachments open in tabs in the citation dialog 2026-06-14 23:24:22 -04:00
Dan Stillman
580e423d4c Fix failing saved-search test
The new Save Search name prompt blocked deck.pane.save().
2026-06-14 16:18:05 -04:00
Dan Stillman
27560d9476 Prompt for a name when saving a new advanced search
The temporary advanced search saved silently under an auto-generated
"Untitled" name. Prompt for a name instead (defaulting to that name),
and label its button "Save Search…" to signal the dialog. The saved-search
editor's button, which saves changes directly, is now just "Save".

https://github.com/zotero/zotero/pull/5658#issuecomment-4696398750
2026-06-14 15:17:25 -04:00
Dan Stillman
81b3c13b13 Close the saved-search editor when the edited search is deleted
Deleting the saved search being edited triggered onCollectionSelected's
save-changes prompt, which doesn't make sense for a search that's gone.
Detect the deleted search and close the editor without prompting.
2026-06-14 15:02:40 -04:00
Dan Stillman
a3180d87c5 Show focus ring on the advanced-search condition menu when opened
The menu was focused programmatically (in response to the filter-button
click), so :focus-visible wasn't set and no focus ring showed. Pass
focusVisible to force the ring.
2026-06-14 14:53:11 -04:00
Dan Stillman
8ed617ae53 Allow removing the last advanced-search condition to reset it
The remove button on the sole remaining condition is now enabled once a
value is entered, and clicking it replaces the condition with the default
empty one shown when the pane is first opened, instead of staying
disabled.
2026-06-13 19:33:31 -04:00
Dan Stillman
97aa4f93ea Restore saving advanced searches in editable group libraries
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Regression in #5658
2026-06-13 19:21:35 -04:00
Dan Stillman
3d334dc244 Align Advanced Search close button with the filter button's right edge
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-06-13 11:03:58 -04:00
Dan Stillman
18d332ec5e Patch out native control margins in Advanced Search
macOS-normalize-controls zeros margins on inputs and checkboxes but not
menulists or buttons, so their native platform margins threw off the
spacing. Zero them and restore spacing via the containers' gaps.

https://github.com/zotero/zotero/pull/5658#issuecomment-4696398750
2026-06-13 09:47:37 -04:00
Dan Stillman
702094f703 Shift Advanced Search filter button to center its icon
https://github.com/zotero/zotero/pull/5658#issuecomment-4696398750
2026-06-13 09:47:25 -04:00
Dan Stillman
40851e1f58 Add bottom border to Advanced Search area
https://github.com/zotero/zotero/pull/5658#issuecomment-4696398750
2026-06-13 09:47:20 -04:00
Dan Stillman
2689997c50 Fix Advanced Search area height calculation
The inner <deck> stacks both panes in one grid cell, so the area was
always sized to the taller (saved-search) pane and never shrank back.
The non-selected pane is now removed from layout entirely.

https://github.com/zotero/zotero/pull/5658#issuecomment-4696398750
2026-06-13 09:47:11 -04:00
Abe Jellinek
00527332c4 Move Advanced Search and saved search editing to the main window (#5658)
---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-06-12 15:21:59 -04:00
Bogdan Abaev
d2f1c56250 keep search conditionIDs in arithmetic sequence
When conditions are removed, shift conditionIDs so that
conditionIDs always go in increments of 1 (0, 1, 2 ...).
It prevents conditionIDs from conflicting with each other
when conditions are rearranged.

Fixes: #3434
2026-06-12 15:16:46 -04:00
Dan Stillman
cd29a818b0 Don't let hover background override selected item background 2026-06-12 15:07:17 -04:00
Bogdan Abaev
0f2d3e103d Citation dialog: show placeholder after a new bubble is added (#5932)
After the first bubble is added, the focused input gets a placeholder
indicating that typing a number will add it as a page to the just-added
bubble. The placeholder is truncated if it's too close to the edge in
multi-item citations.

Also add a tip to the item details popup explaining that locators can
be typed into the main input field, with a link to the documentation.
The tip stops appearing once a typed locator has been used.

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-06-12 15:07:17 -04:00
Bogdan Abaev
40f949ba94 Citation dialog: allow accepting while details popup is open
- allow to accept the dialog via cmd/ctrl+Enter from
inside of a panel
- enable click-through on item details panel, so
one can click on the "Accept" button without having
to close the popup first

These two measures allow one to add a bubble, modify
the citation via item details popup, and accept it
with one less step (without having to close the popup first)
2026-06-12 15:02:14 -04:00
Dan Stillman
c72d80f22b Update Word for Windows submodule 2026-06-12 12:58:00 -04:00
Dan Stillman
cd39445b94 Automatically repair unusable login manager
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Zotero never sets a primary password, so if one is set on the NSS key
database, it was either corrupted or copied in from a Firefox profile,
and stored logins can never be decrypted, since there's no
primary-password prompt. This previously made it impossible to save
credentials without manually deleting cert9.db, key4.db, and
logins.json from the profile directory.

If reading or saving credentials fails and a primary password is set,
clear stored logins and reset the key database so that credentials can
be saved again. Saving the API key or WebDAV password is retried
automatically, so logging in completes without a manual fix, and if
previously stored credentials are lost to a reset, show a one-time
alert prompting the user to log in again.
2026-06-12 11:17:00 -04:00
Dan Stillman
632f11db74 Fix corrupted-login-manager warning never appearing
The once-per-minute rate limit from e40fff6a7d (5.0.91) was inverted,
and the last-error time was never initialized, so the alert hasn't
appeared since 2020.
2026-06-12 11:17:00 -04:00
Dan Stillman
55434d14a1
Encrypt API key and WebDAV password using OS keychain (#5897)
Wraps the values stored in nsILoginManager with OSKeyStore, which derives
its master key from Keychain on macOS, DPAPI on Windows, and libsecret on
Linux. A copy of the profile alone is no longer enough to extract these
credentials.

Existing plaintext entries are mirrored once per session to a new
"(encrypted)" realm but preserved in the original realm so a user can
still downgrade to a release that doesn't know about encryption. Active
credential changes (sign in, sign out, password change) write to the
encrypted realm only and remove the legacy entry. A future version can
clear any remaining legacy entries on startup.

Patches MOZ_APP_BASENAME in the bundled runtime so the keychain master
key is labeled "Zotero Encrypted Storage" rather than "Firefox Encrypted
Storage", with a check_line guard so a future Mozilla change to the
OSKeyStore label format fails the build instead of silently rebranding
the entry. Also fixes check_line to take an explicit file argument.
2026-06-12 11:16:22 -04:00
Martynas Bagdonas
98d82d2909 Add SDT support 2026-06-12 13:47:08 +03:00
Abe Jellinek
982c00aaf6
Add by Identifier: Stay open with input, don't clear unless submitted (#5949) 2026-06-11 11:54:42 -04:00
Dan Stillman
06e16c297c Fix file-change detection in all libraries after the first one synced
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
The local-file-change watcher added in f21e1b2d32 accumulated changed
item keys globally but was drained separately by each library's storage
engine, with the drained keys filtered to that library. The first
library to file-sync (normally My Library) consumed all pending events,
and keys belonging to other libraries were silently discarded, so files
modified on disk in group libraries were never marked for upload. The
initial and periodic full-scan fallbacks on Windows and Linux were
likewise global, so only the first library ever received them, and
changes made in other libraries while Zotero was closed were never
detected at all.

The sync runner now drains the watcher once per sync session and
immediately runs the modification check on the changed items across all
libraries, recording any changes in the database, and the per-library
storage engines skip the check entirely unless the watcher reports that
the library needs a full scan:

- On all platforms, a library that has never been scanned gets one full
  scan, which also gives libraries one recovery scan for changes dropped
  by affected releases.
- On Windows/Linux, where the watchers only capture events while Zotero
  is running, each library gets a full scan on its first file sync of
  the session, on every manual sync, and daily during background syncs
  (instead of the previous 3-hour interval, which dated from when scans
  were the primary detection mechanism).
- On macOS, libraries scanned since the last FSEvents journal
  discontinuity are tracked in a pref, since the journal -- and
  therefore the validity of previous scans -- survives restarts.

Also:

- Check FSEvents event flags and fall back to full scans when events
  were dropped or coalesced (MustScanSubDirs/UserDropped/KernelDropped/
  EventIdsWrapped), and skip HistoryDone sentinel events
- Disable the watcher for the session and fall back to legacy scanning
  on backend errors, including when the inotify watch limit is reached,
  instead of continuing with silently incomplete coverage
- Prune scan records for deleted libraries, since SQLite can reuse a
  deleted library's libraryID

https://forums.zotero.org/discussion/132120/
2026-06-10 22:26:13 -04:00
Dan Stillman
de1bf1cec1 Update Mac Word plugin install flow for macOS 27 Golden Gate
On macOS 27 and later, the installer gets access to the Word startup
folder via a folder-selection dialog rather than an OS permission
prompt, so support an adjusted banner message and add strings for the
folder dialog.
2026-06-10 15:06:01 -04:00
Dan Stillman
4e3baf09a0 Add okButtonLabel support to FilePicker
Maps to nsIFilePicker.okButtonLabel, which customizes the label of the
button used to accept the dialog, where supported by the platform.
2026-06-10 15:06:01 -04:00
Dan Stillman
fde4086e08 Show file-access error when storage directory can't be cleared on download
createDirectoryForItem() wipes and recreates the item's storage
directory before moving in a downloaded file. If removeDir() failed
(e.g., a locked file on Windows), the error bubbled up to zfs.js and
became the generic sync error. Route it through checkFileAccessError()
instead, so the user gets the actionable locked-file message and a
Show Parent Directory button.

https://forums.zotero.org/discussion/132097/
2026-06-10 08:07:32 -04:00
Adomas Venčkauskas
3e7030a642 Update Word for Windows submodule
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-06-10 12:31:08 +03:00
abaevbog
fa3e0f683f
Citation dialog: display preview of the citation (#5916)
In Add/Edit Citation mode, display a preview of the citation
in the bottom section. The section can be hidden/displayed
via the toggle in the right corner.

Remove io.preview from editor instance, so that citation
dialog knows not to show the preview even if the preference
is set.

A minor refactor to have resizeWindow() resolve
when the animation is fully over, and clear minHeight
on window in list mode before resizing, restoring
it when resizing animation is done, same as in library mode.
It allows us to fully expand the window in list mode before
showing the preview.

Fixes: zotero#5910
2026-06-09 12:35:30 -04:00
Abe Jellinek
dcf5010415
Info box: Optimize rendering of many creator rows (#5939)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-06-08 15:43:32 -04:00
Dan Stillman
31d9e89165 Fix item tree focus checks broken by view-specific tree IDs
The item tree's DOM id carries a view-specific suffix (e.g.
"item-tree-main-default", "item-tree-main-recentlyRead"), but three
call sites compared against a single hardcoded "item-tree-main":

- Collection highlighting on Ctrl/Option (zoteroPane.js) -- match on
  the "item-tree-main" prefix to cover all views. This restores
  highlighting in Recently Read, where it silently failed.
- Focusing the items list after Add Item by Identifier (lookup.js) --
  use the current view's tree id instead of a literal that resolved to
  null and threw.
- Shift-Tab from the item tree to the toolbar (zoteroPane.js) -- key the
  actionsMap on the current view's tree id.

Add a test confirming focus lands on the items list after a lookup.

https://forums.zotero.org/discussion/130968/collection-of-selected-papers-is-not-highlighted-in-recently-read-panel
2026-06-08 13:21:15 -04:00
Dan Stillman
d0507e02c8
Restore -default suffix on item tree column-prefs keys (#5941)
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
The earlier fix (ef7896ab5f) migrated 9.0.x's "<id>-default" prefs
forward to the refactor's new "<id>" key, but that left 9.0.x and 10
beta keying differently in treePrefs.json, so switching between versions
always reset 9.0.x to defaults.

Put the `-default` suffix back so Z9 and Z10 read and write the same
key, and migrate any unsuffixed `<id>` data written by an earlier beta
back to `<id>-default` on first load. The migration runs once per
process and can be removed in a future release once beta users have all
updated.

https://forums.zotero.org/discussion/131653/zotero-10-beta-5-reinitializes-the-columns-in-main-pane
2026-06-05 13:10:01 -04:00
Christopher Holmes
31b3ead557
Abbreviate Atmospheric as Atmos. (#5938)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
example: 
"Atmospheric Chemistry and Physics" is currently incorrectly abbreviated as "Atmospheric Chem. Phys." should be abbreviated "Atmos. Chem. Phys."
2026-06-04 15:38:42 -04:00
Adomas Venčkauskas
615ba3c178
Improve Add/Edit Citation button behaviour when cursor is in a non-citation Zotero field (#5935)
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
When in bibliography, show an appropriate message. When in any other
non-citation Zotero field - just open an empty edit citation dialog.
2026-06-03 03:51:26 -04:00
Abe Jellinek
e029354a35
Fix US-formatted Last Read dates in en-* alias locales (#5934) 2026-06-03 02:35:15 -04:00
Dan Stillman
87901c1c21 Fix error when removing a tag stored in a non-normalized form
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
Zotero.Tags.init() keyed the id cache by the raw tag name from the
database, but getID() and Items._loadTags() both normalize names via
cleanData() (trim + NFC). A tag stored in a non-normalized form (e.g.,
non-NFC or with surrounding whitespace) could therefore never be matched
by getID(), which returned false. When such a tag was removed from an
item, that false was bound as the tagID parameter in the DELETE FROM
itemTags query, throwing "Invalid boolean parameter 1 'false'" and
aborting the save. Since this fired while applying changes during sync,
it persistently blocked sync.

To fix, normalize names in init() to match getID()/_loadTags().

https://forums.zotero.org/discussion/131901/error-report-seems-to-be-preventing-sync
2026-05-31 17:07:13 +02:00
Adomas Venčkauskas
8cf5c9b3d1 Fix ctrl-arrow navigation in item tree with no selection causing a select
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
Regression 4a003049
2026-05-28 15:17:45 +03:00
Adomas Venčkauskas
c838e9b29e Fix double-indent on collection tree (regression 5ca1fbb1)
Closes #5915
2026-05-28 15:17:45 +03:00
Adomas Venčkauskas
8277277948 Make Item Pane display the item count on Zotero startup 2026-05-28 15:17:45 +03:00
Adomas Venčkauskas
786960079b Fix item pane lagging 'items in this view' count by one view
Closes #5913
2026-05-28 15:17:45 +03:00
Dan Stillman
70c4cadab1 Fix full-text indexing failing silently after periodic vacuum or backup
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
vacuum() and the APFS-cloning offline backup path both close and
reopen the SQLite connection, which drops all ATTACHed databases --
including the in-memory "indexing" alias used for the fulltextWords
scratch table set up in Fulltext.init(). Once the connection is
reopened, indexing queries fail with "no such table:
indexing.fulltextWords", and indexItems()'s ignoreErrors path
routes the error to logError(), so indexing silently stops working
for the rest of the session.

Add an onConnect() hook on Zotero.DBConnection for per-connection
state that doesn't persist across reopens, and use it from
Fulltext.init() to re-attach the indexing DB on each reconnect.

Regression sources:

- 67288047f3 ("Use APFS cloning for file copies on macOS") flips
  online idle backups to offline-with-clone on APFS, so every idle
  backup interval (24h default) closes and reopens the connection.
  Affects Mac users on APFS.
- b27c4cb023 ("Enable SQLite WAL mode and add periodic VACUUM
  INTO") adds the vacuum path, which closes and reopens on the
  first idle period in a session that passes the freelist/time
  gates. Affects all users with sufficient DB churn, roughly once
  per 14 days.

https://forums.zotero.org/discussion/131576/debug-id-d848621212-indexing-of-pdfs-fail-zotero-9-0-3
https://forums.zotero.org/discussion/131718/possible-bug-regression-report-search-unusable-on-macos-zotero-9-0-3-with-large-library
2026-05-22 10:59:43 -04:00
Abe Jellinek
bf8c6f2027 Revert "Read Aloud: Fix 402 handling"
This reverts commit a692ca3493.

Not necessary on main.
2026-05-22 10:27:40 -04:00
Dan Stillman
cfd5596814 Disable locale selector when parent style specifies default-locale
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
For a dependent CSL style with no default-locale of its own, citeproc-js
parses the parent's XML and silently uses the parent's default-locale
over any user-selected locale, making the locale dropdown a no-op. Add
a Zotero.Style.effectiveLocale getter that falls back to the parent's
locale, and use it in updateLocaleList and the locale-selector custom
element to disable the dropdown in that case.

https://forums.zotero.org/discussion/comment/512891/#Comment_512891
2026-05-21 22:32:23 -04:00
Abe Jellinek
a692ca3493 Read Aloud: Fix 402 handling 2026-05-21 15:10:32 -04:00
Dan Stillman
9ca79a2cb1 Update Mac and Linux builds to Firefox 140.11.0esr 2026-05-21 10:43:53 -04:00
Dan Stillman
d2631cc614 Don't mark group attachment as unsynced when opened
For group items, attachmentLastRead is stored in a synced setting in My
Library rather than on the item itself, but since we didn't pass
skipSyncedUpdate, the item was being marked as needing sync, resulting
in a permission-denied error in read-only libraries.

https://forums.zotero.org/discussion/131717/opening-pdf-in-read-only-group-library-triggers-permission-denied-on-sync
2026-05-21 09:58:34 -04:00
Dan Stillman
ad4caa8688 Let sync API client handle its own 429/Retry-After throttling
The general HTTP layer's automatic 429/Retry-After retry only retries
the one failed request, but the sync layer wants to pause its entire
batch of concurrent requests via concurrentCaller.pause(). Add a
noRetryOnThrottle option to Zotero.HTTP.request() that throws on 429 or
503 with Retry-After so the caller can apply its own throttling, and
have syncAPIClient set it. Extend sync's catch block to also honor
Retry-After on 503 (previously only invoked via _check429).
2026-05-21 09:15:04 -04:00
Dan Stillman
37bf9d0965 Trigger full sync when server library version goes backward
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
When a user's account is deleted and then undeleted on the server, the
dataserver creates a fresh shardLibraries row at a low version, while the
client still has a much higher local library version. Subsequent syncs
then throw "_libraryVersion cannot decrease" and the user is stuck.

Tag the cannot-decrease errors from the library version setters with a
named error, catch it in Engine.start(), reset libraryVersion and
storageVersion to -1, and restart. The retry hits the existing
libraryVersion == -1 branch and runs _fullSync(), which re-uploads the
local library against the recreated server library.
2026-05-20 23:11:35 -04:00
Dan Stillman
f81e7cb5da Handle 429 and Retry-After in general HTTP layer
Previously only 503 + Retry-After was retried automatically; 429 was
handled only inside the sync API client. Extend _retryOnServerError()
to also retry on 429, honoring Retry-After on both 429 and 503 and
falling back to the existing exponential backoff otherwise.
2026-05-20 15:08:52 -04:00
abaevbog
8f44172342
citation dlg: sort libraries by cited count (#5925)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
In list mode, sort libraries by the count of their items
cited in the current document, falling back to alphabetic sorting
when counts are equal, with "My Library" always getting
priority over other groups.

Fixes: #5924
2026-05-19 15:40:55 -04:00
Abe Jellinek
b7437faf55 Convert ItemPane{SectionElement,Container}Base to ES modules
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-05-18 15:48:31 -04:00
Abe Jellinek
c2a9ea2687 Add Abstract section to merge pane with value chooser 2026-05-18 15:48:31 -04:00
abaevbog
5c232d65c0
Citation dialog: display first run guidance panel when the first item is added vs on initial open (#5917)
Display the first run guidance panel when the first
item is added to the citation vs on initial open.
2026-05-18 14:28:32 -04:00
Dan Stillman
596882980d Update reader submodule 2026-05-18 14:19:53 -04:00
Abe Jellinek
714d4416f8 Suppress Crash Reports and RemoteSettings console spam
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
2026-05-13 14:40:51 -04:00
Abe Jellinek
ba7e036582 Devtools: Fix profiler (Performance tab)
Fixes #5111
2026-05-13 14:01:55 -04:00
Adomas Venčkauskas
431e1dabde Fix first-column sizing after tree refactor. Closes #5908
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-05-12 10:35:05 +03:00
Martynas Bagdonas
789c83249c Avoid selecting tabs queued for close
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
When closing multiple tabs, close() could choose another tab from the same close set as the replacement selection. If that tab was an unloaded note, it could load during shutdown or bulk close and open separately. Choose only from tabs that will remain open.

Fixes https://forums.zotero.org/discussion/131203/closing-zotero-with-a-note-opened-as-a-tab-opens-the-note-in-a-separate-window
2026-05-11 19:54:11 +03:00
Dan Stillman
504447de41 Close advanced search window in collectionViewItemTree notify() test
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
The test opened an advanced search window but never closed it, which
likely caused the intermittent failures in the ZoteroPane focus() test
"should shift-tab across the zotero pane".
2026-05-07 14:25:58 -04:00
Dan Stillman
ddf2419e78 Update Windows builds with Word template compatibility fix from #5904
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-05-06 10:51:13 -04:00
Adomas Venčkauskas
d66682b04e
Undo Mozilla changes that allow non-UTF-16 command line args (#5904)
To allow old Word Zotero.dotm to continue to work
2026-05-06 10:27:13 -04:00
windingwind
fda72a3434
Fix beta build plugin max version compatibility check (#5903)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Ignore persisted compatibility flag from previous app version in non-stable releases to fix disabled for wrongly using old flag
Ignore max version compatibility for plugin update check on non-stable releases
Relevant: https://forums.zotero.org/discussion/131096/

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-05-05 14:26:50 -04:00
Wuy
575dc5351c Fix reader cleanup on tab close and event listener removal 2026-05-05 14:30:26 +03:00
Mi Ramon
4b2c0518f1
Add File menu options to note tabs (#5876)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Show in Library and Close

And standardize `showInLibrary()` as a ZoteroPane function
2026-05-04 15:37:32 -04:00
Dan Stillman
99228a6c76 Disallow changing item type to/from attachment, note, or annotation
The UI doesn't allow this, but plugins could do it, leading to sync
errors. (Can't do much for direct DB access.)

https://forums.zotero.org/discussion/131286/67734299-error-happens-in-my-zotero-plz-help-me
2026-05-04 15:30:27 -04:00
Mynacol
ede90ac04a
Make icons directory during build (#5889) 2026-05-04 15:27:03 -04:00
Abe Jellinek
fc37e524fe
Fix Find Full Text captcha loop on ScienceDirect (#5902) 2026-05-04 15:23:43 -04:00
Dan Stillman
8d59331d43 Fix quick search not expanding annotation rows after item tree refactor
https://forums.zotero.org/discussion/131294/quick-search-does-not-expand-annotations-10-0-beta4
2026-05-04 13:52:37 -04:00
abaevbog
3c53118f2c
Citation dialog: resizable width of collectionTree (#5892)
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
2026-04-30 14:43:54 -04:00
Dan Stillman
ef7896ab5f Fix lost item tree column choices after item tree refactor
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Before the refactor, main library column prefs were keyed under
"<id>-default", because the visibilityGroup getter returns 'default'
(truthy) for the main library. The refactor changed the suffix logic to
only append non-default groups, leaving existing prefs orphaned at
"<id>-default" while the new code reads/writes "<id>".

Fall back to the legacy key on load when the new key is missing or an
empty object (which can be written out by an unmodified post-refactor
build that flushed prefs on a visibility-group switch, like switching to
feeds), and drop the legacy key on the next write.

https://forums.zotero.org/discussion/131202/lost-columns-displayed-choice-in-the-items-tree-after-update-to-10-beta-4
2026-04-29 15:25:46 -04:00
Dan Stillman
178f162449 Add test for collection highlighting on Ctrl/Option
Covers the regression fixed by a7d001fc1d. The prior test for
_setHighlightedRowsCallback() called the callback directly, bypassing
the focus check and the keydown handler.
2026-04-29 15:21:29 -04:00
Dan Stillman
a7d001fc1d Fix stale references to old item tree DOM id
After the item tree refactor, the items tree id became "item-tree-main"
instead of "item-tree-main-default". Update the remaining call sites,
restoring collection highlighting on Ctrl/Option, focusing the item tree
after Add Item by Identifier, and Shift-Tab focus movement from the item
tree to the toolbar (which only still worked due to the native tab
order).
2026-04-29 14:57:02 -04:00
Dan Stillman
36e25d171a Update English strings for Transifex 2026-04-29 14:14:58 -04:00
Dan Stillman
a57cb6276f Prefs: Localize "Data Directory:" label in Advanced pane
Hard-coded as "Data directory:" in the original commit. Move to Fluent
and fix the dara-dir-label typo on the id (and the matching
aria-describedby) while we're here.

https://forums.zotero.org/discussion/130868/untranslated-string-data-directory
https://forums.zotero.org/discussion/131212/translation-not-reflected
2026-04-29 14:14:32 -04:00
Dan Stillman
373accce3b Update locales from Transifex 2026-04-29 12:19:13 -04:00
Dan Stillman
66dc97de33 Update English strings for Transifex 2026-04-29 09:44:57 -04:00
Abe Jellinek
03d94060b4 Replace chai-as-promised assertions in server_connector tests
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-04-28 13:33:58 -04:00
Abe Jellinek
13cb8d99b6 Server: Remove /connector/request endpoint
Never used.
2026-04-28 13:05:31 -04:00
Abe Jellinek
82b31574a8 Server: Test new browser detection behavior 2026-04-28 13:05:31 -04:00
Abe Jellinek
bc2ede0459 Server: Close request without response when browser is detected 2026-04-28 13:05:30 -04:00
Abe Jellinek
2e19017d0e Server: Improve browser detection 2026-04-28 13:05:30 -04:00
Abe Jellinek
2f38926da5 CasePreservingHeaders: Fix some Headers instances not being recognized
I guess the Headers class accessible from test code is different from
the Headers class in http.js (for the purposes of instanceof, at least).
2026-04-28 13:05:30 -04:00
Dan Stillman
5dc817db44 Validate Host header in connector server
Restores a check that was originally added in 10ad4c33 but lost in the
June 2024 server rewrite (#5148)
2026-04-28 13:00:40 -04:00
Dan Stillman
fd812070b6 Fix WebDAV download auth for usernames with special characters
HTTP.download() was rewritten in 0fe31b0f04 to use fetch() and build the
Basic auth header itself via btoa(username + ':' + password), but the
username and password come from nsIURI.username/password, which are
percent-encoded. As a result, a username like "user@example.com" was
sent as "user%40example.com", causing a 401 on every WebDAV download for
any user with @, :, space, etc. in their username. Other request types
were unaffected because they go through xmlhttp.open(method, url, true,
username, password), which decodes internally.

Decode username and password in _parseURI() so the values returned can
be used directly for Basic auth (and as a side effect, fix the other
display/use sites that were getting the percent-encoded form).

https://forums.zotero.org/discussion/131174/zotero-10-betas-1-2-3-cant-download-from-webdav-http-401
2026-04-28 13:00:40 -04:00
windingwind
e13e85b2e5
Fix plugin FTL registration (#5896)
Use one registry to avoid competing of language detect between plugins,
as resources declared as optional is identical to resource missing and thus fails the check and returns null for the string (https://searchfox.org/mozilla-esr140/source/intl/l10n/rust/l10nregistry-rs/src/registry/asynchronous.rs#140)
Register the plugin FTL for all languages Zotero supports with proper fallback logic so that even resources are declared as required, the check doesn't fail when plugin doesn't provide the resource.
Fix Zotero.File.getResourceAsync to use NetUtil channel to handle jar: url with `@`.
2026-04-28 12:00:04 -04:00
Dan Stillman
7ca8242a3e Update reader submodule
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-04-27 16:05:48 -04:00
Dan Stillman
a43d6a5d37 Fix erroneous data dir reuse in new profile on Windows
In a newly created profile, we check whether another profile is using
the default data directory, and if so, we create a new data directory
named after the profile (e.g., "Zotero Work"). But on Windows, paths in
prefs.js are stored with escaped backslaches (C:\\Users\\foo\\Zotero),
so searching for the JS string with literal single backslashes
(C:\Users\foo\Zotero) always failed, we would conclude that the default
dir was unused, and we would reuse the existing database.

To fix, escape backslashes in dataDir before the substring check.
Mac/Linux paths have no backslashes so this is a no-op there.
2026-04-27 16:04:52 -04:00
Dan Stillman
1ff2f640eb Remove Zotero for Firefox data directory migration code
The new-install branch in DataDirectory.init() read prefs.js from the
default Firefox profile to detect a pre-2017 dataDir setting, and the
read was unwrapped, making prefs.js access errors fatal at startup [1].
We could add a try/catch, but after nine years, it's probably safe to
just remove the migration. If anyone is returning from >9 years in the
wilderness and they still want old unsynced Zotero data, they can
manually move their data to the default location.

[1] https://forums.zotero.org/discussion/131176/installation-error-accessing-mozillas-pref-js-see-msg-pls
2026-04-27 16:04:52 -04:00
Mi Ramon
462357e49e
"Updating add-ons" → "Updating plugins" (#5893) 2026-04-27 14:50:51 -04:00
Adomas Venčkauskas
777d01b5c2 Item Tree: Column visibility logic improvements
Columns now have properties: `enabledIn`, `disabledIn` and `defaultIn`,
corresponding to column picker availability and default visibility. The
properties now filter based on attached collection view type instead of
visibilityGroup.

Visibility groups are for views where we want distinct column sets to
persist, like the feeds view.

Collection type properties are used to specify which columns are
available for a given type, regardless of whether it's in a different
visibility group or not.
2026-04-27 14:44:39 -04:00
Adomas Venčkauskas
5ca1fbb167 Item tree refactor megacommit
Split ItemTree megaclass into:
- ItemTree - concerned with drawing the virtualized table container and
  column interaction
- ItemTreeRowProvider - provides rows and issues notifications for
  render updates
- ItemTreeRow and subclasses - contains row-specific data and rendering
  logic
- CollectionViewItemTree and its accompanying classes - a version of
  ItemTree that renders items attached to a given Collection or
  CollectionView (CollectionTreeRow).

Various improvements in logic and rendering, separation of concerns.
2026-04-27 14:44:39 -04:00
Dan Stillman
6b42f9f9c0 Update translate submodule
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
2026-04-23 15:34:28 -04:00
Abe Jellinek
d7920e320b cookieContextId -> userContextId 2026-04-23 15:34:28 -04:00
Abe Jellinek
f0474fa250 Add support for clearing challenge in browser during translation 2026-04-23 15:34:28 -04:00
Dan Stillman
df6037ce0a Update Windows build to Firefox 140.10.0esr 2026-04-23 13:43:12 -04:00
Dan Stillman
24e16c0aad extract_xul_dll: Process whichever arch ZIPs are present 2026-04-23 13:38:26 -04:00
Dan Stillman
2d14f33de2 Windows build: Copy custom Firefox ZIP back to app/win/ 2026-04-23 13:38:26 -04:00
Dan Stillman
de69835419 Update Mac and Linux builds to Firefox 140.10.0esr 2026-04-23 11:40:55 -04:00
Dan Stillman
0b15b794e6 Offer to reset data directory on SQLite I/O error at startup
When a custom data directory is on a network share or in a cloud storage
folder, SQLite can fail to open with NS_ERROR_STORAGE_IOERR and the user
previously had to manually edit prefs.js to recover. Show a "Use Default
Location" button in the startup error dialog that resets the data
directory pref and restarts.

Addresses #4860
2026-04-23 11:38:29 -04:00
Adomas Venčkauskas
e7ed0b2881 Update Word for Windows submodule 2026-04-23 09:09:06 +03:00
Dan Stillman
a487dd9eef Rename pdf-worker submodule to document-worker
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
Repo moved to zotero/document-worker on GitHub

After pulling, run:

    git submodule sync
    git submodule update --init document-worker

If an old `pdf-worker/` directory is left behind, it can be removed manually.
2026-04-22 15:50:02 -04:00
Adomas Venčkauskas
9abd13fd22 Fix ZoteroPane tests failing intermittently 2026-04-22 10:16:05 +03:00
Dan Stillman
e1cd83214f CI: Deploy from version branches (9.0, 10.0, etc.)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
And drop reference to fx128 branch
2026-04-21 14:41:21 -04:00
windingwind
fa9a54b773
Fix plugin prefs.js change not taking effect after update (#5888)
Do not use cache for loading the prefs.js
2026-04-21 10:44:44 -04:00
Abe Jellinek
a835136d3b Update reader submodule
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-04-20 12:00:05 -04:00
Abe Jellinek
89ee4c21c0 Read Aloud: Fix network error string key 2026-04-20 11:08:19 -04:00
Adomas Venčkauskas
26816b03e2 Fix bad utilities submodule commit 2026-04-20 09:35:46 +03:00
Adomas Venčkauskas
07906931c1 Update utilities submodule 2026-04-20 09:31:23 +03:00
Dan Stillman
c52a6151dc Fix ZoteroPane.viewAttachment() tests after HTTP.download() rewrite
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
2026-04-17 15:36:21 -04:00
Dan Stillman
ce424c6b67 Fix attachment tests after HTTP.download() rewrite
Add a spy on Zotero.HTTP.download and update call-count assertions to
split between request() and download() calls. Previously download()
delegated to request(), so a single requestStub tracked everything.
2026-04-17 15:21:25 -04:00
Abe Jellinek
cc76207a23
Remove CookieSandbox, add new utility for cookie isolation (#5853) 2026-04-17 14:23:04 -04:00
Tom Najdek
32e43cb8f1
Fix tag paste to include existing field text at cursor position (#5873) 2026-04-17 14:20:52 -04:00
Dan Stillman
00ece3db68 Update reader submodule 2026-04-17 14:18:21 -04:00
Dan Stillman
0fe31b0f04 Rewrite HTTP.download() to stream via fetch() + ReadableStream
- Replace XHR-based download with fetch() + response.body streaming,
  writing chunks to disk via IOUtils instead of buffering the entire
  response in memory
- Separate out the retry and URL-parsing logic so it can be reused
  between request() and download()
- Split the ZFS download code into a request() with `followRedirects:
  false` to get the metadata headers and a separate download() to
  download the file

Fixes #5476, Downloading of large files is broken
2026-04-17 14:17:40 -04:00
Dan Stillman
45596e70e9 Work around Fluent issue leaving "{$version}" in post-upgrade banner
Some users see the literal "{$version}" placeholder in the banner,
likely due to some Fluent quirk triggered by plugin localizations.
(Missing localizations? Missing strings?) After translation, check the
version span and substitute manually if needed.

https://forums.zotero.org/discussion/129171/version-variable-is-displayed-in-zotero-8-upgrade-banner/p1
https://forums.zotero.org/discussion/130864/just-upgraded-to-zotero-9-on-debian-through-check-for-updates-and/p1
2026-04-17 11:32:22 -04:00
Dan Stillman
11c75ae6e5 Update reader submodule 2026-04-17 11:14:39 -04:00
Dan Stillman
c2c66795d2 Add some extra asserts to debug test flakiness in CI 2026-04-17 10:47:04 -04:00
windingwind
55672ba933
Fix removal logic for menu added by MenuManager (#5886)
Add the registered main key to menu class list so that they can be found when plugin is removed.
Add test for the fix.
2026-04-17 10:13:36 -04:00
Abe Jellinek
488fd29978 Read Aloud: Handle failure when trying to pause other tab
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
https://forums.zotero.org/discussion/130920/tts-breaks-after-automatic-theme-changes
2026-04-16 16:26:45 -04:00
Dan Stillman
d2d4451165 Fix WebDAV tests for Account pane rename
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-04-14 23:56:23 -04:00
Dan Stillman
a9fe8e3b62 Fix Account preferences tests
And update suite name
2026-04-14 22:46:37 -04:00
Dan Stillman
acfb7c7915 Rename zotero-prefpane-sync ID to zotero-prefpane-account
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-04-14 16:10:50 -04:00
Dan Stillman
7334080693 Rename sync preference files to account 2026-04-14 16:10:50 -04:00
Dan Stillman
c89abbc5be Rename Sync preferences pane to Account
- Restructure the pane layout: account info (username, email,
  login/logout) at the top, followed by a separate "Sync" section with
  an intro description and "Learn more" link, data sync options, and
  file syncing
- Migrate hardcoded "Zotero storage" strings to FTL and fix
  capitalization
- Rename the "Zotero" storage dropdown to "Zotero Storage".
2026-04-14 16:10:50 -04:00
Mi Ramon
676d609d0d
Add a icon for "Relate Items" (#5884) 2026-04-14 15:46:54 -04:00
Tom Najdek
ad4bb17db9
Fix CSS specificity causing tag swatches to render in reverse order 2026-04-14 11:38:15 +02:00
abaevbog
683ecf1620
fix related item sometimes not selected on click (#5885)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
https://forums.zotero.org/discussion/130887/clicking-related-fails-zotero-report-id-1418809492
2026-04-13 13:09:53 -04:00
Dan Stillman
56ef61e45b Store vacuum.lastTime as seconds, not milliseconds
Also drop the default pref, since this is just internal state
2026-04-13 12:39:51 -04:00
Dan Stillman
f20eb5b566 Fix crash when annotation item is missing itemAnnotations row
Don't throw when updateDisplayTitle() retry fails after loading
annotation data for a corrupt item. Also add an integrity check to
delete annotation items without itemAnnotations rows.

Fixes https://forums.zotero.org/discussion/130872
2026-04-13 12:29:57 -04:00
Dan Stillman
62815bdd00 Update version to 10.0
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
2026-04-10 13:41:18 -04:00
Dan Stillman
1ed1fab55b Switch to 9.0 branch for 9.0 releases 2026-04-10 13:40:13 -04:00
Dan Stillman
16b63a97dd Remove concurrent backup tests
These tested racing an offline backup against an in-progress online
backup, which wouldn't happen in practice (schema backups run at startup
before the idle observer, and someone is very unlikely to perform a DB
integrity check immediately after returning from an idle that triggered
a backup). The tests relied on fragile timing and a shared tmp file,
causing failures on Linux after the WAL checkpoint change. The tests
were already skipped on macOS after switching to APFS clones.
2026-04-10 13:35:57 -04:00
Dan Stillman
b27c4cb023 Enable SQLite WAL mode and add periodic VACUUM INTO
- Switch journal mode from DELETE to WAL for better write performance.
  With EXCLUSIVE locking mode, SQLite uses heap memory for the WAL
  index, avoiding an -shm file. Set synchronous=NORMAL (matching what
  Mozilla uses for Places). Checkpoint WAL on database close so the
  .sqlite file has all data (for copies or backups).
- Add periodic database compaction on idle (after DB backup) using
  VACUUM INTO and do an atomic file swap back to zotero.sqlite if no
  writes occurred during the operation. Check if vacuuming is needed
  based on time interval (default 14 days) and freelist ratio (default
  10% threshold).
- Disable auto_vacuum, which causes fragmentation and is unnecessary
  with periodic VACUUM
- Remove the VACUUM call from the integrity check, which was always just
  an awkward hack to let people trigger a VACUUM without having an
  explicit button

Closes #652
2026-04-10 13:19:32 -04:00
Dan Stillman
e22e2cd9dd Fix hardcoded version in post-upgrade banner 2026-04-10 11:21:20 -04:00
Abe Jellinek
3e04f5e3e7 Enable Read Aloud in stable builds
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-04-09 15:35:02 -04:00
Dan Stillman
a0198e2571 Support pinned incrementals from deploy server
Fetch pinned-incrementals-{platform} from the deploy server and merge
with the normal last-N incrementals list. This ensures incremental MARs
are always built from key versions (e.g., last release of each major
version) regardless of how many newer versions have been released.
2026-04-09 14:35:18 -04:00
Dan Stillman
e96d65f0ed Fix manage_incrementals on Cygwin
Remove Windows path conversion that was needed for aws s3 cp but
breaks scp (which interprets the drive letter colon as a hostname).
2026-04-09 12:32:03 -04:00
Dan Stillman
1a89239a13 Separate build and deploy for release scripts
Split build_and_deploy into build_for_deploy (builds, uploads to S3,
rsyncs manifests) and a deploy script in client-downloads (updates
updates JSON, updates incrementals, runs deploy command).

Release scripts support -b flag for build-only (deploy later manually).
Beta/dev/test scripts build and deploy automatically via SSH.

Move incrementals files from S3 to deploy server. Update DEPLOY_PATH to
root of client-downloads so manifests and deploy script paths are
derived from it.
2026-04-09 11:37:05 -04:00
Dan Stillman
a1baa68006 Update reader submodule 2026-04-09 11:30:51 -04:00
Dan Stillman
f82efc766d Fix Added/Modified By visibility when switching between library types
The `_getColumns()` column cache key didn't account for library type, so
switching between personal and group libraries reused cached columns
from the previous library type. This caused `groupLibrariesOnly` columns
(Added By, Modified By) to be missing in group libraries or incorrectly
shown in personal libraries.

Additionally, `VirtualizedTable.componentDidUpdate()` only recreated its
internal `Columns` object when the id prop changed, not when the columns
prop changed, so even with correct column rebuilding in `ItemTree`, the
table wouldn't pick up the new column list.

https://forums.zotero.org/discussion/130521/added-by-column-missing
2026-04-09 11:29:02 -04:00
Dan Stillman
ea230faa26 Add deployment script for 9.0
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
2026-04-08 14:58:25 -04:00
Dan Stillman
c3d8058794 Update locales from Transifex 2026-04-08 14:49:52 -04:00
Dan Stillman
117fa1dddb Update citeproc.js to include Juris-M/citeproc-js#275 2026-04-08 11:19:01 -04:00
Abe Jellinek
4b14ca9af4
Set Recently Read threshold relative to most recent item (#5878)
Some checks are pending
CI / Build, Upload, Test (push) Waiting to run
And persist cutoff within session
2026-04-07 14:10:18 -04:00
Dan Stillman
91e82566ff Update locales from Transifex 2026-04-07 11:00:22 -04:00
Dan Stillman
70047b6fd6 Update English strings for Transifex 2026-04-07 11:00:22 -04:00
Mi Ramon
5671a724e8
Fix broken Find option under Edit menu in reader (#5877) 2026-04-07 10:38:50 -04:00
Dan Stillman
a3aa2bb5c7 Add general-more-information FTL string 2026-04-07 10:33:11 -04:00
Dan Stillman
3f0de027a2 Auto-resolve lastRead sync conflicts by picking the most recent value 2026-04-07 10:29:25 -04:00
Dan Stillman
4398a2bcee Update reader submodule 2026-04-03 15:49:23 -04:00
Dan Stillman
67288047f3 Use APFS cloning for file copies on macOS
Add Zotero.File.copyFile(), which uses clonefile() on APFS with a
fallback to IOUtils.copy(), and use it for all significant file copies.
APFS is detected and cached for each parent folder via
Zotero.File.isAPFS(), which uses statfs().

On APFS, all database backups now use the offline (close/clone/reopen)
path instead of the SQLite online backup API. Cloning is nearly
instant, and backup files share disk blocks via copy-on-write, saving
potentially gigabytes of space.

Closes #5330
2026-04-03 14:33:21 -04:00
Dan Stillman
b11d776b36 Update locales from Transifex 2026-04-03 13:19:02 -04:00
Dan Stillman
92c876e47d Fix switching-accounts error on loginCancelled from stream server
Only resolve for known events, matching the polling behavior.
2026-04-03 12:17:03 -04:00
Dan Stillman
d5ee350081 Disable snapshot saving in feed item translation itest
To try to fix intermittent CI errors
2026-04-03 09:58:47 -04:00
Dan Stillman
5a347f4be5 Update style test for NLM/Vancouver style changes 2026-04-02 16:11:47 -04:00
Dan Stillman
a602d21258 Remove erroneous license header 2026-04-02 15:57:19 -04:00
Dan Stillman
7f9fdc0786 Update translators, styles, CSL locales, and renamed-styles.json 2026-04-02 15:51:43 -04:00
Dan Stillman
2d88a3f181 Update English strings for Transifex 2026-04-02 15:48:14 -04:00
Dan Stillman
0ff3ec6a1b Move file renaming strings to fileRenaming.ftl
Move file-renaming-* and rename-files-preview-* strings from zotero.ftl
to a dedicated fileRenaming.ftl. Rename rename-files-preview-* keys to
file-renaming-preview-window-* for consistency.
2026-04-02 15:48:14 -04:00
Dan Stillman
c2ce0af87a Update Windows build to Firefox 140.9.0esr 2026-04-02 15:20:52 -04:00
Dan Stillman
92db62df0a Update reader submodule 2026-04-02 15:16:23 -04:00
Tom Najdek
862573eabc
Per-group file renaming settings (#5862)
Also:

- Add `isAdmin` property to libraries
 
---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-04-02 15:15:06 -04:00
Abe Jellinek
41b5442952 Add string for Read Aloud from Here 2026-04-02 14:42:42 -04:00
abaevbog
c6a95fd5b7
citation dlg: fix CI test breakage (#5870)
- prevent the focus handler from resetting the cached
items in search handler
- wait for search to finish after changing dialog type
in "should not display empty note child rows" test
2026-04-02 11:27:43 -04:00
Dan Stillman
842115ff7e Update Mac and Linux builds to Firefox 140.9.0esr 2026-04-02 11:20:52 -04:00
Dan Stillman
8effdacf65 Update English strings for Transifex 2026-04-01 14:57:52 -04:00
Dan Stillman
d2a01d02f8 Move zotero-text-link margin reset to base _textLink.scss
Not sure why this was only in preferences.scss
2026-04-01 14:53:46 -04:00
abaevbog
6f5f221db0
citation dlg: no irrelevant child rows in itemTree (#5863)
Pass filterChildItems to itemTree from citationDialog
to filter out unwanted child rows before adding them
in toggleOpenState.

In annotations mode, do not add child notes and
attachments without annotations
In add-note mode, only keep child notes that have content
2026-04-01 14:05:40 -04:00
Bogdan Abaev
df18ac4e1f citation dlg: updated icons
- updated annotations dialog type icons for dark and light mode
- use checkmark instead of right arrow for "accept" button
- fix incorrect sizing of "accept" and "cancel" icons
2026-03-31 14:53:11 -04:00
Bogdan Abaev
0e7ed13a43 citation dlg: variable min-width for list/library
- Allow different min-width for list vs library layouts.
In library mode, we need at least 1000px to have a functional
itemTree, especially when sidepane is present. But for list
mode, 1000px is very wide, so min-width should be 800px.
Increased min-height of itemTree from 200px to 400px
- When the dialog closes, store the width/height of
the current dialog's layout. Then, when the dialog opens
next time in that layout, set window's width/height based on last saved
params. That way, one can open a citation dialog in list
mode at 800px, then open annotations dialog (always library mode)
at 1000px, and then have the citation dialog in list mode open
again at its intended 800px.
- If one switches from library to list mode, add a smooth
window resizing animation to shrink diagonally from current width
to 800px. If one switches from list to library mode,
window smoothly resizes to 1000px min-width. This is
not applied to linux where win resizing behavior is a bit jumpy.
2026-03-31 14:53:11 -04:00
Tom Najdek
f3ecf285b3
Fix flakiness in "should shift-tab across the zotero pane" test 2026-03-31 01:38:28 +02:00
Mi Ramon
293cd25fb6
fix bg color for highlighted and selected rows in collection tree (#5855)
regression from a365465
2026-03-30 16:50:43 -04:00
Mi Ramon
5f942684b6
fix: indentation for some settings items is not taking effect on Windows (#5867) 2026-03-30 16:49:18 -04:00
Tom Najdek
2ad4618efb
Exclude ©, ®, and ™ from emoji detection regex 2026-03-29 17:51:27 +02:00
Abe Jellinek
fb8389d654
Read Aloud: Persist last-read position in synced setting (#5860) 2026-03-27 15:22:02 -04:00
Dan Stillman
391e8497a6 Handle per-object 403 on settings upload and reset to remote version
If the server returns a write report with 403 for admin-only settings,
mark successful settings as synced and reset rejected settings to
their remote values by re-downloading from the server.

Needed for #5862
2026-03-26 15:54:28 -04:00
Dan Stillman
1267440187 Update reader submodule 2026-03-26 14:23:26 -04:00
Dan Stillman
16380e4a19 Pass includeEmails=1 in /keys request when opening Sync settings 2026-03-26 13:11:26 -04:00
Dan Stillman
6140088b0c Swap order of annotation and note buttons in citation dialog 2026-03-25 13:14:12 -04:00
abaevbog
1d437d88f0
citation dialog: insert annotations followup (#5858)
- fix breakage when initially setting annotation dialog
   mode to library
- on click of a non-annotation item in suggested items,
   select the top-level library if the item could not be selected
   in the initial state of the itemTree
- increase min-width of the dialog to 1000px to ensure
   there's always space for the sidebar.
2026-03-24 21:07:19 -04:00
Dan Stillman
8a0a798672 Update word processor submodules for Add Annotation 2026-03-24 15:38:33 -04:00
Dan Stillman
0e8b195c5a Add tests for image annotation placeholder in notes 2026-03-24 12:09:15 -04:00
Dan Stillman
8167c903f3 Fix WebSocket 4409 error on sync logout
Properly remove the sync streaming subscription before clearing the
API key on logout.
2026-03-24 12:09:15 -04:00
Dan Stillman
322b764091 Show email addresses in sync settings
Fetch emails from /keys response and login session result, persist them
in the local database, and display them in the sync settings for both
logged-in and logged-out-but-linked states.

Also unify the separate logged-out-linked and logged-in containers into
a single layout that toggles elements based on login state.

Requires dataserver and stream-server changes
2026-03-24 12:09:08 -04:00
Dan Stillman
5ac56ae717 Fix removing from Recently Read in read-only group libraries 2026-03-24 11:51:37 -04:00
Dan Stillman
2522be0c17 Add test for removing group item from Recently Read on settings sync 2026-03-24 11:36:43 -04:00
Dan Stillman
a3909af5ab Clear lastRead in Item#fromJSON() when absent from API response
When the server returns an attachment without lastRead (because it was
cleared), fromJSON was not clearing the local value since it only
processes fields present in the JSON. Add explicit check after field
processing to clear lastRead when absent.
2026-03-24 11:36:43 -04:00
Martynas Bagdonas
6998235a31 Allow note creation when image annotation rendering fails
Fixes #5857
2026-03-24 11:56:53 +00:00
abaevbog
81591eb890
ItemTree: + expands all rows one level at a time (#5797)
On + keypress, expand the rows down one level, instead of
expanding all rows at once. If some rows are expanded
and some are collapsed, expand to the deepest level
currently visible.
E.g. if some regular items are already expanded,
expand only remaining collapsed regular items. If there
is a row with expanded attachments, expand all rows.

On - keypress, collapse the rows up one level to be consistent.

https://forums.zotero.org/discussion/129596/optional-annotations-in-item-list
https://forums.zotero.org/discussion/130365/expand-all-shift-in-zoteros-behavior-changed
2026-03-23 15:25:30 -04:00
Bogdan Abaev
6bec3c817e Citation dialog: allow to insert annotations (#5356) 2026-03-23 15:00:22 -04:00
Dan Stillman
587d5cda7e Show placeholder for image annotations without cache image in notes 2026-03-23 14:31:41 -04:00
Dan Stillman
351860273f Fix error creating note from image annotations when PDF file is missing 2026-03-23 14:31:41 -04:00
abaevbog
5823247df9
itembox: fix lost focus on tab from invalid DOI (#5856)
Fix lost focus on tab from DOI field after it is updated
from a valid to an invalid value.

https://github.com/zotero/zotero/pull/5850#discussion_r2976238955
2026-03-23 13:43:23 -04:00
Dan Stillman
d21a5d9cad Use Fluent strings for annotation type names
This also fixes title casing for "Image Annotation"/"Ink Annotation" in
the items list.

https://github.com/zotero/zotero/pull/5356#issuecomment-4112317776
2026-03-23 13:28:45 -04:00
Abe Jellinek
763330d1f4 Prevent attachment preview from bumping lastRead
And also use new _isTransient() to control lastPageIndex update, instead
of overriding _setState() as a no-op in ReaderPreview.
2026-03-23 12:54:00 -04:00
Mi Ramon
6fa5c4b3e3
Add "View Online" for PMID/PMCID (#5850)
resolves: #5845
2026-03-23 12:41:57 -04:00
Dan Stillman
fd7cc84f6b Fix quicksearch in Recently Read to match parent items
Add includeParents to the Recently Read search so parent items are
included in the search scope alongside their child attachments.
2026-03-23 11:22:01 -04:00
Dan Stillman
5bceea7b7b Fix Added By backfill skipping items when batch count doesn't change
Use itemID offset to advance through batches instead of comparing
result counts, which would bail out when the number of remaining items
equaled the previous batch size.
2026-03-23 10:25:34 -04:00
Dan Stillman
46ba21fe75 Add More Information link to Switch Accounts confirmation dialog 2026-03-23 10:25:33 -04:00
Abe Jellinek
8af91b0147
Bump lastRead when reader is closed (#5852) 2026-03-21 13:41:54 -04:00
Dan Stillman
e629a3c456 Update global schema to version 42 to enable lastRead fetching 2026-03-21 13:31:11 -04:00
Dan Stillman
6e5738de0f Try to fix missing Added By for some items
Backfill groupItems rows that have lastModifiedByUserID but null
createdByUserID (not totally sure how), and track completion with a
per-library flag in the settings table so the backfill only runs once
(since createdByUserID could potentially be permanently unavailable).
2026-03-21 12:27:27 -04:00
Dan Stillman
518056f9e7 Notify item tree after backfilling group item users
Fire a modify notifier event after _updateGroupItemUsers() so the
item tree refreshes to show the newly backfilled Added By / Modified By
values. Also change error handling from return to break so partial
progress still triggers the refresh.
2026-03-21 12:21:17 -04:00
Dan Stillman
ba3c3a506f Add test for preserving synced on lastRead setting download 2026-03-20 17:03:23 -04:00
Dan Stillman
e28c97dc56 Fix synced group item marked as unsynced when lastRead setting is applied
When a lastRead synced setting for a group item was downloaded and
applied via AttachmentReadObserver.notify(), the group attachment item
was saved without skipSyncedUpdate, causing it to be marked as synced=0.
On the next sync, this could trigger an upload attempt that failed with
403 for read-only group libraries.
2026-03-20 16:55:02 -04:00
Dan Stillman
7b17187a1a Update reader submodule 2026-03-20 15:46:42 -04:00
Dan Stillman
eb3a136ccd Use new batch setting deletion API endpoint
Use DELETE /settings?settingKey=key1,key2 instead of individual DELETE
per key, with batching matching uploadDeletionBatchSize.
2026-03-20 15:43:03 -04:00
Abe Jellinek
0ade1b9ed5 Show Attachments column by default in Recently Read 2026-03-20 15:19:03 -04:00
Dan Stillman
98d911f6b1 Add "Switch Accounts" flow for logged-out-but-linked state
When a user logs out without removing local data, the sync pane now
shows a third UI state with the linked username, a "Log In" button
(constrained to the same account), and a "Switch Accounts…" button
that shows a hard confirmation dialog requiring the user to type
"remove local data" before proceeding. On confirmation, local data
is reset and the app restarts, reopening the account preferences.
2026-03-20 13:11:52 -04:00
Dan Stillman
883747e372 Improve hard confirmation dialog textbox
- Show confirmation text as placeholder
- Auto-focus the textbox
- Fix border styling
2026-03-20 13:11:38 -04:00
Dan Stillman
35713fefd0 Upload setting deletions during sync
Deleted settings were written to syncDeleteLog but never uploaded. Add
dedicated setting deletion upload in _startUpload() using individual
DELETE requests per setting, since the API doesn't support batch setting
deletion.
2026-03-20 11:25:27 -04:00
Dan Stillman
3ebd26acc0 Fix saving lastRead in read-only library from synced setting
The same skipEditCheck fix from 5dc9705294 but for the synced setting
handler, which updates lastRead when a lastRead setting arrives via
sync for a group attachment.
2026-03-20 11:25:27 -04:00
Dan Stillman
5dc9705294 Fix saving lastRead in read-only library 2026-03-20 08:48:03 -04:00
Dan Stillman
cf7fcd751f Move lastRead column string from .properties to .ftl
Follow-up to 34039991f8
2026-03-19 16:19:52 -04:00
Dan Stillman
1d31ed2508 Fix sync runner test for updated login error button text 2026-03-19 15:48:16 -04:00
Dan Stillman
1502214b5d Web-based login flow
Replaces the login form with a "Log In" button that creates a dataserver
login session and opens the website for logging in. The client polls for
session completion every few seconds and also watches for a notification
from the streaming server.
2026-03-19 15:07:53 -04:00
Abe Jellinek
1f702fc5dc Read Aloud: Add sentence-granularity skip strings 2026-03-19 13:46:44 -04:00
Dan Stillman
c9f893a5a2 Update reader submodule 2026-03-19 12:46:37 -04:00
Martynas Bagdonas
376fba55b6 Update note-editor submodule 2026-03-19 15:57:30 +00:00
Dan Stillman
8401b83cb6 Update dateModified and lastModifiedByUserID when trashing 2026-03-19 11:35:15 -04:00
Dan Stillman
1fad46f983 Disable OA PDF lookups in feed item translation test
Avoid external network requests during test that may have been a source
of intermittent CI failures.
2026-03-19 11:04:22 -04:00
Dan Stillman
5b5b70b499 Show file size in MB instead of KB in ZFS quota warning 2026-03-19 10:38:57 -04:00
Dan Stillman
489a46564f
Add "Remove from Recently Read" (#5847)
- Non-force delete clears `lastRead` on all child attachments (with
  prompt), force delete moves to trash (matching collection behavior)
- Re-run search on item modify in Recently Read view so items disappear
  when lastRead is cleared (also handles sync changes)
2026-03-19 10:30:18 -04:00
Dan Stillman
f0af0bec9d Fix lastModifiedByUserID update overwriting createdByUserID
Use ON CONFLICT to update only lastModifiedByUserID when
createdByUserID isn't being set, rather than REPLACE INTO, which
replaces the entire row.
2026-03-19 00:10:02 -04:00
Dan Stillman
07d99d891f Fix breakage from aaed7ddfc0 in non-standard collectionTreeRow objects
Guard against collectionTreeRow missing isWithinGroup() (e.g., in
Advanced Search)
2026-03-18 23:28:29 -04:00
Dan Stillman
aaed7ddfc0 Add Added By and Modified By columns for group libraries
- Add columns as options in item tree, visible only in group libraries
- Fall back to createdByUserID for Modified By when lastModifiedByUserID
  is not set
- Update lastModifiedByUserID on local saves when dateModified changes
- Fix backfill in _updateGroupItemUsers() to process all batches
- Update formatColumnName() to support Fluent string keys

Closes #233
2026-03-18 23:19:11 -04:00
abaevbog
288fa8e401
hide recently-read pseudo-collection during filter (#5849)
Same way as unfiled, duplicates and trash pseudo-collections
are hidden
2026-03-18 12:05:16 -04:00
Tom Najdek
085a6fa5e0
Move search condition localization strings to Fluent (#5848) 2026-03-18 11:25:58 -04:00
Dan Stillman
d20cac39c9 Fix unhandled promise rejections in ConcurrentCaller when clearing queue
When stop() or stopOnError rejected queued task promises, the rejection
handlers (via Promise.allSettled) weren't attached yet, causing Mozilla
to report each one as "uncaught exception: Object". Add no-op catch
handlers before rejecting so the runtime knows they'll be handled
downstream. Also fix stopOnError path to reject with CanceledException
instead of undefined, and remove legacy Bluebird handledRejection flag.
2026-03-17 13:59:52 -04:00
Abe Jellinek
34039991f8
Track attachment last-read time, add Recently Read virtual collection (#2854)
Track when attachments are last opened or read, storing a `lastRead` Unix timestamp on the attachment. For user library items, `lastRead` syncs as an attachment property in item JSON. For group library items, it syncs via a per-user synced setting (like `lastPageIndex`).

- Add `lastRead` column to `itemAttachments`
- Add `AttachmentReadObserver` to update `lastRead` on file open and page change (throttled to 5 min for page changes)
- Add "Recently Read" virtual collection (items read in last 14 days, sorted by `lastRead` descending)
- Add `lastRead` search condition with date operators
- Add `lastRead` item tree column with new `dependsOnChildren` property for parent item aggregation
- Add `getItemLastRead()` to return max `lastRead` across child attachments

Also:

- Generalize collection tree SCSS to support universal (context-fill) icons alongside themed icons

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-03-17 13:52:00 -04:00
Dan Stillman
0ce5cd5bac Clear Read Aloud voices pref when logging out 2026-03-17 11:44:09 -04:00
Dan Stillman
08c75006f3 Update English strings for Transifex 2026-03-16 15:26:28 -04:00
Dan Stillman
290d44480e Move new "Log In"/"Log Out…" strings to Fluent 2026-03-16 15:25:34 -04:00
Dan Stillman
43f422a989 Update English strings for Transifex 2026-03-16 15:16:10 -04:00
Dan Stillman
a9342cc242 "Log in with your zotero.org account" → "[…] Zotero account" 2026-03-16 15:15:07 -04:00
Dan Stillman
426d681b65 Remove Citation Explorer string
Final string should be committed with #3468 (and in Fluent)
2026-03-16 15:12:42 -04:00
abaevbog
fb4ac78291 citation dlg: fix details popup buttons overflow (#5838)
For locales with long button labels.
https://forums.zotero.org/discussion/130208/spacing-is-off-in-citation-detail-pop-up
2026-03-12 14:46:45 -04:00
Tom Najdek
fa476245fe
Fix "Rename from Parent" button sometimes not appearing (#5835)
After editing the parent item, switching back to the attachment item before the parent item's save completes creates a race condition between the save and the attachment render.

Moved item pane tests related to file renaming to a separate describe() block.
2026-03-11 14:39:37 -04:00
Tom Najdek
518e7d22a0 Fix a flaky test on CI 2026-03-11 14:35:39 -04:00
Tom Najdek
3328557d00 Revert "Temporarily disable attachment-pane tests due to test failures"
This reverts commit 810626e458.
2026-03-11 14:35:39 -04:00
Tom Najdek
259abb15f5 Revert "Temporarily disable attachments-pane tests due to test failures"
This reverts commit 3d09df1419.
2026-03-11 14:35:39 -04:00
Tom Najdek
d3509ae259 Add support for test retries and enable three retries on CI 2026-03-11 14:35:39 -04:00
Dan Stillman
be1373dd3a Update reader submodule 2026-03-10 11:35:46 -04:00
Tom Najdek
fcce3bb21d
Fix autocomplete popup always showing scrollbar
Previous styling caused the autocomplete popup to render a scrollbar even when all items were visible.
2026-03-09 23:32:41 +01:00
windingwind
f130e3b3e5
Fix menu API icon inheritance (#5832)
Also:

- Change menu API l10nArgs from object to string to be consistent with other APIs and function calls
2026-03-09 15:42:42 -04:00
abaevbog
25a96c35ca
citation dlg: fix itemTree dblclick stealing focus (#5833)
Make sure bubble-input is refocused after double-click
on itemTree, same as as after adding an item to citation
via Enter or + click.

Fixes: #5830
2026-03-09 14:56:47 -04:00
Dan Stillman
d2d66d322c Fix UNC paths test for updated Zotero RDF translator
zotero/translators@9906b24d changed the file path predicate from
rdf:resource to z:path.
2026-03-09 14:55:25 -04:00
Dan Stillman
db70d92b4c CLAUDE.md: Clarify that Fluent should be used for all new strings 2026-03-09 14:45:43 -04:00
Dan Stillman
2c234c4c0a Update reader submodule 2026-03-09 10:28:32 -04:00
Dan Stillman
2f6825027a Add Director fallback to firstCreator for Video Recording items
The firstCreator/sortCreator SQL now falls back through primary →
editor → director → contributor. This ensures Video Recording items
with only a Director creator show that creator in the items list.

Also refactors the repetitive SQL-building in _getFirstCreatorSQL()
and _getSortCreatorSQL() into helper functions.

Closes #5720
2026-03-09 10:28:06 -04:00
Dan Stillman
f9a10e1874 Fix Title column not recoverable when hidden by a plugin
If a plugin (or corrupt treePrefs.json) sets the Title column to
hidden and no other primary column is visible, force-show it.

Closes #5747
2026-03-09 10:28:06 -04:00
Dan Stillman
f6811bd1a8 Add Citation Key column
https://forums.zotero.org/discussion/130194/feature-request-citation-key-column-in-library-window
2026-03-08 14:46:39 -04:00
Dan Stillman
e60bf91e31 Fix PDF magic number sniffing matching anywhere in first 200 bytes
The %PDF- sniffer entry had no offset constraint, so it matched anywhere
in the file sample. This could cause ZIP files containing PDFs to be
misidentified as application/pdf when opened, since %PDF- could appear
within the first 200 bytes of the archive. Pin the match to offset 0,
where it must appear in a valid PDF.

Fixes https://forums.zotero.org/discussion/130161/zip-files-and-indexing
2026-03-08 14:46:39 -04:00
Abe Jellinek
81f80a62e3 Update RDF tests 2026-03-06 16:51:25 -05:00
Abe Jellinek
12942ec717 Update translators 2026-03-06 16:51:25 -05:00
Adomas Venčkauskas
ce11d5295c Update LibreOffice submodule 2026-03-06 13:57:53 +02:00
abaevbog
b80bcea184
add citationKey to quicksearch-titleCreatorYear (#5814)
So quicksearch and citation dialog in list mode search
by the citation key, in addition to title,creator,etc.

https://forums.zotero.org/discussion/130090/zotero-picker-i-cant-search-item-by-citation-key
2026-03-05 20:08:52 -05:00
Abe Jellinek
0abf2b455f
Add Read Aloud first-run guidance panel (#5818)
Plus guidance-panel changes:

- Fix description not updating when multiple panels exist
  in the document
- Fix nonfunctional noautohide attribute
- Show "Got It" button for noautohide with no navigation

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-03-05 20:07:06 -05:00
Dan Stillman
20735c425f
Add resetData() as lightweight alternative to resetDB() for sync tests (#5822)
resetData() clears user data and sync state using application APIs
instead of doing a full Zotero shutdown/reinit cycle, making it much
faster for tests that just need clean state between runs.

Also make Tags.init(), Creators.init(), Users.init(), and
SyncedSettings.loadAll() properly clear their caches before reloading,
so they are safe to call more than once.
2026-03-05 15:54:10 -05:00
windingwind
76c7a613a9 Fix attachment preview 2026-03-05 12:24:26 -05:00
Mi Ramon
c0c9437da8
use different notes icons in itempane and tab sidepane (#5799) 2026-03-04 23:46:17 -05:00
Abe Jellinek
3a11ac146d Item merging: Don't merge trashed attachments of any type 2026-03-04 23:14:53 -05:00
Abe Jellinek
c9d108ba5e Extract merge logic to new module
Without changes. Most of this code was never called outside items.js,
so there was no reason to expose it.
2026-03-04 23:14:53 -05:00
Dan Stillman
810626e458 Temporarily disable attachment-pane tests due to test failures
https://github.com/zotero/zotero/issues/5810
2026-03-04 23:08:25 -05:00
windingwind
c4df6b2151
Fix should register other main window menus test (#5812) 2026-03-04 22:56:18 -05:00
Dan Stillman
3d09df1419 Temporarily disable attachments-pane tests due to test failures
https://github.com/zotero/zotero/issues/5810
2026-03-04 22:52:41 -05:00
Dan Stillman
9d5f46587e Disable Better BibTeX <9.0
https://forums.zotero.org/discussion/comment/508650/#Comment_508650
2026-03-04 22:43:24 -05:00
Dan Stillman
7c61d282ee Use our own blocked-plugin message that doesn't mention Mozilla 2026-03-04 22:43:11 -05:00
Abe Jellinek
a6a919234a Update reader submodule 2026-03-03 17:37:06 -05:00
Abe Jellinek
f07d7077b5 Read Aloud: Move caching to RemoteInterface 2026-03-03 17:36:51 -05:00
Abe Jellinek
4e60a9fbca Read Aloud: Don't log FTL fetching errors
appLocalesAsBCP47 contains locales that we don't have FTL files for, so
ignore those errors.
2026-03-03 16:07:47 -05:00
Abe Jellinek
db124860ed Read Aloud: Use POST for /speak 2026-03-03 15:40:35 -05:00
Dan Stillman
f5dd72dfb5 Run CI tests against zip-round-tripped build output
Instead of testing against the build/ directory directly, zip it up first
and then unzip it back into build/ before running tests. This ensures
tests run against the same artifact that gets uploaded to S3 and used by
the deployment scripts, catching issues that only manifest after a zip
round-trip (e.g., resolved symlinks, missing files).

Also makes Ruby setup conditional on deployment branches
2026-03-03 09:53:07 -05:00
Adomas Venčkauskas
1cd12791f6 Update LibreOffice submodule 2026-03-03 11:45:32 +02:00
Abe Jellinek
520a12ebe0 Update reader submodule 2026-03-02 17:51:17 -05:00
Abe Jellinek
b6ffa16893 Add failsafe to ensure pdf.js build worked 2026-03-02 17:46:05 -05:00
Abe Jellinek
a7c76e3202 CI: Trigger new build 2026-03-02 17:33:38 -05:00
Abe Jellinek
041fa48664 Read Aloud: Annotate Last Sentence → Annotate Sentence 2026-03-02 15:32:35 -05:00
Abe Jellinek
13f1606dc7 Read Aloud: Implement onPurchaseReadAloudCredits() 2026-03-02 14:47:14 -05:00
Abe Jellinek
3f90e60cd0 Remove AudioPlayback actor
Not necessary, and it caused some error spam.
2026-03-02 14:13:39 -05:00
Abe Jellinek
e63ed25b3f Read Aloud: Don't localize keys in Annotate Last Sentence 2026-03-02 13:32:28 -05:00
Abe Jellinek
8adee749e0 Read Aloud: Add key hint to Annotate Last Sentence string 2026-03-02 13:19:56 -05:00
Abe Jellinek
b0b2378866
Add support code for Read Aloud (#5355) 2026-03-02 13:00:23 -05:00
abaevbog
5905638907
Clear tag selector when itemTree selection changes, if needed (#5602)
Similar to how quickSearch is cleared already.

To avoid rerunning itemTree refresh twice (once for
clearing quickSearch and once for clearing tag filter),
clear tags and search text directly on collectionTreeRow.
And then call itemTree.refreshAndMaintainSelection to
refresh everything. Without it, tag selector sometimes
would find itself out of sync with current search
results and display tags as if quickSearch was not
cleared.

Fixes #5601
2026-02-28 15:41:32 -05:00
abaevbog
578361913a
Citation dialog: scroll to 1st cited row in library mode (#5782)
Scroll to the first cited item in this citation in current collection

Fixes: #5780
2026-02-28 15:33:36 -05:00
abaevbog
da4978354b
avoid reloading linked item each time it's fetched (#5565)
item.loadAllData() completely reloads all data and
may clear unsaved changes. Instead, Zotero.Items.loadDataTypes
will only load data once if it hasn't been loaded yet.

Followup to fefad8a9e3
per discussion in https://github.com/zotero/zotero/pull/5547#issuecomment-3373597427
2026-02-28 15:24:08 -05:00
Adomas Venčkauskas
003bdab3ff Auto-recover ALL-CAPS macOS corrupted word field codes.
Closes zotero/zotero-word-for-mac-integration#38
2026-02-26 16:22:28 +02:00
Tom Najdek
f0d3347b26
Fix tag splitting for strings containing Unicode characters. Fix #5807 2026-02-25 11:30:55 +01:00
Dan Stillman
f21e1b2d32 Add platform-native file change watcher for storage sync
Use platform-native file change APIs to track which stored-file
attachments have been modified, avoiding expensive full scans of all
attachment files during sync.

Backends:
- macOS: FSEvents (persistent event journal, survives restarts)
- Windows: ReadDirectoryChangesW (live recursive directory watch
  via overlapped I/O polling)
- Linux: inotify (live per-directory watches)

On unsupported platforms or on error, falls back to existing scan logic.
2026-02-24 14:15:15 -05:00
Dan Stillman
2739b29709 Avoid excessive /file requests during file sync
Don't mark attachments for download when server has no file (mtime is
null), and clear TO_DOWNLOAD state on 404 responses in both ZFS and
WebDAV so items aren't re-checked on every subsequent sync.
2026-02-22 23:45:55 -05:00
Adomas Venčkauskas
3386490a03 Fix browser toolbox switching windows breaking script debugging 2026-02-20 12:11:25 +02:00
Dan Stillman
2aaf0c213f DataObject: Set type-derived properties on prototype instead of per-instance
Move _ObjectType, _objectTypePlural, _ObjectTypePlural, and
_ObjectsClass from per-instance properties to prototype properties,
computed once per subclass on first instantiation. This avoids a huge
number of duplicate string allocations and reduce memory usage a bit
(e.g., ~35 MB in a 19K-item test library).
2026-02-17 12:09:59 -05:00
Dan Stillman
0d16d81a3c Share a single cached CSL.Engine instance across output formats
setOutputFormat() just sets this.opt.mode and this.fun.decorate, so
there shouldn't be a need to cache separate engines per format. The
format is excluded from the cache key, and setOutputFormat() is called
on cache hit instead.

Also simplify _preloadFormat() in quickCopy.js to only create one
engine instead of two (one per format).

Saves ~7 MB of resident memory (~140 MB before the citeproc-js
sort-macro caching optimization).
2026-02-17 12:09:59 -05:00
Dan Stillman
fe74fce120 citeproc-js: Cache sort-mode macro expansions to reduce memory usage
Previously, macros referenced by sort keys were expanded inline into
the sort token array every time they were encountered. Non-sort macros
were already cached in state.macros[name] and referenced via closures,
but sort-mode macros were duplicated across sort keys.

With a complex style like chicago-shortened-notes-bibliography (155
macros, 6 macro-based sort keys), this produced 179,605 tokens and
355,652 closures in bibliography_sort alone -- 99.9% of all tokens.

This commit caches sort-mode macro expansions in state.sort_macros[name]
using the same closure-reference approach as non-sort macros.

In addition to the memory savings, avoiding the redundant macro
expansion makes engine initialization dramatically faster with complex
styles.

Results with chicago-shortened-notes-bibliography:
- Engine initialization: 464 ms -> 57 ms (8x faster)
- Per-engine heap (Node.js): 123 MB -> 6.7 MB (94.6% reduction)
- Resident per engine (Firefox): ~140 MB -> ~7 MB
- Total tokens: 187,893 -> 11,669 (93.8% reduction)
- Total closures: 370,982 -> 21,823 (94.1% reduction)
- All 1,502 citeproc-js tests pass (at f88a47e6)
- Same 1501/1505 pass on master (same 4 pre-existing failures)
2026-02-17 12:09:59 -05:00
Dan Stillman
7fe4e50c4c HTTP.request(): Don't try to output responseText if not text response 2026-02-14 16:10:30 -05:00
Dan Stillman
c9ab1ec62c 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-02-14 16:10:30 -05:00
Abe Jellinek
ae397ca37c 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-02-10 14:34:00 -05:00
Dan Stillman
c377c4c7ec CLAUDE.md: Additional test guidance 2026-02-09 22:38:33 -05:00
Dan Stillman
427fb1d4d9 Update version 2026-02-09 22:38:33 -05:00
windingwind
94dbdd5981
Skip plugin strict_max_version check on non-stable releases (#5781)
Unless `extensions.strictCompatibility` is set to true

resolve: #5775
2026-02-09 22:30:09 -05:00
Dan Stillman
fcd887a89d Fix cross-suite ZoteroPane test failures
Failed when running CollectionTree and ZoteroPane tests together

- Make `deleteSelectedItems()` async and await `deleteSelection()` so
  the full Notifier commit chain (including items list refresh)
  completes before the function resolves. Update tests to call
  `deleteSelectedItems()` directly instead of calling fire-and-forget
  keyboard events, and add a stubbed test for the Delete key binding.

- Fix "Delete Item…" menu test to explicitly re-select after `saveTx()`
  to guarantee selection state
2026-02-09 22:18:50 -05:00
abaevbog
9640875fcf
Space on an option of menulist dropdown will select it (#5783)
Fixes: #5779
2026-02-09 16:43:23 -05:00
Dan Stillman
c35d7f21e9 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-02-09 15:24:07 -05:00
Dan Stillman
8a1bdacf7d 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-02-09 14:21:58 -05:00
Dan Stillman
5cb83c69c6 Fix incorrect bold text in file-renaming banner 2026-02-09 14:21:58 -05:00
Yago Iglesias
fe3021701e
Use grep -E instead of egrep (#5789) 2026-02-09 09:54:19 -05:00
Dan Stillman
06794ce278 Update note-editor submodule 2026-02-06 15:57:43 -05:00
Dan Stillman
fa30baf523 Add CLAUDE.md with project guidance for Claude Code
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:54:09 -05:00
Dan Stillman
c7014b97c7 Update locales from Transifex 2026-02-06 15:47:31 -05:00
Dan Stillman
752b0f7008 Convert preferences_sync.xhtml from spaces to tabs 2026-02-06 15:15:30 -05:00
Dan Stillman
d966eee6a7 Update English strings for Transifex 2026-02-06 00:55:29 -05:00
Dan Stillman
2f7055e15d Update CSL locales 2026-02-06 00:51:33 -05:00
abaevbog
bd8d56e384
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 00:51:01 -05:00
Tom Najdek
bbb39719f0
Tweak hover color for banner buttons (#5776) 2026-02-04 12:10:24 +01:00
Dan Stillman
eecdd66098 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-03 23:42:45 -05:00
Abe Jellinek
5e82c888b6 Scaffold: Fix repeated reload prompts 2026-02-03 16:14:25 -05:00
Tom Najdek
0f565ecfef
Replace × close links with buttons in banners (#5735) 2026-02-03 15:51:24 -05:00
abaevbog
7b0f5d8f32
update menulist keyboard nav and appearance (#5773)
- on all platforms, Space will open the menulist popup
- on Windows, ArrowUp/ArrowDown will change selection without triggering command event.
This is needed to avoid alerts that are meant to fire after a confirmed
selection when the user is just trying to navigate the menu (e.g. item type menu)
- on Windows, fix odd appearance of checked menulist options in advanced search
condition dropdowns when "More" option is selected
- on Linux, fix menulist focus ring not appearing in Advanced Search
or Preferences (https://forums.zotero.org/discussion/129498/advanced-search-drop-down-menus-are-not-highlighted-during-keyboard-navigation)

Fixes: #5772
2026-02-03 15:22:30 -05:00
Dan Stillman
2f064a5604 Rename deployment scripts 2026-02-03 14:34:37 -05:00
Tom Najdek
818ce564bb
Improve RTF scan. Add test coverage. (#5672) 2026-02-02 15:03:57 -05:00
Dan Stillman
331a83d2e8 Update reader submodule 2026-02-02 11:13:03 -05:00
Dan Stillman
56cd4713f1 Clear cached WebDAV credentials if we get a 401 2026-02-01 16:42:55 -05:00
Dan Stillman
089701eca8 Fix WebDAV authentication handling in fx140
Firefox no longer seems to send a previously used Authorization header
with subsequent requests. This results in extra requests, since every
WebDAV request triggers a 401, and also results in errors, because a PUT
is sent without Authorization, causing some WebDAV servers to
immediately send a 401 and close the connection, which the HTTP layer
interprets as a connection failure (status 0). (It's also not good to
try to send a large file just to get a 401.)

There might be some way to share context between requests, but instead,
just get the used Authorization header and include that explicitly in
future requests.

To test this properly, we have to switch to using httpd.js for all
WebDAV requests, since the mocked XHR doesn't trigger the 401 retry.

https://forums.zotero.org/discussion/129194/webdav-uploads-fail-on-zotero-8-put-sent-without-authorization-server-closes-connection
2026-02-01 16:42:55 -05:00
Dan Stillman
e7d1772470 Don't show Authorization header value in HTTP.request() debug mode 2026-02-01 16:42:55 -05:00
windingwind
1347023b39
Fix note context tabNotesDeck.selectedPanel null error (#5771)
fix: #5770
2026-02-01 11:06:10 -05:00
Dan Stillman
62894d56e5 Update version 2026-01-29 15:35:21 -05:00
Dan Stillman
a0758e016a Update locales from Transifex 2026-01-29 15:35:21 -05:00
Dan Stillman
0c97b6f74c Include collections in trash when generating item JSON
Any modification to an item belonging to collections in the trash would
cause those collections to be removed from the item when the item was
uploaded.

Fixes #5766
2026-01-29 15:14:02 -05:00
Dan Stillman
f3c0e4fdc2 Fix error resetting group library with unsynced embedded images
https://forums.zotero.org/discussion/129373/zotero-7-8-warning-when-syncing-group-library
2026-01-29 13:22:14 -05:00
Dan Stillman
2c2b6fe171 Update package-lock.json 2026-01-29 13:22:13 -05:00
abaevbog
22f62138a3
Fix quicksearch not finding annotations of standalone attachments (#5756)
Fix search not finding annotations of standalone attachments
when searching within a scope.

Fixes: #5751
2026-01-28 13:30:36 -05:00
windingwind
1969bde82c
Fix attachment preview discard logic (#5757)
Use keepAlive() to prevent deferred discard when the preview moves out of view but later enters the view again before the discard timeout.
2026-01-28 11:28:06 -05:00
Abe Jellinek
db9598f01e Update reader submodule 2026-01-28 10:28:23 -05:00
Abe Jellinek
c31386dc3e Update reader submodule 2026-01-28 10:01:15 -05:00
windingwind
aef4beee89 Fix note editor iframe docShell undefined error
Regression after 37ce3c0
2026-01-28 10:49:55 +01:00
Abe Jellinek
4880674668 Remove DOI when creating bookSection from book
And book from bookSection.

Closes #5732
2026-01-27 16:20:14 -05:00
Dan Stillman
85bff00a3c Update translators 2026-01-27 13:46:49 -05:00
Dan Stillman
0e78eb6709 Update locales from Transifex 2026-01-27 13:45:15 -05:00
Dan Stillman
2b262c5442 CI: Fix disabling of certain tests
Wrong property name
2026-01-27 13:34:25 -05:00
Tom Najdek
aabac2576c
Ensure automatic file renaming is enabled for group libraries (#5750) 2026-01-27 13:25:12 -05:00
abaevbog
56de1abb79
Citation dialog: localized CSL page locator in guidance panel text (#5746)
* citation dlg: local. CSL locator in guidance panel

Use localized short CSL page locator in guidance panel
text example ("p10")

Fixes: #5745

Also, fix dialog mode switch buttons not properly sizing
in locales where list/library string is longer.
2026-01-27 12:26:32 -05:00
Dan Stillman
7d79ec01a5 CI: Disable "should add an item to My Publications" test due to errors
https://github.com/zotero/zotero/issues/5749
2026-01-27 12:14:01 -05:00
Abe Jellinek
8b446b7fc5
Set auto attachment title when not renaming (#5748) 2026-01-27 11:44:50 -05:00
Tom Najdek
cc53bc1699
Fix clipped inputs in RTF Scan and related UI issues
* Remove `overflow: visible` in RTF Scan to prevent richlistbox from expanding the width and causing clipping
* Introduce small margins as an alternative to prevent focus rings from being clipped in the `wizard`
* Fix "Display as" alignment on Windows
2026-01-27 13:06:04 +01:00
Dan Stillman
98b0a3a741 Update utilities submodule
Fixes #5744
2026-01-27 00:39:40 -05:00
abaevbog
1f9d9d2967
citation dlg: updates to locator shortcuts (#5529)
- after a new bubble is added to the citation, it is recorded
as a just-added bubble. The next locator typed without a search query
will go to that item instead of going to the item before where the locator was typed.
Same logic applies when multiple bubbles are added at once.
- the record of just-added bubbles is cleared on focusout
or keypress of an arrow key. That way, it's discarded if the user
is almost certainly not intending to immediately type a locator.
- added a special case to recognize a numeric value as a page locator
if it is typed when just-added bubble is recorded. That special locator
will be added to the just-added bubble as one is typing without
pressing Enter after debounce. Enter will immediately add the locator
without waiting for debounce.
- if a just-added bubble is recorded, cmd-z will clear
whatever numeric locator may have been typed and place
it back into the input, in case one meant to type an
actual search query

- added a special case to recognize ":<number>" as a page locator
in the same circumstances that "page <number>" is currently recognized
- do not use year extraction (SearchHandler._cleanYear)
when parsing input. It strips the first number from
a range of numbers and conflicts with the new locator
logic.
- ensure a bubble with a very long locator does not overflow
2026-01-26 20:12:44 -05:00
abaevbog
83e1a6030a
fix windowIcon logged error in titlebar.js on win (#5743)
Instead of adding listeners to the window icon unless
no-titlebar-icon is set on the window, add listeners
to the icon whenever it exists. That way, one doesn't have
to remember to add the no-titlebar-icon attribute to
every window that doesn't have an icon.

Fixes: #5733
2026-01-26 20:11:18 -05:00
Dan Stillman
c9f34754a9 CI: Skip attachment pane preview test for now
Too many spurious failures

See #5624
2026-01-26 16:50:50 -05:00
abaevbog
ea1ef4492d
Citation dialog: restore first run guidance panel (#5740)
- restore guidance panel with instructions appearing on the first run
- clear firstRunGuidanceShown.quickFormat pref
- set slightly higher arrow padding on win/linux for the guidance
  panel. If the arrow is too close to the edge, it appears
  partly disconnected from the panel.

Fixes: #5738
2026-01-26 16:34:53 -05:00
Abe Jellinek
3c6ad491b5
Fix citations accumulating due to CSL.Engine caching (#5742)
* makeFormattedBibliographyOrCitationList(): Don't modify CSL.Engine state

Use previewCitationCluster(), which leaves the processor state alone,
instead of calling appendCitationCluster(), which has side effects and
isn't used anywhere else in Zotero.

* Make CSL.Engine caching opt-in

* Remove unnecessary CSL.Engine reinitialization

* Code style

* Add/update tests
2026-01-26 16:12:36 -05:00
Dan Stillman
c42f3b8174 Fix citing in Serbian locale
Fixes #5741
2026-01-26 15:14:19 -05:00
Dan Stillman
e0ff103afd Update reader submodule 2026-01-26 12:29:05 -05:00
Tom Najdek
f58ca4b399
Fix missing ellipsis for styles in the style selector (#5736) 2026-01-26 11:29:11 -05:00
Adomas Ven
6697db1dba
Delete duplicate renamed styles on init (#5734)
After citation-style-language/styles#7928 renamed Vancouver styles to
NLM terminology, Zotero installations end up with both vancouver.csl
and nlm-citation-sequence.csl. To fix, on init, delete any installed style
whose ID appears in the renamed-styles mapping if the target style
also exists.

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
2026-01-26 10:35:43 -05:00
Martynas Bagdonas
69002c122d Add string for "Black" reader theme 2026-01-26 17:06:48 +02:00
Martynas Bagdonas
6c4fde54be Add string for image color inversion option in reader themes 2026-01-26 16:44:41 +02:00
Dan Stillman
a34b845154 Update style test to use NLM citation-sequence instead of Vancouver 2026-01-26 00:43:06 -05:00
Dan Stillman
e3e122b69d Add script for pulling Fluent translations 2026-01-25 22:15:29 -05:00
Dan Stillman
67c10a9ff7 Update bundled styles and renamed styles 2026-01-25 22:12:52 -05:00
Dan Stillman
9891b6dc51 Update version 2026-01-25 14:48:55 -05:00
Dan Stillman
2ffd6a893b Don't load annotations or tags for items individually at startup
Load tags and annotations before itemData, since `_loadItemData()`
calls `updateDisplayTitle()`, which needs creators, tags, and
annotations to be loaded for some item types.

This should fix slow startup times in Zotero 8 in libraries with many
(e.g., tens of thousands) of annotations.

The slowdown here was introduced by #3416 loading annotations in the
`catch`, but it was already incorrect for tags to be loaded that way at
startup. Creators were loaded before itemData, but tags weren't.

The `catch` might not be necessary at all now that the loading order for
`loadAllData()` is fixed, but we'll need to test further to confirm
that.

Fixes #5724
2026-01-25 11:15:30 -05:00
Mark
b1b0d45353
Update Russian translation for reader.ftl (#5728) 2026-01-24 09:34:37 -05:00
Mark
3424ab511b
Update Russian translation for note-editor.ftl (#5727) 2026-01-24 09:34:33 -05:00
Mark
3c6c90a2a1
Update Russian translation for integration.ftl (#5726) 2026-01-24 09:34:27 -05:00
Mark
c21d8983de
Update Russian translation for zotero.ftl (#5725) 2026-01-24 09:34:21 -05:00
Dan Stillman
fef2178450 Don't load unnecessary item types for updateDisplayTitle() fallback
We don't need to try to load annotations for regular items or creators
or tags for annotations.

Every annotation item's annotation is still loaded independently, but we
can try to fix that separately. For now, just don't try to load data
that will never exist.

Addresses #5724
2026-01-23 13:07:57 -05:00
Dan Stillman
d53d760cb6 Remove obsolete call to updatePostUpgradeBanner() 2026-01-23 12:53:02 -05:00
Dan Stillman
aab4a08267 Revert disabled="disabled" to disabled="true" after #5719 2026-01-23 11:25:28 -05:00
Northword
dfca2f5862
Fix color of OK button in MS Word DocPrefs (#5719)
https://forums.zotero.org/discussion/129173/zotero-8-accent-color-of-the-ok-button-in-the-document-preferences-pane-of-ms-word-addins
2026-01-23 11:19:33 -05:00
Gemini
404667f6a9 Fix double-encoding of special characters in DOIs in word processor integrations
Disable wrap_url_and_doi for citeproc instances used in word processor
integrations to prevent brackets and other special characters in DOIs
from being double-encoded.

Fixes #5557
2026-01-23 12:58:17 +02:00
Dan Stillman
dcad02c1d4 Update Windows build to Firefox 140.7.0esr 2026-01-22 23:22:35 +00:00
Abe Jellinek
bd89c00d37
About window: Always pass centerscreen, and pass dialog=yes on macOS (#5718) 2026-01-22 15:52:39 -05:00
Dan Stillman
5dcb1a4bba Update Mac and Linux builds to Firefox 140.7.0esr 2026-01-22 15:25:13 -05:00
Abe Jellinek
3e248c703e
showAttachmentFilenames: Fall back on invalid attachment path (#5717) 2026-01-22 14:55:23 -05:00
windingwind
14a26038d5
Fix v-table stylesheet keys (#5714)
Fix custom column keys parsing issue in v-table
Use index of css suffix instead of dash for key parsing
Ensure style map key is escaped
2026-01-22 14:42:33 -05:00
Abe Jellinek
0d864ae83e
Fix contextPaneOpen race condition causing duplicate button (#5716) 2026-01-22 14:42:13 -05:00
Dan Stillman
620d1b96b9 Don't try to create incremental updates from Z7 for ARM Linux 2026-01-21 22:42:18 +00:00
936 changed files with 99253 additions and 26415 deletions

View file

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

110
CLAUDE.md Normal file
View file

@ -0,0 +1,110 @@
# 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=140.0
MaxVersion=140.99.*
MinVersion=153.0
MaxVersion=153.99.*
[XRE]
EnableExtensionManager=1

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

@ -1,30 +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"
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

@ -1,27 +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"
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

@ -1,29 +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="main"
if [ "`uname`" = "Darwin" ]; then
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/release/ZoteroSafariExtension.appex"
fi
cd "$SCRIPT_DIR"
./check_requirements
hash=`./get_repo_branch_hash $BRANCH`
source_dir=`./get_commit_files $hash`
build_dir=`mktemp -d`
function cleanup {
rm -rf "$source_dir"
rm -rf "$build_dir"
}
trap cleanup EXIT
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL

View file

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

View file

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

View file

@ -0,0 +1,64 @@
#!/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

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

View file

@ -1,6 +1,7 @@
#!/bin/bash
#
# Builds and deploys Zotero with full and incremental updates
# 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).
#
set -euo pipefail
@ -107,13 +108,23 @@ 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"
@ -126,46 +137,31 @@ done
# Upload builds to S3
"$SCRIPT_DIR/upload_builds" $CHANNEL $VERSION
# Upload file lists for each platform
channel_deploy_path="$DEPLOY_PATH/$CHANNEL"
# Upload file lists and build info for each platform
channel_deploy_path="$DEPLOY_PATH/manifests/$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-*
rsync -rv "$TEMP_DIR/version_info/" $DEPLOY_HOST:"$channel_deploy_path/$VERSION/"
# Download updates JSON for each platform, update it, and reupload it
# Generate build-{os}.json for each platform
SHORT_VERSION=$(echo "$VERSION" | grep -oE '^[0-9]+\.[0-9]+')
DETAILS_URL="https://www.zotero.org/support/${SHORT_VERSION}_changelog"
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"
;;
m) os_name="mac" ;;
w) os_name="win" ;;
l) os_name="linux" ;;
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
printf '{\n "buildID": "%s",\n "detailsURL": "%s"\n}\n' "$BUILD_ID" "$DETAILS_URL" \
> "$TEMP_DIR/version_info/build-${os_name}.json"
chmod g+w "$TEMP_DIR/version_info/build-${os_name}.json"
done
# Add version to incremental lists
echo
for i in `seq 0 1 $((${#PLATFORMS}-1))`
do
"$SCRIPT_DIR/manage_incrementals" -c $CHANNEL -p ${PLATFORMS:i:1} -a $VERSION
done
$DEPLOY_CMD
rsync -rv "$TEMP_DIR/version_info/" $DEPLOY_HOST:"$channel_deploy_path/$VERSION/"
rm -rf "$STAGE_DIR"/*
echo
echo "Build $VERSION uploaded successfully."
echo "To deploy, run on the deploy server:"
echo " ./deploy $CHANNEL $VERSION $PLATFORMS"

View file

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

View file

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

View file

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

322
app/scripts/incremental_update Executable file
View file

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

View file

@ -64,22 +64,19 @@ if [[ "$VERSION" ]] && [[ "$NUM_VERSIONS" ]]; then
usage
fi
INCR_FILENAME="incrementals-$CHANNEL-$PLATFORM"
S3_URL="s3://$S3_BUCKET/$S3_DIST_PATH/$CHANNEL/incrementals-$PLATFORM"
INCR_FILENAME="incrementals-$PLATFORM"
REMOTE_PATH="$DEPLOY_PATH/manifests/$CHANNEL/$INCR_FILENAME"
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"
aws s3 cp $S3_URL "$INCR_PATH" >&2
scp $DEPLOY_HOST:"$REMOTE_PATH" "$INCR_PATH" >&2
# Add version to file and reupload
if [ "$VERSION" ]; then
echo "Adding $VERSION to incrementals-$PLATFORM"
echo $VERSION >> "$INCR_PATH"
aws s3 cp "$INCR_PATH" $S3_URL
scp "$INCR_PATH" $DEPLOY_HOST:"$REMOTE_PATH"
# Show last n versions
elif [ "$NUM_VERSIONS" -gt 0 ]; then
# TEMP: Don't include 6.0 versions

View file

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

View file

@ -0,0 +1,46 @@
#!/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,9 +39,88 @@ 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
if ! egrep -q "$pattern" "$file"; then
file=$2
if ! grep -E -q "$pattern" "$file"; then
echo "$pattern" not found in "$file" -- aborting 2>&1
exit 1
fi
@ -52,7 +131,7 @@ function replace_line {
replacement=$2
file=$3
if egrep -q "$pattern" "$file"; then
if grep -E -q "$pattern" "$file"; then
perl -pi -e "s/$pattern/$replacement/" "$file"
else
echo "$pattern" not found in "$file" -- aborting 2>&1
@ -64,8 +143,8 @@ function remove_line {
pattern=$1
file=$2
if egrep -q "$pattern" "$file"; then
egrep -v "$pattern" "$file" > "$file.tmp"
if grep -E -q "$pattern" "$file"; then
grep -E -v "$pattern" "$file" > "$file.tmp"
mv "$file.tmp" "$file"
else
echo "$pattern" not found in "$file" -- aborting 2>&1
@ -78,7 +157,7 @@ function remove_between {
end_pattern=$2
file=$3
if egrep -q "$start_pattern" "$file" && egrep -q "$end_pattern" "$file"; then
if grep -E -q "$start_pattern" "$file" && grep -E -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 | i686 | arm64 (alias x64 → x86_64)
# x86_64 | arm64 (alias x64 → x86_64)
set -euo pipefail
@ -26,7 +26,7 @@ Usage: xulrunner_hash.sh -p <m|w|l> [-a ARCH]
-p PLATFORM m (mac), w (windows), l (linux) [required]
-a ARCH *required* for Windows & Linux builds
Windows: x64 | arm64 | win32
Linux: x64 (or x86_64) | arm64 | i686
Linux: x64 (or x86_64) | arm64
Mac: disallowed always universal
DONE
exit 1
@ -65,7 +65,7 @@ case $platform in
[[ -z $arch ]] && { echo "-a is required for Linux builds" >&2; echo; usage; }
[[ $arch == x64 ]] && arch="x86_64"
case $arch in
x86_64|i686|arm64) ;;
x86_64|arm64) ;;
*) echo "Invalid Linux arch: $arch" >&2; echo; usage;;
esac
GECKO_VERSION="$GECKO_VERSION_LINUX"
@ -91,4 +91,5 @@ fi
input_string="${platform}-${arch}-${components_hash}|${GECKO_VERSION}|${xulrunner_content}"
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $2}'
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $NF}'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,8 +30,8 @@ else
echo "Unknown architecture $arch" >&2
exit 1
fi
rustup target add $rust_target-pc-windows-msvc
rustup default $RUST_VERSION
rustup target add $rust_target-pc-windows-msvc
cp "$SCRIPT_DIR/mozconfig" "$gecko_path"
@ -43,3 +43,17 @@ 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 each architecture
# Process whichever arch ZIPs are present
processed=0
for i in "${!files[@]}"; do
file="${files[$i]}"
arch="${arches[$i]}"
temp_zip="xul-temp-$arch.zip"
if [ ! -f "$file" ]; then
echo "Missing file: $file"
exit 1
continue
fi
dlls=(xul.dll)
@ -75,4 +75,10 @@ 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,6 +29,11 @@ ac_add_options --disable-update-agent
export MOZILLA_OFFICIAL=1
export RELEASE_OR_BETA=1
# We don't need source-repo info embedded, and generating it would require a
# source-repo.h that Firefox only auto-populates for Mercurial, not our git
# checkout, which breaks packaging. Turn it off.
ac_add_options MOZ_INCLUDE_SOURCE_INFO=
MOZ_REQUIRE_SIGNING=
# Build updater without MAR signature verification

View file

@ -1,144 +0,0 @@
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];

179
app/win/mozilla-153.patch Normal file
View file

@ -0,0 +1,179 @@
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:e66cbd095e4b714ef5f2494240190f67da78af77053368b65f380c7c7ef95f41
size 393284
oid sha256:13a76afa9dac4bfbb5f1a9b2c69ecc82f300a6f52a673c562da8f30a11b62438
size 876372

View file

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

View file

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

View file

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

View file

@ -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 },
cookieSandbox: tester.cookieSandbox,
userContextId: tester.cookieSandbox,
});
await browser.load(url, { requireSuccessfulStatus: true });

View file

@ -37,6 +37,10 @@ ChromeUtils.defineESModuleGetters(globalThis, {
const progressListeners = new Set();
// From nsSandboxFlags.h
const SANDBOXED_ORIGIN = 0x10;
const SANDBOXED_SCRIPTS = 0x80;
/**
* Functions for creating and destroying hidden browser objects
**/
@ -48,10 +52,13 @@ 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 {Zotero.CookieSandbox} [options.cookieSandbox]
* @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
*/
constructor(options = {}) {
this._destroyed = false;
this._allowJavaScript = options.allowJavaScript !== false;
this._createdPromise = (async () => {
let doc;
if (options.useHiddenFrame !== false) {
@ -59,7 +66,6 @@ export class HiddenBrowser {
this._frame = frame;
var windowlessBrowser = await frame.get();
windowlessBrowser.browsingContext.allowJavascript = options.allowJavaScript !== false;
windowlessBrowser.docShell.allowImages = false;
if (options.docShell) {
Object.assign(windowlessBrowser.docShell, options.docShell);
@ -80,13 +86,12 @@ 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) {
@ -96,6 +101,17 @@ 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;
})();
@ -142,15 +158,11 @@ 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 oa = E10SUtils.predictOriginAttributes({ browser: this });
let remoteType = E10SUtils.getRemoteTypeForURI(
uri,
true,
false,
E10SUtils.DEFAULT_REMOTE_TYPE,
null,
oa
);
let remoteType = ChromeUtils.predictRemoteTypeForURI(uri, {
preferredRemoteType: E10SUtils.DEFAULT_REMOTE_TYPE,
useRemoteTabs: true,
useRemoteSubframes: false
});
if (this.remoteType !== remoteType) {
// The following functions need to be called on the <browser> directly,
// not through our proxy (aka 'this')
@ -250,12 +262,22 @@ export class HiddenBrowser {
/**
* @param {String[]} props - 'characterSet', 'title', 'bodyText', 'documentHTML', 'cookie', 'channelInfo'
* @param {Object} [options]
* @param {Number} [options.timeout=30000] - Time to wait for each property in milliseconds.
* The queries wait for the document to be ready, so a page that never finishes loading
* would otherwise hang the query forever.
*/
async getPageData(props) {
async getPageData(props, { timeout = 30000 } = {}) {
var actor = this.browsingContext.currentWindowGlobal.getActor("PageData");
var data = {};
for (let prop of props) {
data[prop] = await actor.sendQuery(prop);
let timeoutPromise = new Promise((_, reject) => {
setTimeout(
() => reject(new Error(`Timed out getting '${prop}' from hidden browser`)),
timeout
);
});
data[prop] = await Promise.race([actor.sendQuery(prop), timeoutPromise]);
}
return data;
}

View file

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

@ -0,0 +1,33 @@
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

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

View file

@ -109,15 +109,6 @@ 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]);
}
@ -158,7 +149,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 Not used in Connector
* @param {N/A} cookieSandbox Unused, kept for compatibility with translate submodule
* @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

@ -1,190 +0,0 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
import 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

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

View file

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

View file

@ -26,7 +26,7 @@
const React = require('react');
const ReactDOM = require('react-dom');
const LibraryTree = require('./libraryTree');
const VirtualizedTable = require('components/virtualized-table');
const VirtualizedTree = require('components/virtualized-table').VirtualizedTree;
const { getCSSIcon } = require('components/icons');
const { getDragTargetOrient } = require('components/utils');
const { noop } = require("./components/utils");
@ -50,6 +50,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
dragAndDrop: false,
filterLibraryIDs: false,
hideSources: [],
multiSelect: false,
onContextMenu: noop,
};
@ -59,17 +60,19 @@ 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,
@ -138,6 +141,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
componentDidMount() {
super.componentDidMount();
this.selection.select(0);
this.makeVisible();
if (this.props.dragAndDrop) {
@ -161,10 +166,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false; // In-line editing active
}
var libraryID = this.getSelectedLibraryID();
if (!libraryID) return true;
let treeRow = this.getRow(this.selection.focused);
var libraryID = treeRow?.ref?.libraryID;
if (!libraryID) return true;
if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
this.expandLibrary(libraryID, true);
}
@ -180,6 +185,16 @@ var CollectionTree = class CollectionTree extends LibraryTree {
else if (event.key == "F2" && !Zotero.isMac && treeRow.isCollection()) {
this.handleActivate(event, [this.selection.focused]);
}
else if (event.key == 'a' && !event.shiftKey
&& (Zotero.isMac ? (event.metaKey && !event.ctrlKey) : event.ctrlKey)) {
if (this.props.multiSelect) {
this._handleSelectAll();
}
// Own Cmd/Ctrl-A here so that neither the table's unscoped Select All nor
// the cmd_selectAll key command runs
event.preventDefault();
return false;
}
else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
// Specific logic for keypress navigation during collection filtering
// that skips context-rows
@ -240,6 +255,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
handleActivate = (event, indices) => {
// Activation (rename collection, edit saved search/feed, open library) acts on
// a single row, so ignore it when multiple rows are selected
if (this.selection.count > 1) {
return;
}
let index = indices[0];
let treeRow = this.getRow(index);
if (treeRow.isCollection() && this.editable && this.selection.focused == index) {
@ -273,7 +293,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (!treeRow.editingName) return;
treeRow.ref.name = treeRow.editingName;
delete treeRow.editingName;
await treeRow.ref.saveTx();
await treeRow.ref.saveTx({ undoAction: 'undo-action-rename-collection' });
window.Zotero_Tabs.rename("zotero-pane", treeRow.ref.name);
}
@ -300,16 +320,14 @@ 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);
@ -338,7 +356,6 @@ 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');
@ -463,7 +480,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
render() {
return React.createElement(VirtualizedTable,
return React.createElement(VirtualizedTree,
{
getRowCount: () => this._rows.length,
id: this.id,
@ -478,15 +495,18 @@ var CollectionTree = class CollectionTree extends LibraryTree {
isContainer: this.isContainer,
isContainerEmpty: this.isContainerEmpty,
isContainerOpen: this.isContainerOpen,
toggleOpenState: this.toggleOpenState,
onToggleOpenState: this.toggleOpenState,
getRowString: this.getRowString.bind(this),
multiSelect: this.props.multiSelect,
// The collection tree must always have a selection
requireSelection: true,
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
onKeyDown: this.handleKeyDown,
onActivate: (...args) => (this.props.onActivate ? this.props.onActivate(...args) : this.handleActivate(...args)),
role: 'tree',
label: Zotero.getString('pane.collections.title')
}
);
@ -513,6 +533,8 @@ 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');
@ -730,10 +752,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return 0;
}
var currentLibraryID = this.getSelectedLibraryID();
var libraryID = items[0].libraryID;
// If in a different library
if (libraryID != currentLibraryID) {
// If not in one of the selected libraries
if (!this.getSelectedLibraryIDs().includes(libraryID)) {
Zotero.debug("Library ID differs; switching library");
await this.selectLibrary(libraryID);
}
@ -1245,9 +1266,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (treeRow.isLibrary(true) || treeRow.isCollection() || treeRow.isFeeds()) {
count = await this._expandRow(this._rows, index, true);
}
if (this.selection.focused > index) {
this.selection.select(this.selection.focused + count);
}
this.selection.adjustForRowInsertion(index, count);
this.selection.selectEventsSuppressed = false;
this._rows[index].isOpen = true;
@ -1258,7 +1277,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
/**
* Toggle virtual collection (duplicates/unfiled) visibility
* Toggle virtual collection (duplicates/unfiled/recently read/retracted) visibility
*
* @param libraryID {Number}
* @param type {String}
@ -1269,6 +1288,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
const types = {
duplicates: 'D',
unfiled: 'U',
recentlyRead: 'Y',
retracted: 'R',
publications: 'P'
};
@ -1308,17 +1328,40 @@ var CollectionTree = class CollectionTree extends LibraryTree {
* @returns {Promise<void>}
*/
async deleteSelection(deleteItems) {
var treeRow = this.getRow(this.selection.focused);
if (treeRow.isFeed()) {
await treeRow.ref.eraseTx();
return;
let treeRows = this.getSelectedRows();
// Feeds must be erased outside a transaction since Feed.erase()
// starts its own transaction internally
let feeds = treeRows.filter(r => r.isFeed());
let others = treeRows.filter(r => !r.isFeed());
for (let row of feeds) {
await row.ref.eraseTx();
}
treeRow.ref.deleted = true;
if (treeRow.isCollection()) {
await treeRow.ref.saveTx({ deleteItems });
return;
if (others.length) {
let collectionCount = others.filter(r => r.isCollection()).length;
let searchCount = others.length - collectionCount;
// Use the search label only when nothing but searches is selected;
// otherwise describe the action in terms of collections
let undoAction, undoActionArgs;
if (searchCount && !collectionCount) {
undoAction = 'undo-action-trash-search';
undoActionArgs = { count: searchCount };
}
else {
undoAction = 'undo-action-trash-collection';
undoActionArgs = { count: collectionCount + searchCount };
}
await Zotero.DB.executeTransaction(async () => {
for (let row of others) {
row.ref.deleted = true;
if (row.isCollection()) {
await row.ref.save({ deleteItems, undoAction, undoActionArgs });
}
else {
await row.ref.save({ undoAction, undoActionArgs });
}
}
});
}
await treeRow.ref.saveTx();
}
unregister() {
@ -1364,40 +1407,63 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return this.getRow(index).getName();
}
/**
* Return libraryID of selected row (which could be a collection, etc.)
*/
getSelectedLibraryID() {
var treeRow = this.getRow(this.selection.focused);
return treeRow && treeRow.ref && treeRow.ref.libraryID !== undefined
&& treeRow.ref.libraryID;
throw new Error("CollectionTree#getSelectedLibraryID() was removed "
+ "-- use getSelectedLibraryIDs()");
}
getSelectedCollection(asID) {
var collection = this.getRow(this.selection.focused);
if (collection && collection.isCollection()) {
return asID ? collection.ref.id : collection.ref;
}
}
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 the libraryID of every selected row, in tree order
*
* @return {Integer[]}
*/
getSelectedLibraryIDs() {
var libraryIDs = [];
for (let index of [...this.selection.selected].sort((a, b) => a - b)) {
let row = this.getRow(index);
if (row && row.ref && row.ref.libraryID !== undefined
&& !libraryIDs.includes(row.ref.libraryID)) {
libraryIDs.push(row.ref.libraryID);
}
}
return false;
return libraryIDs;
}
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;
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 false;
return collections;
}
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);
}
}
return searches;
}
getSelectedGroup() {
throw new Error("CollectionTree#getSelectedGroup() was removed "
+ "-- filter getSelectedRows() by isGroup()");
}
getIconName(index) {
@ -1427,6 +1493,10 @@ 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') {
@ -1447,18 +1517,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
////////////////////////////////////////////////////////////////////////////////
onDragStart(event, index) {
const treeRow = this.getRow(index);
super.onDragStart(event, index);
// See note in #setDropEffect()
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = 'copyMove';
}
if (!treeRow.isCollection() && !treeRow.isSearch()) {
let rows = this.getSelectedRows();
// Ignore drag if all selected rows aren't the same type
if (!rows.every(o => o.type == rows[0].type)) {
return;
}
let type = treeRow.isCollection() ? "zotero/collection" : "zotero/search";
event.dataTransfer.setData(type, treeRow.ref.id);
Zotero.debug(`Dragging ${type} ` + treeRow.id);
// Ignore if not all collections or searches
if (!rows.every(o => o.isCollection() || o.isSearch())) {
return;
}
let type = rows[0].isCollection() ? "zotero/collection" : "zotero/search";
let ids = rows.map(o => o.ref.id);
event.dataTransfer.setDragImage(this._dragImageContainer, 0, 0);
event.dataTransfer.setData(type, ids);
Zotero.debug(`Dragging ${type} ` + (ids.length > 1 ? '[' + ids.join(', ') + ']' : ids[0]));
}
onDragOver(event, index) {
@ -1532,7 +1611,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
let move = (Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey);
// A selection from a multiple-collection view can span libraries. Those items can
// only be copied, never moved (a move can't coherently move some items and copy
// others), so disallow a move rather than silently substituting a copy.
let ids = Zotero.DragDrop.getDataFromDataTransfer(event.dataTransfer).data;
let items = Zotero.Items.get(ids);
if (new Set(items.map(item => item.libraryID)).size > 1) {
this.setDropEffect(event, move ? "none" : "copy");
return false;
}
if (move) {
this.setDropEffect(event, "move");
}
else {
@ -1602,7 +1693,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
onDragEnd = () => {
onDragEnd = (event, index) => {
super.onDragEnd(event, index);
let dropRow = this._dropRow;
this._dropRow = null;
this.tree.invalidateRow(dropRow);
@ -1731,11 +1824,14 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// Intra-library drag
// Don't allow drag onto root of same library
// An item can't be added to the root of its own library, but skip it rather
// than rejecting the whole drag, so a mixed-library selection can still copy
// its out-of-library items here. (If every item is already in this library,
// `skip` stays true and the drag is refused below.)
if (treeRow.isLibrary(true)) {
Zotero.debug("Can't drag into same library root");
return false;
Zotero.debug("Item " + item.id + " already in library " + treeRow.ref.libraryID);
continue;
}
// Make sure there's at least one item that's not already in this destination
@ -1774,19 +1870,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false;
}
let draggedCollectionID = data[0];
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
for (let id of data) {
let draggedCollection = Zotero.Collections.get(id);
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (draggedCollectionID == treeRow.ref.id) {
return false;
}
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (id == treeRow.ref.id) {
return false;
}
// Nor in their children
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
return false;
// Nor in their children
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
return false;
}
}
}
@ -2191,16 +2288,21 @@ var CollectionTree = class CollectionTree extends LibraryTree {
// Dropping items, collections, or searches into trash
if (targetTreeRow.isTrash()) {
let objects = [];
let undoAction;
if (dataType == 'zotero/collection') {
objects = await Zotero.Collections.getAsync(data);
undoAction = 'undo-action-trash-collection';
}
else if (dataType == 'zotero/search') {
objects = await Zotero.Searches.getAsync(data);
undoAction = 'undo-action-trash-search';
}
else if (dataType == 'zotero/item') {
objects = await Zotero.Items.getAsync(data);
undoAction = 'undo-action-trash';
}
await Zotero.DB.executeTransaction(async function () {
Zotero.UndoHistory.stageAction(undoAction, { count: objects.length });
for (let obj of objects) {
obj.deleted = true;
await obj.save();
@ -2213,21 +2315,31 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
if (dataType == 'zotero/collection') {
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
let droppedCollections = await Zotero.Collections.getAsync(data);
if (droppedCollections.some(c => c.id == targetCollectionID)) {
throw new Error("Can't drop onto source row");
}
// Collection drag between libraries
if (targetLibraryID != droppedCollection.libraryID) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
if (targetLibraryID != droppedCollections[0].libraryID) {
for (let droppedCollection of droppedCollections) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
}
// Collection drag within a library
else {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.saveTx();
await Zotero.DB.executeTransaction(async () => {
for (let droppedCollection of droppedCollections) {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.save({ undoAction: 'undo-action-move-collection' });
}
});
}
}
else if (dataType == 'zotero/item') {
@ -2274,42 +2386,61 @@ var CollectionTree = class CollectionTree extends LibraryTree {
});
}
let newItems = [];
let newIDs = [];
// Route each item by its own library: items already in the target library are added
// directly, while items from other libraries are copied into the target library. A
// selection can span multiple libraries when dragging from a multiple-collection view.
let sameLibraryItems = [];
let otherLibraryItems = [];
let toMove = [];
// TODO: support items coming from different sources?
let sameLibrary = items[0].libraryID == targetLibraryID
for (let item of items) {
if (!item.isTopLevelItem()) {
continue;
}
newItems.push(item);
if (sameLibrary) {
newIDs.push(item.id);
if (item.libraryID == targetLibraryID) {
sameLibraryItems.push(item);
toMove.push(item.id);
}
else {
otherLibraryItems.push(item);
}
}
if (sameLibrary) {
// Add items to target container in the same library.
// Add same-library items to the target container
if (sameLibraryItems.length) {
if (targetCollectionID) {
let ids = newIDs.filter(itemID => Zotero.Items.get(itemID).isTopLevelItem());
let ids = sameLibraryItems.map(item => item.id);
await Zotero.DB.executeTransaction(async function () {
let collection = await Zotero.Collections.getAsync(targetCollectionID);
await collection.addItems(ids);
// If moving, remove from source in the same
// transaction so it's a single undo step
if (dropEffect == 'move' && toMove.length
&& sourceTreeRow && sourceTreeRow.isCollection()) {
await sourceTreeRow.ref.removeItems(toMove);
toMove = [];
Zotero.UndoHistory.stageAction(
'undo-action-move-to-collection', { count: ids.length }
);
}
else {
Zotero.UndoHistory.stageAction(
'undo-action-add-to-collection', { count: ids.length }
);
}
}.bind(this));
}
else if (targetTreeRow.isPublications()) {
await Zotero.Items.addToPublications(newItems, copyOptions);
await Zotero.Items.addToPublications(sameLibraryItems, copyOptions);
}
}
else {
// Copy items from other libraries into the target library
if (otherLibraryItems.length) {
let toReconcile = [];
await Zotero.Utilities.Internal.forEachChunkAsync(
newItems,
otherLibraryItems,
100,
function (chunk) {
return Zotero.DB.executeTransaction(async () => {
@ -2378,11 +2509,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// If moving, remove items from source collection
if (dropEffect == 'move' && toMove.length) {
if (!sameLibrary) {
throw new Error("Cannot move items between libraries");
}
// If moving, remove items from source collection. A move of a mixed-library selection
// is disallowed in onDragOver(), so it shouldn't reach here; guard against a partial
// move just in case, since only the same-library items would be moved.
if (dropEffect == 'move' && toMove.length && !otherLibraryItems.length) {
if (!sourceTreeRow || !sourceTreeRow.isCollection()) {
throw new Error("Drag source must be a collection for move action");
}
@ -2518,9 +2648,72 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return true;
}
isSelectable = index => {
// Cmd/Ctrl-A on the collection tree. Expand the current selection to all rows of
// the same kind within their natural scope: all library roots in the same
// visibility group (so feeds and regular libraries don't mix), all collections
// sharing a parent with a selected collection (so a multi-level or multi-parent
// selection expands each branch it touches), or Recently Read in every library.
// Anything else (saved searches, Unfiled, Trash, etc.) has no useful "select
// all", so the selection is left untouched rather than cleared.
async _handleSelectAll() {
let selectedIndexes = Array.from(this.selection.selected);
if (!selectedIndexes.length) {
return;
}
let selectedRows = selectedIndexes.map(index => this.getRow(index));
let scope;
if (selectedRows.every(row => row.isLibrary(true))) {
// Stay within one visibility group so feeds aren't pulled into an
// all-libraries selection, which couldn't be shown together
let visibilityGroup = selectedRows[0].visibilityGroup;
scope = row => row.isLibrary(true) && row.visibilityGroup === visibilityGroup;
}
else if (selectedRows.every(row => row.isCollection())) {
let parentIndexes = new Set(
selectedIndexes.map(index => this.getParentIndex(index))
);
scope = (row, index) => row.isCollection()
&& parentIndexes.has(this.getParentIndex(index));
}
else if (selectedRows.every(row => row.isRecentlyRead())) {
// Recently Read can appear under any library, so open any collapsed
// libraries that show it -- otherwise its row isn't in the tree to select
let libraryIDs = [];
for (let i = 0; i < this._rows.length; i++) {
let row = this.getRow(i);
if (row.isLibrary(true)
&& this._virtualCollectionLibraries.recentlyRead?.[row.ref.libraryID] !== false) {
libraryIDs.push(row.ref.libraryID);
}
}
for (let libraryID of libraryIDs) {
await this.expandLibrary(libraryID);
}
scope = row => row.isRecentlyRead();
}
else {
return;
}
this._selectAllScope = scope;
try {
this.selection.selectAll();
}
finally {
this._selectAllScope = null;
}
}
isSelectable = (index) => {
let treeRow = this.getRow(index);
return treeRow && !(treeRow.isSeparator() || treeRow.isHeader());
if (!treeRow || treeRow.isSeparator() || treeRow.isHeader()) {
return false;
}
// While a scoped Select All (Cmd/Ctrl-A) is running, restrict the new
// selection to the rows in the scope computed in _handleSelectAll()
if (this._selectAllScope) {
return this._selectAllScope(treeRow, index);
}
return true;
}
_closeContainer(row, skipMap) {
@ -2531,9 +2724,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var level = this.getLevel(row);
var nextRow = row + 1;
// Remove child rows
// Remove child rows, remapping any selected ones to the collapsed container
while ((nextRow < this._rows.length) && (this.getLevel(nextRow) > level)) {
this._removeRow(nextRow, true);
this.selection.adjustForRowRemoval(nextRow, true);
this._removeRow(nextRow, true, true);
}
this.selection.selectEventsSuppressed = false;
@ -2644,7 +2838,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
let collectionTable = document.getElementById("collection-tree").firstElementChild;
let isEmpty = this._isFilterEmpty();
let willBeEmpty = filterText.length == 0;
this._filter = filterText.toLowerCase();
this._filter = Zotero.Utilities.Internal.normalizeForSearch(filterText);
let currentRow = this.getRow(this.selection.focused) || this._hiddenFocusedRow;
let currentRowDisplayed = currentRow && this._includedInTree(currentRow.ref);
let shouldRestoreScrollPosition = willBeEmpty && !isEmpty && !this._treeWasFocused;
@ -2659,6 +2853,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this.selection.clearSelection();
}
await this.reload();
// reload() rebuilds the rows, so any selected indices from before now point at
// different (or out-of-range) rows. Clear them and re-select just the current
// row below, collapsing a multi-selection to the focused row while filtering.
this.selection.clearSelection();
if (currentRow) {
// Special treatment for when there are no filter matches
// Otherwise, selection.focused does not get updated by selectByID, which breaks ZoteroPane.
@ -2891,14 +3089,15 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (this._filterResultsCache[objectID] && !resetCache) {
return this._filterResultsCache[objectID];
}
// Filtering is case insensitive
let objectName = (object.name || "").toLowerCase();
// Filtering is case- and accent-insensitive
let normalize = Zotero.Utilities.Internal.normalizeForSearch;
let objectName = normalize(object.name || "");
// Special treatment to fetch the name for My Library or Feeds
if (objectID[0] == 'L' && object._ObjectType !== "Group") {
objectName = Zotero.getString('pane.collections.library').toLowerCase();
objectName = normalize(Zotero.getString('pane.collections.library'));
}
else if (objectID == 'feeds') {
objectName = Zotero.getString('pane.collections.feedLibraries').toLowerCase();
objectName = normalize(Zotero.getString('pane.collections.feedLibraries'));
}
let filterValue = this._filter;
@ -2971,6 +3170,7 @@ 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);
@ -2983,6 +3183,7 @@ 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;
@ -2997,7 +3198,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return 0;
}
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRetracted || showTrash);
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRecentlyRead || showRetracted || showTrash);
// If this isn't a manual open, set the initial state depending on whether
// there are child nodes
@ -3011,6 +3212,21 @@ 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
@ -3146,13 +3362,19 @@ 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;
// Since collections come first, if we reach something that's not a collection,
// stop
// If we reach something that's not a collection, stop
if (!treeRow.isCollection()) {
break;
}
@ -3215,7 +3437,11 @@ 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.ownerGlobal) {
if (!span.documentGlobal) {
// If this script was imported from a non-window context, we'll have a global object that looks like
// a Window and document.createElement() will succeed, but the returned Element object won't have
// an ownerGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
// an documentGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
// let's just abort if we get an invalid Element.
// TODO: Remove once we're using ES modules
return div;

View file

@ -33,13 +33,14 @@ 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, selected, title, renderTitle, width } = props;
const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, onAudioStatusClick, selected, title, renderTitle, width, audioStatus } = 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;
@ -72,6 +73,17 @@ 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>}
@ -98,10 +110,15 @@ 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
width: PropTypes.number,
audioStatus: PropTypes.shape({
active: PropTypes.bool.isRequired,
paused: PropTypes.bool.isRequired,
}),
};
@ -235,6 +252,11 @@ 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;
@ -419,6 +441,7 @@ const TabBar = forwardRef(function (props, ref) {
onTabClick={ handleTabClick}
onTabClose={ handleTabClose}
onTabMouseDown = { handleTabMouseDown }
onAudioStatusClick={handleAudioStatusClick}
/>
: null}
</div>
@ -456,6 +479,7 @@ const TabBar = forwardRef(function (props, ref) {
onTabClick={handleTabClick}
onTabClose={handleTabClose}
onTabMouseDown={handleTabMouseDown}
onAudioStatusClick={handleAudioStatusClick}
/>)}
<div ref={spacerRef} className="spacer"></div>
</div>
@ -487,6 +511,7 @@ 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,
@ -499,7 +524,12 @@ TabBar.propTypes = {
onTabClick: PropTypes.func.isRequired,
onTabMouseDown: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired
title: PropTypes.string.isRequired,
renderTitle: PropTypes.bool.isRequired,
audioStatus: PropTypes.shape({
active: PropTypes.bool.isRequired,
paused: PropTypes.bool.isRequired,
}),
})
).isRequired
};

View file

@ -33,6 +33,7 @@ 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;
@ -143,9 +144,10 @@ class TreeSelection {
* @returns {boolean} False if nothing to select and select handlers won't be called
*/
select(index, shouldDebounce) {
if (!this._tree.props.isSelectable(index)) return;
index = Math.max(0, index);
if (!this._tree.props.isSelectable(index)) return;
if (this.selected.size == 1 && this.isSelected(index)) {
this._updateTree(shouldDebounce);
return false;
}
@ -161,7 +163,12 @@ class TreeSelection {
this._tree.scrollToRow(index);
this._updateTree(shouldDebounce);
if (this._tree.invalidate) {
toInvalidate.forEach(this._tree.invalidateRow.bind(this._tree));
const rowCount = this._tree.props.getRowCount();
toInvalidate.forEach((idx) => {
// this._updateTree() may change row count
if (idx >= rowCount) return;
this._tree.invalidateRow(idx);
});
}
return true;
}
@ -251,6 +258,63 @@ class TreeSelection {
this._updateTree(shouldDebounce);
}
/**
* Adjusts selection indexes after a row is removed. A removed selected row is
* dropped from the selection, or remapped to the previous row when remapToPrevious
* is true (e.g., when collapsing a container remaps removed descendants to the
* container row, which precedes them). If the removal leaves nothing selected, the
* previous row is selected. If the focused or pivot row is removed, it moves to
* the previous row.
*
* @param {Number} index Removed row index
* @param {Boolean} [remapToPrevious=false] Select the previous row in place of a
* removed selected row instead of dropping it from the selection
*/
adjustForRowRemoval(index, remapToPrevious = false) {
let previousIndex = Math.max(index - 1, 0);
let selected = new Set();
for (let selectedIndex of this.selected) {
if (selectedIndex == index) {
if (remapToPrevious) {
selected.add(previousIndex);
}
}
else {
selected.add(selectedIndex > index ? selectedIndex - 1 : selectedIndex);
}
}
if (this.selected.size && !selected.size) {
selected.add(previousIndex);
}
this.selected = selected;
this.focused = this.focused == index
? previousIndex
: this.focused > index ? this.focused - 1 : this.focused;
this.pivot = this.pivot == index
? previousIndex
: this.pivot > index ? this.pivot - 1 : this.pivot;
}
/**
* Adjusts selection indexes after rows are inserted after a given row.
*
* @param {Number} index Row after which rows were inserted
* @param {Number} count Number of inserted rows
*/
adjustForRowInsertion(index, count) {
let selected = new Set();
for (let selectedIndex of this.selected) {
selected.add(selectedIndex > index ? selectedIndex + count : selectedIndex);
}
this.selected = selected;
if (this.focused > index) {
this.focused += count;
}
if (this.pivot > index) {
this.pivot += count;
}
}
/**
* Calls the onSelectionChange prop on the tree
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
@ -272,8 +336,9 @@ class TreeSelection {
}
set selectEventsSuppressed(val) {
let valChanged = val !== this._selectEventsSuppressed;
this._selectEventsSuppressed = val;
if (!val) {
if (!val && valChanged) {
this._updateTree();
if (this._tree.invalidate) {
this._tree.invalidate();
@ -323,6 +388,8 @@ 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);
@ -362,11 +429,24 @@ 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
@ -419,6 +499,8 @@ 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
@ -429,6 +511,11 @@ class VirtualizedTable extends React.Component {
multiSelect: PropTypes.bool,
requireSelection: PropTypes.bool,
stickySectionHeaders: PropTypes.bool,
isSectionHeader: PropTypes.func,
onSelectionChange: PropTypes.func,
// The below are for arrow-key navigation
@ -539,7 +626,7 @@ class VirtualizedTable extends React.Component {
*
* @param {Event} e
*/
_onKeyDown = (e) => {
_onKeyDown = async (e) => {
if (this.props.onKeyDown && this.props.onKeyDown(e) === false) return;
this._preventKeyboardScrolling(e);
@ -633,6 +720,25 @@ class VirtualizedTable extends React.Component {
}
if (shiftSelect || moveFocused) return;
// If selection count is greater than 1 and the focused row wasn't
// moved out of the selection - toggle open/closed all rows in that selection
// Otherwise if the focused row has moved out of the selection, toggle state
// of the focused row (handled below)
if (this.selection.count > 1
&& this.selection.isSelected(this.selection.focused)
&& [Zotero.arrowPreviousKey, Zotero.arrowNextKey].includes(e.key)) {
let open = e.key == Zotero.arrowNextKey;
let rows = Array.from(this.selection.selected)
.filter(index => this.props.isContainer(index)
&& !this.props.isContainerEmpty(index)
&& (open ? !this.props.isContainerOpen(index) : this.props.isContainerOpen(index)))
.sort((a, b) => b - a);
for (let index of rows) {
await this.toggleOpenState(index);
}
return;
}
switch (e.key) {
case Zotero.arrowPreviousKey:
@ -640,7 +746,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.props.toggleOpenState(this.selection.focused);
this.toggleOpenState(this.selection.focused);
}
else if (parentIndex != -1) {
this.onSelection(parentIndex);
@ -651,7 +757,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.props.toggleOpenState(this.selection.focused);
this.toggleOpenState(this.selection.focused);
}
else {
this.onSelection(this.selection.focused + 1);
@ -783,7 +889,16 @@ class VirtualizedTable extends React.Component {
* @param index {Number}
*/
scrollToRow(index) {
this._jsWindow && this._jsWindow.scrollToRow(index);
if (!this._jsWindow) return;
// When a sticky section header is pinned at the top of the view, it overlays the rows
// below it. Reserve a row's worth of space so a row scrolled up into view lands below the
// pinned header rather than behind it.
let topOffset = 0;
if (this.props.stickySectionHeaders && !this.props.isSectionHeader(index)
&& this._getSectionHeaderIndices().some(i => i < index)) {
topOffset = this._rowHeight;
}
this._jsWindow.scrollToRow(index, false, topOffset);
}
/**
@ -809,6 +924,11 @@ class VirtualizedTable extends React.Component {
this.selection.shiftSelect(index, toggleSelection, shouldDebounce);
}
else if (toggleSelection) {
// Don't allow toggling off the last selected row when a selection is required
if (this.props.requireSelection
&& this.selection.count === 1 && this.selection.isSelected(index)) {
return;
}
this.selection.toggleSelect(index, shouldDebounce);
}
else if (moveFocused) {
@ -821,7 +941,9 @@ class VirtualizedTable extends React.Component {
}
// Normal selection
else if (!toggleSelection) {
if (index > 0 && !this.props.isSelectable(index)) {
// Non-selectable rows (e.g. a sticky library header) are a no-op, including no
// scroll-to-row below -- otherwise clicking the header at index 0 jumps to the top
if (!this.props.isSelectable(index)) {
return;
}
this.selection.select(index, shouldDebounce);
@ -864,10 +986,7 @@ 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)}`);
@ -881,9 +1000,12 @@ 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 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 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 bColumnWidth = widthSum - aColumnWidth;
let onResizeData = {};
onResizeData[aColumn.dataKey] = aColumnWidth;
@ -1055,18 +1177,31 @@ class VirtualizedTable extends React.Component {
this._setXulTooltip();
this._topDiv.style.setProperty("--firstColumnExtraWidth", `${this.props.firstColumnExtraWidth || 0}px`);
window.addEventListener("resize", () => {
this._debouncedRerender();
});
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();
}
}
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) {
if (this.props.id !== prevProps.id
|| this.props.columns !== prevProps.columns) {
this._columns = new Columns(this);
this.forceUpdate();
}
@ -1112,13 +1247,13 @@ class VirtualizedTable extends React.Component {
return {
getItemCount: this.props.getRowCount,
itemHeight: this._rowHeight,
renderItem: this._renderItem,
renderItem: this._renderItem.bind(this),
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;
@ -1133,8 +1268,12 @@ class VirtualizedTable extends React.Component {
}
node.style.height = (index in this._customRowHeightMap ? this._customRowHeightMap[index] : this._rowHeight) + 'px';
node.id = this.props.id + "-row-" + index;
node.classList.toggle('odd', index % 2 == 1);
node.classList.toggle('even', index % 2 == 0);
// Row striping restarts at each section header, so every section's first row is
// the same shade (see _sectionRelativeIndex); without section headers this is just
// the row index
let stripeIndex = this._sectionRelativeIndex(index);
node.classList.toggle('odd', stripeIndex % 2 == 1);
node.classList.toggle('even', stripeIndex % 2 == 0);
if (!node.hasAttribute('role')) {
node.setAttribute('role', 'row');
}
@ -1183,7 +1322,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, { 'first-column': index === 0, dragging: this.state.draggingColumn == index },
const className = cx("cell", column.className, { dragging: this.state.draggingColumn == index },
{ "cell-icon": !!column.iconLabel });
return (<Draggable
onDragStart={this._handleColumnDragStart.bind(this, index)}
@ -1237,7 +1376,9 @@ 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,
@ -1262,7 +1403,12 @@ class VirtualizedTable extends React.Component {
}
let jsWindowProps = {
id: this._jsWindowID,
className: "virtualized-table-body",
// Reserve a scrollbar gutter when there are sticky section headers (see CSS), so the
// scrollbar doesn't float over the content. Without it a macOS overlay scrollbar
// overlaps the rows, and the opaque pinned header -- which must paint above the rows
// to occlude them, and so above the scrollbar -- covers the scrollbar's edge.
className: "virtualized-table-body"
+ (this.props.stickySectionHeaders ? " has-sticky-section-headers" : ""),
onFocus: (e) => {
if (e.target.id == this._jsWindowID) {
// Focus should always remain on the list itself.
@ -1275,7 +1421,23 @@ class VirtualizedTable extends React.Component {
<div {...props}>
{columnDragMarker}
{header}
<div {...jsWindowProps} />
<div {...jsWindowProps}>
{/* Pinned copy of the current section's header. Lives inside the scrolling
body (as its first child, before the windowed-list) and pins with
position: sticky, so its width tracks the body's content box automatically
and it lines up with the rows without any JS geometry. */}
{this.props.stickySectionHeaders
&& <div
className="virtualized-table-sticky-section-header"
ref={ref => this._stickyHeader = ref}
aria-hidden="true"
>
<div
className="virtualized-table-sticky-section-header-content"
ref={ref => this._stickyHeaderContent = ref}
/>
</div>}
</div>
</div>
);
}
@ -1287,6 +1449,7 @@ class VirtualizedTable extends React.Component {
if (!this._jsWindow) return;
this._jsWindow.invalidate();
this._updateWidth();
this._refreshStickySectionHeader();
}
/**
@ -1297,6 +1460,149 @@ class VirtualizedTable extends React.Component {
if (!this._jsWindow) return;
this._jsWindow.render();
this._updateWidth();
this._refreshStickySectionHeader();
}
// ------------------------ Sticky Section Headers ------------------------ //
/**
* Make a drop on the pinned header a no-op. Not preventing the default on dragover leaves
* it an invalid drop target (so no drop fires), and stopping propagation keeps the event
* from the body's handlers, which would otherwise allow the drop and treat it as a drop on
* the list. The header occludes a row, but dropping on it shouldn't act on that row.
*/
_rejectStickyHeaderDrop = (e) => {
e.stopPropagation();
if (e.type == 'dragover' && e.dataTransfer) {
e.dataTransfer.dropEffect = 'none';
}
}
/**
* The set of section-header rows can change whenever the row model changes, so drop
* the cached indices and repin. Called after the list is invalidated/rerendered.
*/
_refreshStickySectionHeader() {
if (!this.props.stickySectionHeaders) return;
this._sectionHeaderIndices = null;
this._stickyHeaderIndex = null;
this._updateStickySectionHeader();
}
/**
* The stripe index of a row, so striping restarts at each section header. The header
* counts as the section's first (unstriped) row, so the data row right below it is
* striped; subsequent rows alternate. Without section headers this is just the row
* index, preserving the normal whole-list striping (first row unstriped).
*/
_sectionRelativeIndex(index) {
let base = -1;
for (let headerIndex of this._getSectionHeaderIndices()) {
if (headerIndex <= index) {
base = headerIndex;
}
else {
break;
}
}
// No header above: stripe from the top (first row unstriped). With a header above,
// the header is the unstriped row 0, so the row below it (index - base == 1) is striped.
return base === -1 ? index : index - base;
}
/**
* Indices of all section-header rows, ascending. Cached until the row model changes.
*/
_getSectionHeaderIndices() {
// Only trees that opt into section headers have them
if (!this.props.stickySectionHeaders) {
return [];
}
if (this._sectionHeaderIndices) {
return this._sectionHeaderIndices;
}
let indices = [];
let count = this.props.getRowCount();
for (let i = 0; i < count; i++) {
if (this.props.isSectionHeader(i)) {
indices.push(i);
}
}
this._sectionHeaderIndices = indices;
return indices;
}
/**
* Pin the header of the section currently at the top of the view, pushing it up as
* the next section's header scrolls into it, and reuse the consumer's renderItem so the
* pinned copy matches the real header row's appearance.
*
* The pinned header is the first child of the scrolling body and stays put via position:
* sticky, taking its width from the body's content box so it lines up with the rows. The
* outer element has zero height (so it adds no space to the flow); the inner (opaque)
* element holds the rendered header, overflows downward over the rows, and is the part that
* translates, so a header pushed up by the next section is clipped at the top of the body
* (by the body's own overflow) rather than spilling over the column header.
*/
_updateStickySectionHeader = () => {
if (!this.props.stickySectionHeaders || !this._stickyHeader || !this._jsWindow) {
return;
}
let clip = this._stickyHeader;
let content = this._stickyHeaderContent;
let headerIndices = this._getSectionHeaderIndices();
let scrollTop = this._jsWindow.targetElement.scrollTop;
// The section in view is the last header at or above the top of the view; the
// next header (if any) is what pushes it up
let currentIndex = -1;
let nextIndex = -1;
for (let index of headerIndices) {
if (this._jsWindow._getItemPosition(index) <= scrollTop) {
currentIndex = index;
}
else {
nextIndex = index;
break;
}
}
// Show the pinned copy only once the header row has scrolled up past the top edge of
// the view
let stuck = currentIndex != -1
&& scrollTop > this._jsWindow._getItemPosition(currentIndex);
if (!stuck) {
clip.style.display = 'none';
this._stickyHeaderIndex = null;
return;
}
clip.style.display = '';
clip.classList.add('stuck');
// Re-render only when the pinned section changes. Use _renderItem (not the raw
// renderItem prop) so the pinned copy gets the same post-processing as a real row
// (e.g. the tree's indent/twisty spacer), then drop its id to avoid duplicating the
// real row's.
if (this._stickyHeaderIndex !== currentIndex) {
this._stickyHeaderIndex = currentIndex;
let node = this._renderItem(currentIndex);
node.removeAttribute('id');
// Strip the focus ring: focus defaults to row 0, which can be a header, but a
// pinned header shouldn't show focus
node.classList.remove('focused');
content.textContent = '';
content.appendChild(node);
}
// Geometry is all CSS now: the clip is the first child of the scrolling body, has zero
// height (so it takes no space in the flow and the rows below aren't shifted down), and
// pins itself with position: sticky. Its content overflows downward over the rows and
// gets its width from the body's content box, so it lines up with the real rows.
// Push the pinned header up as the next section's header approaches the top
let translateY = 0;
if (nextIndex != -1) {
let nextTop = this._jsWindow._getItemPosition(nextIndex) - scrollTop;
if (nextTop < this._rowHeight) {
translateY = nextTop - this._rowHeight;
}
}
content.style.transform = `translateY(${translateY}px)`;
}
updateFontSize = () => {
@ -1359,8 +1665,6 @@ class VirtualizedTable extends React.Component {
return parseFloat(height.split('px')[0]);
}
_debouncedRerender = Zotero.Utilities.debounce(this.rerender, 200);
_updateWidth() {
if (!this.props.showHeader) return;
const jsWindow = document.querySelector(`#${this._jsWindowID} .windowed-list`);
@ -1407,10 +1711,19 @@ 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);
await new Promise((resolve) => {this.forceUpdate(resolve)});
return new Promise((resolve) => {
this.forceUpdate(() => {
this._jsWindow.invalidate();
resolve();
});
})
}
// Set aria-activedescendant on table container
@ -1423,6 +1736,143 @@ 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.
@ -1468,9 +1918,10 @@ 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;
}
column = Object.assign({}, column, columnsSettings[column.dataKey]);
// Don't load column settings for disabled columns (they are overriden to be hidden)
column = Object.assign({}, column, column.disabled ? {} : columnsSettings[column.dataKey]);
column.className = cx(column.className, column.dataKey, column.dataKey + this._cssSuffix,
{ 'fixed-width': column.fixedWidth });
if (column.type) {
@ -1490,33 +1941,22 @@ var Columns = class {
// if new columns got added recently
columns.forEach((column, index) => column.ordinal = index);
// Setting column widths
const visibleColumns =
columns.reduce((accumulator, column) => accumulator += column.hidden ? 0 : 1, 0);
const containerWidth = this._virtualizedTable._containerWidth;
// Compute initial CSS widths for visible columns. Widths are not persisted
// from here they flow to disk only when the user resizes (see onResize).
let columnWidths = {};
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);
}
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);
}
// 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() {
@ -1526,16 +1966,17 @@ var Columns = class {
if (this._stylesheet) {
this._columnStyleMap = {};
for (let i = 0; i < this._stylesheet.sheet.cssRules.length; i++) {
const cssText = this._stylesheet.sheet.cssRules[i].cssText;
const dataKey = cssText.slice(1, cssText.indexOf('-'));
const selector = this._stylesheet.sheet.cssRules[i].selectorText;
const dataKey = selector.slice(1, selector.length - this._cssSuffix.length);
this._columnStyleMap[dataKey] = i;
}
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
let column = this._virtualizedTable.props.columns[i];
if (column.dataKey in this._columnStyleMap) continue;
let escapedDataKey = window.CSS.escape(column.dataKey);
if (escapedDataKey in this._columnStyleMap) continue;
const ruleIndex = Object.keys(this._columnStyleMap).length;
this._stylesheet.sheet.insertRule(`.${window.CSS.escape(column.dataKey) + this._cssSuffix} {flex-basis: 100px}`, ruleIndex);
this._columnStyleMap[column.dataKey] = ruleIndex;
this._stylesheet.sheet.insertRule(`.${escapedDataKey + this._cssSuffix} {flex-basis: 100px}`, ruleIndex);
this._columnStyleMap[escapedDataKey] = ruleIndex;
}
} else {
this._stylesheet = document.createElement('style');
@ -1544,8 +1985,9 @@ var Columns = class {
this._columnStyleMap = {};
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
let column = this._virtualizedTable.props.columns[i];
this._stylesheet.sheet.insertRule(`.${window.CSS.escape(column.dataKey) + this._cssSuffix} {flex-basis: 100px}`, i);
this._columnStyleMap[column.dataKey] = i;
let escapedDataKey = window.CSS.escape(column.dataKey);
this._stylesheet.sheet.insertRule(`.${escapedDataKey + this._cssSuffix} {flex-basis: 100px}`, i);
this._columnStyleMap[escapedDataKey] = i;
}
}
}
@ -1578,24 +2020,6 @@ 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
@ -1607,28 +2031,36 @@ 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);
const styleIndex = this._columnStyleMap[dataKey];
if (column.hidden) continue;
const styleIndex = this._columnStyleMap[window.CSS.escape(dataKey)];
const columnPadding = column.iconLabel ? 0 : COLUMN_PADDING;
if (storePrefs && !column.fixedWidth) {
column.width = width;
prefs[dataKey] = this._getColumnPrefsToPersist(column);
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 (column.fixedWidth) {
width = column.width;
cssWidth = 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', `${width}px`, 'important');
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `${width}px`, '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');
} else {
width = (width - columnPadding);
Zotero.debug(`Columns ${dataKey} width ${width}`);
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `${width}px`);
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);
}
}
if (storePrefs) {
@ -1646,7 +2078,6 @@ 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();
@ -1686,7 +2117,6 @@ 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();
@ -1696,15 +2126,34 @@ var Columns = class {
const column = this._columns[index];
column.hidden = !column.hidden;
let prefs = this._getPrefs();
if (prefs[column.dataKey]) {
prefs[column.dataKey].hidden = column.hidden;
if (!column.hidden && !column.width && !(column.fixedWidth || column.staticWidth)) {
column.width = this._computeFlexWidth(column);
}
this._adjustColumnWidths();
let prefs = this._getPrefs();
prefs[column.dataKey] = prefs[column.dataKey] || {};
prefs[column.dataKey].hidden = column.hidden;
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;
@ -1724,8 +2173,9 @@ var Columns = class {
}
}
});
this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
let result = this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
this._virtualizedTable.forceUpdate();
return result;
}
getAsArray() {
@ -1735,8 +2185,8 @@ var Columns = class {
function renderCell(index, data, column, dir = null) {
column = column || { dataKey: "" };
if (column.renderer) {
return column.renderer(index, data, column, dir);
if (column.renderCell) {
return column.renderCell(index, data, column, dir);
}
let span = document.createElement('span');
span.className = `cell ${column.className}`;
@ -1837,12 +2287,10 @@ function makeRowRenderer(getRowData) {
else {
div.appendChild(renderCell(index, rowData[column.dataKey], column));
}
let columnName = column.label;
if (column.label in Zotero.Intl.strings) {
columnName = Zotero.getString(column.label);
}
if (typeof rowData[column.dataKey] === "string") {
ariaLabel += `${columnName}: ${rowData[column.dataKey]} `;
let columnName = formatColumnName(column);
let value = rowData[column.dataKey];
if (typeof value === "string" || typeof value === "number") {
ariaLabel += `${columnName}: ${value} `;
}
else {
ariaLabel += `${columnName} `;
@ -1862,7 +2310,8 @@ function formatColumnName(column) {
if (column.label in Zotero.Intl.strings) {
return Zotero.getString(column.label);
}
else if (/^[^\s]+\w\.\w[^\s]+$/.test(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)) {
try {
let labelString = Zotero.getString(column.label);
if (labelString !== column.label) {
@ -1877,6 +2326,8 @@ 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,8 +205,11 @@ module.exports = class {
* @param {Integer} index
* @param {Boolean} forceScrollToTop If true, the row will be scrolled to the top of the scrollbox
* even if it is below the current scroll window.
* @param {Integer} topOffset Amount of space reserved at the top of the scrollbox (e.g. for a
* sticky section header that overlays the rows). When scrolling a row into view from above, the
* row is positioned below this offset rather than flush with the top edge.
*/
scrollToRow(index, forceScrollToTop = false) {
scrollToRow(index, forceScrollToTop = false, topOffset = 0) {
const { scrollOffset } = this;
const itemCount = this._getItemCount();
const height = this.getWindowHeight();
@ -214,11 +217,18 @@ module.exports = class {
index = Math.max(0, Math.min(index, itemCount - 1));
let startPosition = this._getItemPosition(index);
let endPosition = this._getItemPosition(index + 1);
if (startPosition < scrollOffset) {
// 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) {
this.scrollTo(startPosition);
return;
}
if (startPosition - topOffset < scrollOffset) {
this.scrollTo(startPosition - topOffset);
}
else if (endPosition > scrollOffset + height) {
this.scrollTo(forceScrollToTop ? startPosition : endPosition - height - 1);
this.scrollTo(endPosition - height - 1);
}
}

View file

@ -61,6 +61,9 @@ 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.
@ -101,10 +104,13 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
componentDidUpdate(_prevProps, _prevState) {
Zotero.debug("Tag selector updated");
// If we changed collections, scroll to top
if (this.collectionTreeRow && this.collectionTreeRow.id != this.prevTreeViewID) {
this.tagListRef.current.scrollToTop();
this.prevTreeViewID = this.collectionTreeRow.id;
// If we changed the set of collections, scroll to top
if (this.collectionTreeRows) {
let treeViewIDs = this.collectionTreeRows.map(row => row.id).sort();
if (!Zotero.Utilities.arrayEquals(treeViewIDs, this.prevTreeViewIDs)) {
this.tagListRef.current.scrollToTop();
this.prevTreeViewIDs = treeViewIDs;
}
}
}
@ -114,24 +120,107 @@ 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({ collectionTreeRow, libraryID }) {
async onItemViewChanged({ collectionTreeRows, libraryID }) {
Zotero.debug('Updating tag selector from current view');
var prevLibraryID = this.libraryID;
this.collectionTreeRow = collectionTreeRow;
this.libraryID = libraryID;
var prevLibraryIDs = this.libraryIDs;
this.collectionTreeRows = collectionTreeRows;
this.libraryIDs = this._getSelectedLibraryIDs(collectionTreeRows, libraryID);
this.libraryID = this.libraryIDs[0];
var newState = {
loaded: true
};
if (prevLibraryID != libraryID) {
if (libraryID) {
newState.tagColors = Zotero.Tags.getColors(libraryID);
}
else {
newState.tagColors = new Map();
}
if (!Zotero.Utilities.arrayEquals(prevLibraryIDs, this.libraryIDs)) {
newState.tagColors = this._getScopeColors();
}
var { tags, scope } = await this.getTagsAndScope();
newState.tags = tags;
@ -145,14 +234,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
if (ids.some(val => val.split('/')[1] == 'tagColors')) {
Zotero.debug("Updating tag selector after tag color change");
this.setState({
tagColors: Zotero.Tags.getColors(this.libraryID)
tagColors: this._getScopeColors()
});
}
return;
}
// Ignore anything other than deletes in duplicates view
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
if (this.collectionTreeRows?.[0]?.isDuplicates()) {
switch (event) {
case 'delete':
case 'trash':
@ -192,11 +281,9 @@ 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.collectionTreeRow.getTags([type], tagIDs));
changedTagsInScope.push(...await this._safeGetTags([type], tagIDs));
if (this.displayAllTags) {
changedTagsInView.push(
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
);
changedTagsInView.push(...await this._getAllTagsInLibraries(tagIDs));
}
}
if (!this.displayAllTags) {
@ -315,11 +402,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
}
async getTagsAndScope() {
var tags = await this.collectionTreeRow.getTags();
var tags = await this._safeGetTags();
// The scope is all visible tags, not all tags in the library
var scope = new Set(tags.map(t => t.tag));
if (this.displayAllTags) {
tags = await Zotero.Tags.getAll(this.libraryID);
tags = await this._getAllTagsInLibraries();
}
// If tags haven't changed, return previous array without sorting again
@ -497,10 +584,10 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
});
tags = coloredTags.concat(tags);
// Filter
// Filter (case- and accent-insensitive, matching search conditions)
if (this.state.searchString) {
let lcStr = this.state.searchString.toLowerCase();
tags = tags.filter(tag => tag.tag.toLowerCase().includes(lcStr));
let normalizedStr = Zotero.Utilities.Internal.normalizeForSearch(this.state.searchString);
tags = tags.filter(tag => Zotero.Utilities.Internal.normalizeForSearch(tag.tag).includes(normalizedStr));
}
// Prepare tag objects for list component
@ -563,6 +650,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
for (let i = 0; i < tagContextMenu.childNodes.length; i++) {
tagContextMenu.childNodes[i].disabled = this.state.viewOnly;
}
// In a cross-library selection, disable per-library actions that would only
// affect one of the selected libraries. Deleting still works -- it removes the
// tag from all selected libraries (see openDeletePrompt())
if (this.multiLibrary) {
for (let id of ['assign-color-tag', 'rename-tag', 'split-tag']) {
document.getElementById(id).disabled = true;
}
}
ev.preventDefault();
tagContextMenu.openPopupAtScreen(
@ -644,7 +739,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
ids = ids.split(',');
var items = Zotero.Items.get(ids);
var value = elem.textContent;
Zotero.UndoHistory.stageAction(
remove ? 'undo-action-remove-tag' : 'undo-action-add-tag',
{ count: items.length }
);
for (let i=0; i<items.length; i++) {
let item = items[i];
if (remove) {
@ -720,6 +819,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
if (dataOut.result.op === 'split') {
const itemIDs = await Zotero.Tags.getTagItems(this.libraryID, oldTagID);
await Zotero.DB.executeTransaction(async () => {
Zotero.UndoHistory.stageAction('undo-action-split-tag');
for (const itemID of itemIDs) {
const item = await Zotero.Items.getAsync(itemID);
const tagType = item.getTagType(oldTagName);
@ -783,12 +883,16 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
var tagID = Zotero.Tags.getID(this.contextTag.name);
if (tagID) {
await Zotero.Tags.removeFromLibrary(this.libraryID, tagID);
}
// If only a tag color setting, remove that
else {
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
// Delete from every selected library. removeFromLibrary() only clears the color
// for tags that have items in the library, so also clear any remaining color-only
// setting (e.g. a tag that's a real tag in one library but only colored in another)
for (let libraryID of this.libraryIDs) {
if (tagID) {
await Zotero.Tags.removeFromLibrary(libraryID, tagID);
}
if (Zotero.Tags.getColors(libraryID).has(this.contextTag.name)) {
await Zotero.Tags.setColor(libraryID, this.contextTag.name, false);
}
}
}

View file

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

View file

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

View file

@ -28,12 +28,13 @@
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'],
@ -50,8 +51,10 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['note-box', 'chrome://zotero/content/elements/noteBox.js'],
['note-editor', 'chrome://zotero/content/elements/noteEditor.js'],
['notes-box', 'chrome://zotero/content/elements/notesBox.js'],
['query-textbox', 'chrome://zotero/content/elements/queryTextbox.js'],
['quick-search-textbox', 'chrome://zotero/content/elements/quickSearchTextbox.js'],
['related-box', 'chrome://zotero/content/elements/relatedBox.js'],
['search-textbox', 'chrome://zotero/content/elements/searchTextbox.js'],
['shadow-autocomplete-input', 'chrome://zotero/content/elements/shadowAutocompleteInput.js'],
['split-menu-button', 'chrome://zotero/content/elements/splitMenuButton.js'],
['tabs-menu-panel', 'chrome://zotero/content/elements/tabsMenuPanel.js'],
@ -62,6 +65,7 @@ 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'],
@ -78,6 +82,7 @@ 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);
@ -335,6 +340,89 @@ 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: [
@ -354,7 +442,6 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
}
],
mac: [
"wizard",
{
element: "dialog",
// The `attachShadow` are cleared in <dialog>, we need to monkey-patch after `connectedCallback`.

View file

@ -1,5 +1,9 @@
/* 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,6 +26,10 @@
"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;
@ -39,6 +43,10 @@
</collapsible-section>
`);
_mode = 'view';
_fieldAlternatives = {};
get item() {
return this._item;
}
@ -66,6 +74,36 @@
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');
@ -100,7 +138,13 @@
throw new Error('Item has not been added to library');
}
this._item.setField('abstractNote', this._abstractField.value);
await this._item.saveTx();
await this._item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString('abstractNote'),
count: 1
}
});
}
this._forceRenderAll();
}
@ -157,6 +201,59 @@
}
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

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

View file

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

View file

@ -39,13 +39,23 @@
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) {
@ -75,7 +85,7 @@
let topLevelItems = Zotero.Items.getTopLevel(this.items);
// Remove collapsible sections for top-level items whose annotations are no longer selected
// Remove collapsible sections for top-level items that no longer have any annotations
for (let section of [...this.querySelectorAll("collapsible-section")]) {
let parentID = section.dataset.pane.split("-")[1];
if (!topLevelItems.some(item => item.id == parentID)) {
@ -83,7 +93,8 @@
}
}
for (let parentItem of topLevelItems) {
let selectedAnnotations = this.items.filter(item => item.topLevelItem.id == parentItem.id);
let allAnnotations = this.items.filter(item => item.topLevelItem.id == parentItem.id);
let visibleAnnotations = allAnnotations.filter(item => this._passesFilter(item));
// 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) {
@ -98,17 +109,23 @@
section.appendChild(sectionBody);
this._body.append(section);
}
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);
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));
}
}
// Remove annotation rows for annotations that are no longer selected
// Remove annotation rows for annotations that are no longer in this.items
for (let row of [...this.querySelectorAll("annotation-row")]) {
let rowID = row.getAttribute("annotation-id");
if (!this.items.some(obj => obj.id == rowID)) {
@ -128,6 +145,40 @@
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,6 +31,7 @@
<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"/>
@ -39,7 +40,7 @@
_annotation = null;
static get observedAttributes() {
return ['annotation-id'];
return ['annotation-id', 'action'];
}
attributeChangedCallback(name, oldValue, newValue) {
@ -47,6 +48,9 @@
case 'annotation-id':
this._annotation = Zotero.Items.get(newValue);
break;
case 'action':
this.action = newValue;
break;
}
this.render();
}
@ -60,6 +64,21 @@
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');
@ -69,7 +88,7 @@
}
render() {
if (!this.initialized) return;
if (!this.initialized || !this._annotation) return;
this._title.textContent = Zotero.getString('pdfReader.page') + ' '
+ (this._annotation.annotationPageLabel || '-');
@ -79,6 +98,14 @@
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)) {
@ -126,7 +153,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(`pdfReader.${this.annotation.annotationType}Annotation`);
let annotationTypeStr = Zotero.getString(`reader-${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,6 +24,11 @@
*/
{
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,6 +28,10 @@
{
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(`
@ -320,12 +324,17 @@
}
notify(event, _type, ids, _extraData) {
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' && 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 (event != 'modify' || !this.item?.id || !ids.includes(this.item.id)) return;
Promise.all([
this.updateInfo(),
this.updatePreview()
@ -340,6 +349,9 @@
this._previewDiscarded = false;
this.previewElem.render();
}
else {
this.previewElem.keepAlive();
}
return;
}
@ -581,7 +593,7 @@
}
if (show) {
reindexButton.setAttribute('hidden', false);
reindexButton.removeAttribute('hidden');
}
else {
reindexButton.setAttribute('hidden', true);
@ -753,7 +765,13 @@
_handleTitleBlur = () => {
this.item.setField('title', this._id('title').value);
this.item.saveTx();
this.item.saveTx({
undoAction: 'undo-action-edit-field',
undoActionArgs: {
field: Zotero.ItemFields.getLocalizedString('title'),
count: 1
}
});
};
_handleFileNameFocus = () => {

View file

@ -24,6 +24,11 @@
*/
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class PreviewRenderAbortError extends Error {
constructor() {
super("AttachmentPreview render aborted");
@ -263,7 +268,7 @@
notify(event, type, ids, extraData) {
if (!this.item) return;
if (this.isReaderType && this._reader) {
if (this.isReaderType && this._reader && this._reader._internalReader) {
// Following chrome/content/zotero/xpcom/reader.js
if (event === "delete") {
let disappearedIDs = this._reader.annotationItemIDs.filter(x => ids.includes(x));
@ -345,6 +350,19 @@
});
}
/**
* Update the last task UID to keep the preview alive. This prevents deferred discard when
* the preview is still in use. Useful when the preview moves out of view but later enters
* the view again before the discard timeout.
*/
async keepAlive() {
if (!this._lastTaskUID || this._isProcessingTask) {
return;
}
let newUID = `${Date.now()}-${Math.random()}`;
this._lastTaskUID = newUID;
}
/**
* Clear all pending tasks and reset processing states.
*/
@ -405,13 +423,18 @@
this._debug(`Processing task ${task.type} (${uid})`);
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;
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}`);
}
this._isProcessingTask = false;
@ -436,6 +459,12 @@
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;
@ -474,7 +503,11 @@
}
}
finally {
this.setPreviewStatus(success ? "success" : "fail");
// 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._isRendering = false;
this._debug(`Render processed, item ${itemID} ${success ? "succeeded" : "failed"}`);
@ -517,6 +550,16 @@
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");
@ -612,10 +655,10 @@
*/
async _renderReader(itemID) {
this.setPreviewStatus("loading");
// This only need to be awaited during first load
// 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.
await this._previewInitializePromise.promise;
// This should be awaited in the following refreshes
await this._nextPreviewInitializePromise.promise;
this._tryAbortRender(itemID);
@ -670,6 +713,13 @@
_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,6 +25,11 @@
{
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" disabled="false">
<toolbarbutton class="zotero-clicky zotero-clicky-minus" tabindex="0" data-l10n-id="section-button-remove">
<image class="toolbarbutton-icon"/>
<label class="toolbarbutton-text" />
</toolbarbutton>

View file

@ -26,6 +26,11 @@
"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">
@ -214,6 +219,9 @@
}
}
}
else {
this.previewElem.keepAlive();
}
return;
}
this._renderStage = "final";

View file

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

View file

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

View file

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

View file

@ -24,26 +24,39 @@
*/
{
class DuplicatesMergePane extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<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>
const { ItemPaneContainerBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneContainerBase.mjs",
{ global: "current" }
);
<vbox id="zotero-duplicates-merge-info-box-container">
<info-box id="zotero-duplicates-merge-info-box"/>
let lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
mergeItems: "chrome://zotero/content/mergeItems.mjs",
});
class DuplicatesMergePane extends ItemPaneContainerBase {
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 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>
<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() {
@ -52,6 +65,10 @@
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(
@ -108,15 +125,14 @@
// Update the UI
//
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 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 alternatives = oldestItem.multiDiff(otherItems, this._ignoreFields);
if (alternatives) {
// Populate richlistbox with Date Added values from all items
let dateList = document.getElementById('zotero-duplicates-merge-original-date');
let dateList = this.querySelector('#zotero-duplicates-merge-original-date');
dateList.innerHTML = '';
let numRows = 0;
@ -138,9 +154,9 @@
document.l10n.setArgs(button, { count: otherItems.length + 1 });
versionSelect.hidden = fieldSelect.hidden = !alternatives;
itembox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
// Since the header of the collapsible section is hidden, the section has to be opened
itembox.open = true;
this._infoBox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
this._infoBox.open = true;
this._abstractBox.open = true;
this.setMaster(0);
@ -148,8 +164,8 @@
}
setMaster(pos) {
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
itembox.mode = 'fieldmerge';
this._infoBox.mode = 'fieldmerge';
this._abstractBox.mode = 'fieldmerge';
this._otherItems = this._items.concat();
let item = this._otherItems.splice(pos, 1)[0];
@ -163,26 +179,36 @@
for (let i in alternatives) {
alternatives[i].unshift(itemValues[i] !== undefined ? itemValues[i] : '');
}
itembox.fieldAlternatives = alternatives;
this._infoBox.fieldAlternatives = alternatives;
this._abstractBox.fieldAlternatives = {
abstractNote: alternatives.abstractNote
};
}
else {
this._infoBox.fieldAlternatives = {};
this._abstractBox.fieldAlternatives = {};
}
this._masterItem = item;
itembox.item = item.clone();
// The item.id is null which equals to _lastRenderItemID, so we need to force render it
itembox._forceRenderAll();
// 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();
}
async merge() {
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
Zotero.CollectionTreeCache.clear();
// Update master item with any field alternatives from the item box
// Update master item with any field alternatives chosen in the boxes
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 } = itembox.item.toJSON();
const { relations: _r, collections: _c, tags: _t, ...keep } = this._infoBox.item.toJSON();
Object.assign(json, keep);
this._masterItem.fromJSON(json);
Zotero.Items.merge(this._masterItem, this._otherItems);
await lazy.mergeItems(this._masterItem, this._otherItems);
}
}
customElements.define("duplicates-merge-pane", DuplicatesMergePane);

View file

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

View file

@ -0,0 +1,389 @@
/*
***** 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,6 +41,8 @@ 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>
`);
@ -72,11 +74,23 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
if (this.getAttribute("noautohide") == 'true'
&& !this.hasAttribute('forward')) {
let listener = () => {
this.panel.removeEventListener("click", listener);
let dismiss = () => {
if (this._pref) {
Zotero.Prefs.set(this._pref, true);
}
this.panel.hidePopup();
};
this.panel.addEventListener("click", listener);
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();
}
});
}
}
@ -84,6 +98,7 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
* @param {Object} [options]
* @param {String} [options.text] Text to use in place of firstRunGuidance.<about>
* @param {DOMElement} [options.forEl] Anchor node
* @param {Object} [options.l10nArgs] Localization arguments for text in the panel
* @param {Boolean} [options.force] Show even if already shown
*/
async show(options) {
@ -121,10 +136,10 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
if (!useLastText) {
if (!text) {
text = await document.l10n.formatValue("first-run-guidance-" + about);
text = await document.l10n.formatValue("first-run-guidance-" + about, options.l10nArgs || {});
}
text = text.split("\n");
var descriptionNode = document.querySelector('.panel-text');
var descriptionNode = this.querySelector('.panel-text');
while (descriptionNode.hasChildNodes()) {
descriptionNode.removeChild(descriptionNode.firstChild);
@ -143,14 +158,49 @@ 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);
const anchorOffset = getAnchorOffset(forEl, this.panel);
// On Win/Linux, the arrow appears disconnected from the panel if too close to the edge
const padding = Zotero.isMac ? 5 : 10;
const anchorOffset = getAnchorOffset(forEl, this.panel, padding);
this.panel.style.setProperty('--anchor-x', `${anchorOffset}px`);
if (pref) {
Zotero.Prefs.set(pref, true);
if (this.getAttribute('noautohide') === 'true') {
this._pref = pref;
}
else {
Zotero.Prefs.set(pref, true);
}
}
};

File diff suppressed because it is too large Load diff

View file

@ -24,32 +24,12 @@
*/
{
const waitFrame = async () => {
return waitNoLongerThan(new Promise((resolve) => {
requestAnimationFrame(resolve);
}), 30);
};
const waitFrames = async (n) => {
for (let i = 0; i < n; i++) {
await waitFrame();
}
};
const { ItemPaneContainerBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneContainerBase.mjs",
{ global: "current" }
);
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 {
class ItemDetails extends ItemPaneContainerBase {
content = MozXULElement.parseXULToFragment(`
<hbox id="zotero-view-item-container" class="zotero-view-item-container" flex="1">
<html:div class="zotero-view-item-main">
@ -88,6 +68,18 @@
this._item = item;
}
get extraItems() {
return this._extraItems ?? [];
}
set extraItems(val) {
if (!Array.isArray(val)) {
return;
}
this._extraItems = val.filter(item => item instanceof Zotero.Item && item.isRegularItem());
}
/*
* For contextPane update
*/
@ -116,20 +108,20 @@
this._tabID = tabID;
}
get tabType() {
return this.getAttribute('tabType');
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRows(collectionTreeRows) {
this._collectionTreeRows = collectionTreeRows;
}
set tabType(tabType) {
this.setAttribute('tabType', tabType);
get supportsPinning() {
return true;
}
get collectionTreeRow() {
return this._collectionTreeRow;
}
set collectionTreeRow(collectionTreeRow) {
this._collectionTreeRow = collectionTreeRow;
get supportsReorder() {
return true;
}
get pinnedPane() {
@ -151,47 +143,12 @@
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 this._sidenav;
return super.sidenav;
}
set sidenav(sidenav) {
this._sidenav = sidenav;
sidenav.container = this;
super.sidenav = sidenav;
// Manually update once and further changes will be synced automatically to sidenav
this.forceUpdateSideNav();
}
@ -293,7 +250,12 @@
box.tabID = this.tabID;
box.tabType = this.tabType;
box.item = item;
box.collectionTreeRow = this.collectionTreeRow;
box.extraItems = this.extraItems;
box.collectionTreeRows = this.collectionTreeRows;
if (this.extraItems.length > 0) {
// mark everything, except the header and the info pane, as hidden
box.hidden = box.dataset.pane !== 'info' && box !== this._header;
}
// Discard hidden panes
if (box.hidden && box.discard) {
box.discard();
@ -337,9 +299,9 @@
if (!this.isPaneVisible(box.dataset.pane)) {
continue;
}
await waitNoLongerThan(box.asyncRender(), 500);
await this._waitNoLongerThan(box.asyncRender(), 500);
// Make sure the layout is updated for next isPaneVisible check
await waitDOMUpdate();
await this._waitDOMUpdate();
}
}
@ -431,22 +393,6 @@
}
};
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 = [];
@ -543,111 +489,29 @@
}
}
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;
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
_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);
}
// 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
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
for (let i = paneIndex; i < panes.length; i++) {
let nextPane = panes[i];
// Stop if the pane is not visible anymore
// Stop at the first pane no longer in view
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 waitDOMUpdate();
await this._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,8 +40,11 @@
previousfocus="zotero-items-tree" />
<duplicates-merge-pane id="zotero-duplicates-merge-pane" />
<annotation-items-pane id="zotero-annotations-pane" />
<groupbox id="batch-edit-prompt" pack="center" align="center" data-l10n-id="item-pane-batch-editing-prompt">
<description id="batch-edit-prompt-message" />
<button id="batch-edit-prompt-enable" data-l10n-id="item-pane-batch-editing-enable" />
</groupbox>
</deck>
<item-pane-sidenav id="zotero-view-item-sidenav" no-context-notes="true" class="zotero-view-item-sidenav"/>
`);
@ -52,6 +55,8 @@
this._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
this._messagePane = this.querySelector("#zotero-item-message");
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
this._deck = this.querySelector("#zotero-item-pane-content");
@ -59,6 +64,14 @@
this._notifierID = Zotero.Notifier.registerObserver(this, ['item']);
this._batchEditEnableBtn.addEventListener("command", () => {
this._isBatchEditEnabled = true;
this._setBatchEditCollapsible(true);
this.render();
this.updateItemPaneButtons();
});
this._isBatchEditEnabled = false;
this._translationTarget = null;
}
@ -74,12 +87,12 @@
this._data = data;
}
get collectionTreeRow() {
return this._collectionTreeRow;
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRow(val) {
this._collectionTreeRow = val;
set collectionTreeRows(val) {
this._collectionTreeRows = val;
}
get itemsView() {
@ -100,12 +113,12 @@
}
get mode() {
return ["message", "item", "note", "duplicates"][this._deck.selectedIndex];
return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt"][this._deck.selectedIndex];
}
/**
* Set mode of item pane
* @param {"message" | "item" | "note" | "duplicates"} type view type
* @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt"} type view type
*/
set mode(type) {
this.setAttribute("view-type", type);
@ -126,10 +139,26 @@
if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
return renderStatus = this.renderAnnotations(this.data);
}
// reset the batch editing flag
let IDs = this.data.map(item => item.id);
if (!(IDs.length === this._prevIDs?.length && IDs.every((id, i) => id === this._prevIDs?.[i]))) {
if (this._isBatchEditEnabled) {
this._setBatchEditCollapsible(false);
}
this._isBatchEditEnabled = false;
this._prevIDs = IDs;
}
// Multiple items selected (not duplicates)
if (!this.collectionTreeRows[0].isDuplicates() && this.data.length > 1 && this.data.every(item => item.isRegularItem() && !item.isFeedItem)) {
// Hide the batch editing UI until the user opts-in
renderStatus = this._isBatchEditEnabled ? this.renderItemPane(this.data) : this.renderBatchEditorPrompt();
}
// Single item selected
if (this.data.length == 1) {
else if (this.data.length === 1) {
let item = this.data[0];
// If a collection or search is selected, it must be in the trash.
if (item instanceof Zotero.Collection || item instanceof Zotero.Search) {
renderStatus = this.renderMessage();
@ -141,7 +170,7 @@
renderStatus = this.renderItemPane(item);
}
}
// Zero or multiple items selected
// No items selected or multiple, but includes some irregular items
else {
renderStatus = this.renderMessage();
}
@ -150,7 +179,7 @@
notify(action, type) {
if (type == 'item' && action == 'modify') {
if (this.collectionTreeRow && this.collectionTreeRow.isFeedsOrFeed()) {
if (this.collectionTreeRows?.[0]?.isFeedsOrFeed()) {
this.updateReadLabel();
}
}
@ -175,9 +204,12 @@
return true;
}
async renderItemPane(item) {
async renderItemPane(items) {
let previousMode = this.mode;
this.mode = "item";
if (!Array.isArray(items)) {
items = [items];
}
// Fix https://forums.zotero.org/discussion/115450/zotero-7-beta-wrong-vertical-position-in-the-item-pane-after-switching-from-a-note
if (previousMode === "note") {
@ -186,20 +218,21 @@
requestIdleCallback(resolve, { timeout: 50 });
});
}
this._itemDetails.editable = this.editable;
this._itemDetails.tabID = "zotero-pane";
this._itemDetails.tabType = "library";
this._itemDetails.item = item;
this._itemDetails.collectionTreeRow = this.collectionTreeRow;
this._itemDetails.item = items[0];
this._itemDetails.extraItems = items.slice(1);
this._itemDetails.collectionTreeRows = this.collectionTreeRows;
this._itemDetails.render();
if (this.getAttribute("collapsed") == "true") {
if (this.hasAttribute("collapsed")) {
return true;
}
if (item.isFeedItem) {
if (items[0].isFeedItem) {
let lastTranslationTarget = Zotero.Prefs.get('feeds.lastTranslationTarget');
if (lastTranslationTarget) {
let id = parseInt(lastTranslationTarget.substr(1));
@ -218,7 +251,7 @@
// if (!item.isTranslated) {
// item.translate();
// }
ZoteroPane.startItemReadTimeout(item.id);
ZoteroPane.startItemReadTimeout(items[0].id);
}
return true;
}
@ -229,7 +262,7 @@
let count = this.data.length;
// Display duplicates merge interface in item pane
if (this.collectionTreeRow.isDuplicates()) {
if (this.collectionTreeRows[0].isDuplicates()) {
if (!this.editable) {
if (count) {
msg = Zotero.getString('pane.item.duplicates.writeAccessRequired');
@ -244,7 +277,7 @@
// On a Select All of more than a few items, display a row
// count instead of the usual item type mismatch error
let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.rowCount;
let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.objectRowCount;
// Initialize the merge pane with the selected items
this._duplicatesPane.setItems(this.data, displayNumItemsOnTypeError);
@ -259,7 +292,7 @@
if (count) {
let key;
// In the trash, we have to check the object type
if (this.collectionTreeRow.isTrash()) {
if (this.collectionTreeRows[0].isTrash()) {
if (this.data.every(x => x instanceof Zotero.Collection)) {
key = 'item-pane-message-collections-selected';
}
@ -279,8 +312,8 @@
msg = { l10nId: key, l10nArgs: { count } };
}
else {
let count = this.itemsView.rowCount;
if (this.collectionTreeRow.isTrash()
let count = this.itemsView.objectRowCount;
if (this.collectionTreeRows[0].isTrash()
&& this.itemsView._rows?.some(
x => x.ref instanceof Zotero.Collection || x.ref instanceof Zotero.Search
)) {
@ -298,6 +331,16 @@
return true;
}
renderBatchEditorPrompt() {
this.mode = 'batch-edit-prompt';
document.l10n.setAttributes(
this._batchEditPromptMessage,
'item-pane-message-items-selected',
{ count: this.data.length }
);
return true;
}
setItemPaneMessage(msg) {
this.mode = "message";
this._messagePane.render(msg);
@ -315,7 +358,7 @@
}
// My Publications buttons
var isPublications = this.collectionTreeRow.isPublications();
var isPublications = this.collectionTreeRows[0].isPublications();
// Show in My Publications view if selected items are all notes or non-linked-file attachments
var showMyPublicationsButtons = isPublications
&& this.data.every((item) => {
@ -331,13 +374,13 @@
// Trash button
let nonDeletedItemsSelected = this.data.some(item => !item.deleted);
if (this.collectionTreeRow.isTrash() && !nonDeletedItemsSelected) {
if (this.collectionTreeRows[0].isTrash() && !nonDeletedItemsSelected) {
container.renderCustomHead(this.renderTrashHead.bind(this));
return;
}
// Feed buttons
if (this.collectionTreeRow.isFeedsOrFeed()) {
if (this.collectionTreeRows[0].isFeedsOrFeed()) {
container.renderCustomHead(this.renderFeedHead.bind(this));
this.updateReadLabel();
return;
@ -348,6 +391,11 @@
return;
}
if (this._isBatchEditEnabled && this.data.length > 1) {
container.renderCustomHead(this.renderBatchEditHead.bind(this));
return;
}
container.renderCustomHead();
}
@ -413,7 +461,7 @@
renderAnnotationsHead(data) {
let { doc, append } = data;
let button = doc.createXULElement("button");
button.disabled = !this.collectionTreeRow.editable;
button.disabled = !this.collectionTreeRows.every(o => o.editable);
button.id = 'zotero-item-pane-note-from-annotations';
if (Zotero.Items.getTopLevel(this.data).length == 1) {
button.label = Zotero.getString('pane.items.menu.addNoteFromAnnotations');
@ -426,6 +474,28 @@
append(button);
}
renderBatchEditHead(data) {
let { doc, append } = data;
let description = doc.createXULElement("description");
document.l10n.setAttributes(
description,
'item-pane-batch-editing-header',
{ count: this.data.length }
);
let icon = doc.createElement("span");
icon.className = "batch-edit-head-icon";
let doneButton = doc.createXULElement("button");
doneButton.setAttribute("default", "true");
document.l10n.setAttributes(doneButton, 'item-pane-batch-editing-done');
doneButton.addEventListener("command", () => {
this._setBatchEditCollapsible(false);
this._isBatchEditEnabled = false;
this.render();
this.updateItemPaneButtons();
});
append(icon, description, doneButton);
}
updateReadLabel() {
var items = this.data;
var isUnread = false;
@ -535,6 +605,25 @@
this.setTranslateButton();
}
_setBatchEditCollapsible(enabled) {
let section = this._itemDetails.querySelector('collapsible-section[data-pane="info"]');
if (!section) return;
if (enabled) {
// Force open without saving to prefs, so the previous state is preserved
section._skipSaveOpenState = true;
section.open = true;
section._skipSaveOpenState = false;
section.collapsible = false;
section.showContextMenu = false;
}
else {
section.collapsible = true;
section.showContextMenu = true;
// Restore the pref-saved open state
section._restoreOpenState();
}
}
getCurrentPane(mode = undefined) {
if (!mode) {
// Guess a mode from the current data
@ -543,7 +632,7 @@
mode = "annotations";
}
// No/multiple objects are selected OR selected object is a trashed collection/search
else if (!this.data.length || this.data.length > 1
else if (!this.data.length || (this.data.length > 1 && !this._isBatchEditEnabled)
|| this.data[0] instanceof Zotero.Collection || this.data[0] instanceof Zotero.Search) {
mode = "message";
}
@ -595,7 +684,7 @@
}
handleResize() {
if (this.getAttribute("collapsed")) {
if (this.hasAttribute("collapsed")) {
this.removeAttribute("width");
this.removeAttribute("height");
}
@ -636,18 +725,28 @@
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();
}
this._itemDetails.sidenav.toggleDefaultStatus(!isViewingItem);
// 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);
}
}
customElements.define("item-pane", ItemPane);

View file

@ -0,0 +1,257 @@
/*
***** 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

@ -23,162 +23,14 @@
***** END LICENSE BLOCK *****
*/
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();
}
}
"use strict";
{
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
{ global: "current" }
);
class ItemPaneCustomSection extends ItemPaneSectionElementBase {
_hooks = {};

View file

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

View file

@ -0,0 +1,178 @@
/*
***** 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,6 +112,15 @@
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();
}
@ -188,6 +197,10 @@
}
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;
}
@ -199,6 +212,10 @@
}
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);
@ -823,13 +840,24 @@
this._contextMenuTarget = paneID;
let isPinnable = this.isPanePinnable(paneID);
this.querySelector('.zotero-menuitem-pin').hidden = !isPinnable || this.pinnedPane == paneID;
this.querySelector('.zotero-menuitem-unpin').hidden = !isPinnable || this.pinnedPane != 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-separator').hidden = !isPinnable;
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();
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('.context-menu')
.openPopupAtScreen(event.screenX, event.screenY, true);

View file

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

View file

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

View file

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

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