"; strip back to the path
+ let prefix = (_appBundleID || '') + ':';
+ return ids.map(function (id) {
+ return (typeof id === 'string' && id.startsWith(prefix))
+ ? id.slice(prefix.length)
+ : id;
+ });
+ };
+
+ this.notify = async function (event, type, ids, extraData) {
+ if (!_enabled || !_isOwner) {
+ return;
+ }
+ if (type === 'group') {
+ if (event === 'add') {
+ _scheduleRebuild();
+ }
+ else if (event === 'delete') {
+ SpotlightBridge.removeLibraries(ids.map(id => 'groups.' + id));
+ }
+ return;
+ }
+ if (type !== 'item') {
+ return;
+ }
+ if (event === 'add' || event === 'modify') {
+ for (let id of ids) {
+ _pendingIndex.add(id);
+ }
+ }
+ else if (event === 'trash') {
+ for (let id of ids) {
+ let item = Zotero.Items.get(id);
+ let path = item && SpotlightLibraries.itemPath(item.libraryID, item.key);
+ if (path) {
+ _pendingRemove.add(path);
+ }
+ _pendingIndex.delete(id);
+ }
+ }
+ else if (event === 'delete') {
+ for (let id of ids) {
+ let data = extraData && extraData[id];
+ if (data && data.key) {
+ let path = SpotlightLibraries.itemPath(data.libraryID, data.key);
+ if (path) {
+ _pendingRemove.add(path);
+ }
+ }
+ _pendingIndex.delete(id);
+ }
+ }
+ else {
+ return;
+ }
+ _flush();
+ };
+
+ function _registerNotifier() {
+ if (_notifierID) {
+ return;
+ }
+ _notifierID = Zotero.Notifier.registerObserver(
+ Zotero.Spotlight,
+ ['item', 'group'],
+ 'spotlight'
+ );
+ }
+
+ function _unregisterNotifier() {
+ if (_notifierID) {
+ Zotero.Notifier.unregisterObserver(_notifierID);
+ _notifierID = null;
+ }
+ }
+
+ let _scheduleRebuild = Zotero.Utilities.debounce(
+ () => Zotero.Spotlight.rebuild().catch(e => Zotero.logError(e)), DEBOUNCE_MS);
+
+ let _flush = Zotero.Utilities.debounce(async function () {
+ if (!_enabled || !_available || !_isOwner || _reindexing) {
+ return;
+ }
+ try {
+ let indexIDs = [..._pendingIndex];
+ _pendingIndex.clear();
+ let removePaths = new Set(_pendingRemove);
+ _pendingRemove.clear();
+
+ let records = [];
+ for (let id of indexIDs) {
+ let item = Zotero.Items.get(id);
+ if (!item) {
+ continue;
+ }
+ let library = Zotero.Libraries.get(item.libraryID);
+ let record = (library && !item.deleted) ? await SpotlightRecord.build(item, library) : null;
+ if (record) {
+ records.push(record);
+ }
+ else {
+ let path = SpotlightLibraries.itemPath(item.libraryID, item.key);
+ if (path) {
+ removePaths.add(path);
+ }
+ }
+ }
+
+ if (removePaths.size) {
+ SpotlightBridge.remove([...removePaths]);
+ }
+ for (let i = 0; i < records.length; i += INDEX_CHUNK) {
+ SpotlightBridge.index(records.slice(i, i + INDEX_CHUNK));
+ await _yield();
+ }
+
+ // Keep the signature current so startup doesn't force a needless rebuild
+ Zotero.Prefs.set(PREF + 'lastSignature', SpotlightLibraries.signature());
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ }, DEBOUNCE_MS);
+
+ function _registerPrefObservers() {
+ // Only 'enabled' acts immediately; content prefs apply on the next rebuild.
+ _prefObserverIDs.push(
+ Zotero.Prefs.registerObserver(PREF + 'enabled', _onEnabledChanged)
+ );
+ // excludeDataDir has an immediate filesystem effect, so apply on change.
+ _prefObserverIDs.push(
+ Zotero.Prefs.registerObserver(PREF + 'excludeDataDir',
+ () => _applyDataDirExclusion().catch(e => Zotero.logError(e)))
+ );
+ }
+
+ function _unregisterPrefObservers() {
+ for (let id of _prefObserverIDs) {
+ Zotero.Prefs.unregisterObserver(id);
+ }
+ _prefObserverIDs = [];
+ }
+
+ async function _onEnabledChanged() {
+ let enabled = !!Zotero.Prefs.get(PREF + 'enabled');
+ if (enabled === _enabled) {
+ return;
+ }
+ _enabled = enabled;
+ if (enabled) {
+ _isOwner = await SpotlightOwner.claim();
+ if (_isOwner) {
+ await Zotero.Spotlight.start();
+ }
+ else {
+ await _cleanUpAsNonOwner();
+ }
+ }
+ else {
+ Zotero.Spotlight.stop();
+ // Only touch the shared index/ownership if we owned it, so a secondary
+ // install turning off the feature can't wipe the owner's index.
+ if (_isOwner) {
+ await Zotero.Spotlight.clearIndex();
+ await SpotlightOwner.release();
+ _isOwner = false;
+ }
+ }
+ await _applyDataDirExclusion();
+ }
+
+ // `.metadata_never_index` file excludes the data dir from Spotlight indexing.
+ async function _applyDataDirExclusion() {
+ if (!_dataDir) {
+ return;
+ }
+ let file = PathUtils.join(_dataDir, '.metadata_never_index');
+ let shouldExclude = _available && _enabled
+ && !!Zotero.Prefs.get(PREF + 'excludeDataDir');
+ try {
+ let exists = await IOUtils.exists(file);
+ if (shouldExclude && !exists) {
+ await IOUtils.writeUTF8(file, '');
+ }
+ else if (!shouldExclude && exists) {
+ await IOUtils.remove(file);
+ }
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ }
+
+ this.getForeignOwner = async function () {
+ return _available ? SpotlightOwner.foreign() : null;
+ };
+
+ this.takeOwnership = async function () {
+ if (!_available) {
+ return;
+ }
+ await SpotlightOwner.write();
+ _isOwner = true;
+ SpotlightBridge.reset();
+ Zotero.Prefs.clear(PREF + 'lastSignature');
+ if (!_enabled) {
+ // Triggers _onEnabledChanged, which will see _isOwner and start()
+ Zotero.Prefs.set(PREF + 'enabled', true);
+ }
+ else {
+ _registerNotifier();
+ await this.rebuild();
+ }
+ };
+
+ /**
+ * Enabled but another install owns the index: clear our leftovers only if it's
+ * a different channel; same-channel installs share the owner's domain.
+ */
+ async function _cleanUpAsNonOwner() {
+ let owner = await SpotlightOwner.read();
+ if (owner && owner.bundleID !== _appBundleID) {
+ SpotlightBridge.reset();
+ Zotero.Prefs.clear(PREF + 'lastSignature');
+ }
+ }
+
+ const SpotlightRecord = {
+
+ /**
+ * The record to index for an item, or null if it shouldn't be indexed.
+ */
+ async build(item, library) {
+ if (!item || !item.isRegularItem() || item.deleted) {
+ return null;
+ }
+ if (!SpotlightLibraries.indexable(library)) {
+ return null;
+ }
+ let path = SpotlightLibraries.itemPath(library.libraryID, item.key);
+ if (!path) {
+ return null;
+ }
+
+ // Load everything we read below (item data, creators, tags, notes)
+ await item.loadAllData();
+
+ let titleTemplate = Zotero.Prefs.get(PREF + 'titleTemplate') || '{{ title }}';
+ let descTemplate = Zotero.Prefs.get(PREF + 'descriptionTemplate')
+ || '{{ firstCreator }}{{ year prefix=" (" suffix=")" }}';
+
+ let title = this._template(titleTemplate, item)
+ || item.getDisplayTitle()
+ || item.key;
+ let description = this._template(descTemplate, item);
+
+ let record = {
+ path,
+ title,
+ library: SpotlightLibraries.domain(library),
+ keywords: this._keywords(item, library)
+ };
+ if (description) {
+ record.description = description;
+ }
+ let yearMatch = String(item.getField('date', true) || '').match(/\d{4}/);
+ if (yearMatch) {
+ record.date = yearMatch[0];
+ }
+ let content = await this._content(item);
+ if (content) {
+ record.content = content;
+ }
+ return record;
+ },
+
+ /**
+ * Searchable but hidden content for an item: fields, attachment names,
+ * notes, and full text, capped at MAX_CONTENT_CHARS.
+ */
+ async _content(item) {
+ let parts = [];
+ let length = 0;
+ let cap = MAX_CONTENT_CHARS;
+ // Append as much of `text` as the current cap allows.
+ let add = function (text) {
+ let remaining = cap - length;
+ if (!text || remaining <= 0) {
+ return;
+ }
+ text = String(text);
+ if (text.length > remaining) {
+ text = text.slice(0, remaining);
+ }
+ parts.push(text);
+ length += text.length + 1;
+ };
+
+ let includeFullText = !!Zotero.Prefs.get(PREF + 'indexFullText');
+
+ // Load child attachments once; reused for names and (later) full text.
+ let attachments = [];
+ for (let id of item.getAttachments()) {
+ let attachment = await Zotero.Items.getAsync(id);
+ if (!attachment) {
+ continue;
+ }
+ await attachment.loadAllData();
+ attachments.push(attachment);
+ }
+
+ // Hold back budget for full text so fields/notes can't consume it all.
+ if (includeFullText) {
+ cap = MAX_CONTENT_CHARS - FULLTEXT_RESERVE_CHARS;
+ }
+
+ // Compact, high-value text first: item fields, then attachment names.
+ for (let name of item.getUsedFields(true)) {
+ add(item.getField(name));
+ }
+ for (let attachment of attachments) {
+ add(attachment.getField('title'));
+ add(attachment.attachmentFilename);
+ }
+ // Then notes (user-written, sometimes long).
+ for (let noteID of item.getNotes()) {
+ let note = await Zotero.Items.getAsync(noteID);
+ if (!note) {
+ continue;
+ }
+ await note.loadAllData();
+ add(this._noteText(note));
+ }
+
+ // Finally attachment full text, free to use the reserved budget.
+ if (includeFullText) {
+ cap = MAX_CONTENT_CHARS;
+ for (let attachment of attachments) {
+ if (length >= cap) {
+ break;
+ }
+ add(await this._attachmentText(attachment));
+ }
+ }
+
+ return parts.join('\n');
+ },
+
+ _noteText(note) {
+ let html = note.getNote();
+ return html ? Zotero.Utilities.trimInternal(Zotero.Utilities.unescapeHTML(html)) : '';
+ },
+
+ async _attachmentText(attachment) {
+ try {
+ if (!attachment.isFileAttachment()) {
+ return '';
+ }
+ let contentType = attachment.attachmentContentType;
+ if (Zotero.FullText.isCachedMIMEType(contentType)) {
+ let cacheFile = Zotero.FullText.getItemCacheFile(attachment).path;
+ if (await IOUtils.exists(cacheFile)) {
+ return await Zotero.File.getContentsAsync(cacheFile);
+ }
+ }
+ else if (Zotero.MIME.isTextType(contentType)) {
+ let path = await attachment.getFilePathAsync();
+ if (path && await IOUtils.exists(path)) {
+ return await Zotero.File.getContentsAsync(path, attachment.attachmentCharset);
+ }
+ }
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ return '';
+ },
+
+ _keywords(item, library) {
+ let keywords = [library.name];
+ for (let creator of item.getCreatorsJSON()) {
+ let name = creator.name || [creator.firstName, creator.lastName].filter(Boolean).join(' ');
+ if (name) {
+ keywords.push(name);
+ }
+ }
+ for (let tag of item.getTags()) {
+ keywords.push(tag.tag);
+ }
+ let pub = item.getField('publicationTitle');
+ if (pub) {
+ keywords.push(pub);
+ }
+ keywords.push(Zotero.ItemTypes.getLocalizedString(item.itemTypeID));
+ return keywords.filter(Boolean);
+ },
+
+ // Render a {{ variable }} template using the shared file-renaming variables.
+ _template(template, item) {
+ try {
+ return Zotero.Attachments.getStringFromItemTemplate(item, { formatString: template });
+ }
+ catch (e) {
+ Zotero.logError(e);
+ return '';
+ }
+ }
+ };
+
+ const SpotlightLibraries = {
+ indexable(library) {
+ if (!library) {
+ return false;
+ }
+ if (library.libraryType !== 'user' && library.libraryType !== 'group') {
+ return false;
+ }
+ return !this.excluded().includes(library.libraryID);
+ },
+
+ excluded() {
+ try {
+ let raw = Zotero.Prefs.get(PREF + 'excludedLibraries');
+ let arr = raw ? JSON.parse(raw) : [];
+ return Array.isArray(arr) ? arr.map(Number) : [];
+ }
+ catch {
+ return [];
+ }
+ },
+
+ // "library/items/KEY" or "groups/123/items/KEY"
+ itemPath(libraryID, key) {
+ if (!key) {
+ return null;
+ }
+ let library = Zotero.Libraries.get(libraryID);
+ if (!library) {
+ return null;
+ }
+ if (library.libraryType === 'user') {
+ return `library/items/${key}`;
+ }
+ if (library.libraryType === 'group') {
+ let groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID);
+ return `groups/${groupID}/items/${key}`;
+ }
+ return null;
+ },
+
+ // "library" or "groups.123", for dropping library's index without a rebuild.
+ domain(library) {
+ if (library.libraryType === 'user') {
+ return 'library';
+ }
+ if (library.libraryType === 'group') {
+ return 'groups.' + Zotero.Groups.getGroupIDFromLibraryID(library.libraryID);
+ }
+ return null;
+ },
+
+ signature() {
+ let parts = [];
+ for (let library of Zotero.Libraries.getAll()) {
+ if (!this.indexable(library)) {
+ continue;
+ }
+ parts.push(library.libraryID + ':' + (library.libraryVersion || 0));
+ }
+ parts.sort();
+ return JSON.stringify({
+ libraries: parts,
+ title: Zotero.Prefs.get(PREF + 'titleTemplate') || '',
+ desc: Zotero.Prefs.get(PREF + 'descriptionTemplate') || '',
+ excluded: this.excluded().slice().sort(),
+ fullText: !!Zotero.Prefs.get(PREF + 'indexFullText')
+ });
+ }
+ };
+
+ const SpotlightBridge = {
+ _ctypes: null,
+ _lib: null,
+ _fn: null,
+
+ // Load the dylib and declare its entry points; returns whether it loaded.
+ async load() {
+ try {
+ let dylib = PathUtils.join(Zotero.resourcesDir, 'libZoteroSpotlight.dylib');
+ if (!await IOUtils.exists(dylib)) {
+ return false;
+ }
+ let c = ChromeUtils.importESModule("resource://gre/modules/ctypes.sys.mjs").ctypes;
+ this._ctypes = c;
+ this._lib = c.open(dylib);
+ this._fn = {
+ bundleID: this._lib.declare("zotero_spotlight_bundle_id",
+ c.default_abi, c.char.ptr),
+ index: this._lib.declare("zotero_spotlight_index",
+ c.default_abi, c.int, c.char.ptr),
+ remove: this._lib.declare("zotero_spotlight_remove",
+ c.default_abi, c.int, c.char.ptr),
+ removeLibraries: this._lib.declare("zotero_spotlight_remove_libraries",
+ c.default_abi, c.int, c.char.ptr),
+ reset: this._lib.declare("zotero_spotlight_reset",
+ c.default_abi, c.int),
+ indexingAvailable: this._lib.declare("zotero_spotlight_indexing_available",
+ c.default_abi, c.int),
+ lastError: this._lib.declare("zotero_spotlight_last_error",
+ c.default_abi, c.char.ptr),
+ query: this._lib.declare("zotero_spotlight_query",
+ c.default_abi, c.char.ptr, c.char.ptr),
+ setClientState: this._lib.declare("zotero_spotlight_set_client_state",
+ c.default_abi, c.int, c.char.ptr),
+ getClientState: this._lib.declare("zotero_spotlight_get_client_state",
+ c.default_abi, c.char.ptr)
+ };
+ return true;
+ }
+ catch (e) {
+ Zotero.debug("Spotlight: failed to load bridge dylib: " + e);
+ this._lib = null;
+ this._fn = null;
+ return false;
+ }
+ },
+
+ _toCharPtr(str) {
+ // js-ctypes UTF-8-encodes the string itself.
+ return this._ctypes.char.array()(str);
+ },
+
+ bundleID() {
+ try {
+ return this._fn.bundleID().readString();
+ }
+ catch (e) {
+ Zotero.logError(e);
+ return null;
+ }
+ },
+
+ indexingAvailable() {
+ try {
+ return !!this._fn && this._fn.indexingAvailable() === 1;
+ }
+ catch {
+ return false;
+ }
+ },
+
+ index(records) {
+ if (!this._fn || !records.length) {
+ return;
+ }
+ try {
+ this._fn.index(this._toCharPtr(JSON.stringify(records)));
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ },
+
+ remove(paths) {
+ if (!this._fn || !paths.length) {
+ return;
+ }
+ try {
+ this._fn.remove(this._toCharPtr(JSON.stringify(paths)));
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ },
+
+ removeLibraries(tokens) {
+ if (!this._fn || !tokens.length) {
+ return;
+ }
+ try {
+ this._fn.removeLibraries(this._toCharPtr(JSON.stringify(tokens)));
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ },
+
+ reset() {
+ if (!this._fn) {
+ return;
+ }
+ try {
+ this._fn.reset();
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ },
+
+ lastError() {
+ try {
+ let ptr = this._fn && this._fn.lastError();
+ return ptr && !ptr.isNull() ? ptr.readString() : '';
+ }
+ catch {
+ return '';
+ }
+ },
+
+ /**
+ * For tests. Raw JSON array of matching unique identifiers (or '[]'); parsed by search().
+ * @param {String} queryString - Core Spotlight query, e.g. `title == "*x*"c`.
+ * @returns {String} JSON array of matching unique identifiers
+ */
+ query(queryString) {
+ if (!this._fn || !queryString) {
+ return '[]';
+ }
+ try {
+ let ptr = this._fn.query(this._toCharPtr(queryString));
+ return (ptr && !ptr.isNull()) ? ptr.readString() : '[]';
+ }
+ catch (e) {
+ Zotero.logError(e);
+ return '[]';
+ }
+ },
+
+ setClientState(state) {
+ if (!this._fn) {
+ return;
+ }
+ try {
+ this._fn.setClientState(this._toCharPtr(String(state)));
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ },
+
+ clientState() {
+ try {
+ let ptr = this._fn && this._fn.getClientState();
+ return (ptr && !ptr.isNull()) ? ptr.readString() : '';
+ }
+ catch (e) {
+ Zotero.logError(e);
+ return '';
+ }
+ }
+ };
+
+ const SpotlightOwner = {
+ path() {
+ let home = Services.env.get('HOME');
+ return home
+ ? PathUtils.join(home, 'Library', 'Application Support', 'org.zotero.spotlight', 'owner.json')
+ : null;
+ },
+
+ async read() {
+ let path = this.path();
+ if (!path) {
+ return null;
+ }
+ try {
+ let data = JSON.parse(await IOUtils.readUTF8(path));
+ return (data && data.dataDir) ? data : null;
+ }
+ catch {
+ return null;
+ }
+ },
+
+ async write() {
+ let path = this.path();
+ if (!path) {
+ return;
+ }
+ await IOUtils.makeDirectory(PathUtils.parent(path), {
+ createAncestors: true,
+ ignoreExisting: true
+ });
+ await IOUtils.writeUTF8(path, JSON.stringify({
+ dataDir: _dataDir,
+ bundleID: _appBundleID
+ }));
+ },
+
+ isUs(record) {
+ return !!record && record.dataDir === _dataDir && record.bundleID === _appBundleID;
+ },
+
+ /**
+ * The owner record if a *different* install/profile owns the index, else null
+ */
+ async foreign() {
+ let record = await this.read();
+ return (record && !this.isUs(record)) ? record : null;
+ },
+
+ /**
+ * Become the owner if the index is unowned or already ours; returns whether we now own it
+ */
+ async claim() {
+ if (!this.path()) {
+ return true;
+ }
+ let record = await this.read();
+ if (!record || this.isUs(record)) {
+ await this.write();
+ return true;
+ }
+ return false;
+ },
+
+ async release() {
+ let path = this.path();
+ if (path && this.isUs(await this.read())) {
+ try {
+ await IOUtils.remove(path);
+ }
+ catch {}
+ }
+ }
+ };
+};
diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js
index 9e56721b15..8abb02030e 100644
--- a/chrome/content/zotero/xpcom/zotero.js
+++ b/chrome/content/zotero/xpcom/zotero.js
@@ -761,7 +761,10 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte
Zotero.Feeds.init();
Zotero.addShutdownListener(() => Zotero.Feeds.uninit());
-
+
+ Zotero.Spotlight.init();
+ Zotero.addShutdownListener(() => Zotero.Spotlight.uninit());
+
Zotero.Schema.schemaUpdatePromise.then(Zotero.purgeDataObjects.bind(Zotero));
// Migrate fields from Extra that can be moved to item fields after a schema update
diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs
index 2af2386810..16078a0ebb 100644
--- a/chrome/content/zotero/zotero.mjs
+++ b/chrome/content/zotero/zotero.mjs
@@ -136,6 +136,7 @@ const xpcomFilesLocal = [
'server/server_localAPI',
'server/saveSession',
'session',
+ 'spotlight',
'streamer',
'style',
'sync',
diff --git a/chrome/locale/en-US/zotero/preferences.ftl b/chrome/locale/en-US/zotero/preferences.ftl
index bb4d619697..7418900716 100644
--- a/chrome/locale/en-US/zotero/preferences.ftl
+++ b/chrome/locale/en-US/zotero/preferences.ftl
@@ -95,6 +95,35 @@ preferences-advanced-default-data-dir =
preferences-pane-account = Account
+preferences-spotlight-title = Spotlight Search
+preferences-spotlight-enable =
+ .label = Show { -app-name } items in Spotlight
+preferences-spotlight-result-action =
+ .value = When opening a result:
+preferences-spotlight-result-action-show =
+ .label = Show in Library
+preferences-spotlight-result-action-open =
+ .label = Open File
+preferences-spotlight-index-fulltext =
+ .label = Index attachment full text
+preferences-spotlight-owner-other = Another { -app-name } installation or profile is currently providing Spotlight results (database at { $path }).
+preferences-spotlight-take-ownership =
+ .label = Use This Profile Instead
+preferences-spotlight-libraries-title = Indexed Libraries
+preferences-spotlight-privacy-title = Avoiding Duplicate Indexing
+preferences-spotlight-privacy-description = macOS may also index the attachment files in your { -app-name } data directory, showing them in Spotlight alongside your library items. Excluding the data directory keeps Spotlight showing only your { -app-name } items.
+preferences-spotlight-exclude-data-dir =
+ .label = Exclude data directory from Spotlight
+preferences-spotlight-format-title = Display Format
+preferences-spotlight-format-description = Customize how items appear in Spotlight. See the documentation for the full list.
+preferences-spotlight-title-template =
+ .value = Title:
+preferences-spotlight-description-template =
+ .value = Subtitle:
+preferences-spotlight-rebuild =
+ .label = Rebuild Index
+preferences-spotlight-reindex-notice = Your changes will be indexed the next time { -app-name } starts, or click Rebuild Index to apply them now.
+
-preferences-sync-data-syncing = Data Syncing
preferences-sync-data-syncing-groupbox =
.aria-label = { -preferences-sync-data-syncing }
diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js
index 6ecac853dd..a9503a0239 100644
--- a/defaults/preferences/zotero.js
+++ b/defaults/preferences/zotero.js
@@ -162,6 +162,17 @@ pref("extensions.zotero.httpServer.enabled", true);
pref("extensions.zotero.httpServer.port", 23119); // ascii "ZO"
pref("extensions.zotero.httpServer.localAPI.enabled", false);
+// macOS Spotlight integration
+pref("extensions.zotero.spotlight.enabled", true);
+pref("extensions.zotero.spotlight.openOnConfirm", true); // true = open the item's attachment, false = show in library
+pref("extensions.zotero.spotlight.indexFullText", true);
+pref("extensions.zotero.spotlight.excludeDataDir", true);
+pref("extensions.zotero.spotlight.titleTemplate", "{{ title }}");
+pref("extensions.zotero.spotlight.descriptionTemplate", "{{ firstCreator }}{{ year prefix=\" (\" suffix=\")\" }}");
+pref("extensions.zotero.spotlight.excludedLibraries", "[]"); // JSON array of libraryIDs to skip
+pref("extensions.zotero.spotlight.indexSchemaVersion", 0);
+pref("extensions.zotero.spotlight.lastSignature", "");
+
// Zeroconf
pref("extensions.zotero.zeroconf.server.enabled", false);
diff --git a/test/tests/spotlightTest.js b/test/tests/spotlightTest.js
new file mode 100644
index 0000000000..1e271d69b3
--- /dev/null
+++ b/test/tests/spotlightTest.js
@@ -0,0 +1,580 @@
+"use strict";
+
+describe("Zotero.Spotlight", function () {
+ var savedPrefs = {};
+ var savedOwner = null;
+ var ranSetup = false;
+
+ var PREF_KEYS = [
+ 'spotlight.enabled',
+ 'spotlight.openOnConfirm',
+ 'spotlight.indexFullText',
+ 'spotlight.excludeDataDir',
+ 'spotlight.titleTemplate',
+ 'spotlight.descriptionTemplate',
+ 'spotlight.excludedLibraries',
+ 'spotlight.lastSignature',
+ 'spotlight.indexSchemaVersion'
+ ];
+
+ function ownerPath() {
+ let home = Services.env.get('HOME');
+ return PathUtils.join(
+ home, 'Library', 'Application Support', 'org.zotero.spotlight', 'owner.json'
+ );
+ }
+
+ function neverIndexPath() {
+ return PathUtils.join(Zotero.DataDirectory.dir, '.metadata_never_index');
+ }
+
+ // Unique lowercase-alnum token so a Core Spotlight wildcard query can find a
+ // specific item without colliding with anything else in the index.
+ function token() {
+ let rand = Math.random().toString(36).slice(2, 12);
+ return 'ztspot' + rand.replace(/[^a-z0-9]/g, '');
+ }
+
+ // Poll the real index until `predicate(results)` holds or we time out. Success
+ // returns in ~2-3s; the cap is kept under the harness's 10s test timeout so a
+ // failing wait gives up and reports a clean assertion rather than hanging.
+ async function searchUntil(query, predicate, timeout = 8000) {
+ let start = Date.now();
+ let results = [];
+ while (Date.now() - start < timeout) {
+ results = Zotero.Spotlight.search(query);
+ if (predicate(results)) {
+ return results;
+ }
+ await Zotero.Promise.delay(500);
+ }
+ return results;
+ }
+
+ async function waitForFileState(path, shouldExist, timeout = 5000) {
+ let start = Date.now();
+ while (Date.now() - start < timeout) {
+ if ((await IOUtils.exists(path)) === shouldExist) {
+ return true;
+ }
+ await Zotero.Promise.delay(100);
+ }
+ return false;
+ }
+
+ before(async function () {
+ if (!Zotero.isMac || !Zotero.Spotlight || !Zotero.Spotlight.available) {
+ Zotero.debug("Spotlight: bridge unavailable; skipping Spotlight tests");
+ this.skip();
+ return;
+ }
+ ranSetup = true;
+ for (let key of PREF_KEYS) {
+ savedPrefs[key] = Zotero.Prefs.get(key);
+ }
+ try {
+ savedOwner = await IOUtils.readUTF8(ownerPath());
+ }
+ catch {
+ savedOwner = null;
+ }
+ });
+
+ after(async function () {
+ if (!ranSetup) {
+ return;
+ }
+ // Stop observing and drop anything we put in the shared index.
+ try {
+ Zotero.Spotlight.stop();
+ }
+ catch {}
+ try {
+ await Zotero.Spotlight.clearIndex();
+ }
+ catch {}
+ // Restore prefs
+ for (let key of PREF_KEYS) {
+ if (savedPrefs[key] === undefined) {
+ Zotero.Prefs.clear(key);
+ }
+ else {
+ Zotero.Prefs.set(key, savedPrefs[key]);
+ }
+ }
+ // Restore the previous owner.json so the user's normal profile keeps
+ // ownership of the system-wide index.
+ let path = ownerPath();
+ if (savedOwner === null) {
+ try {
+ await IOUtils.remove(path);
+ }
+ catch {}
+ }
+ else {
+ await IOUtils.makeDirectory(PathUtils.parent(path), {
+ createAncestors: true,
+ ignoreExisting: true
+ });
+ await IOUtils.writeUTF8(path, savedOwner);
+ }
+ });
+
+ describe("preferences pane visibility", function () {
+ async function loadAdvancedPane() {
+ let win = await loadWindow("chrome://zotero/content/preferences/preferences.xhtml", {
+ pane: 'zotero-prefpane-advanced'
+ });
+ await win.Zotero_Preferences.waitForFirstPaneLoad();
+ return win;
+ }
+
+ it("should show the Spotlight section when the bridge is available", async function () {
+ let win = await loadAdvancedPane();
+ let section = win.document.getElementById('zotero-prefpane-advanced-spotlight');
+ assert.isFalse(section.hidden);
+ win.close();
+ });
+
+ it("should hide the Spotlight section when the bridge is unavailable (macOS < 11)", async function () {
+ // On macOS < 11 the bridge dylib can't load, so available is false.
+ let stub = sinon.stub(Zotero.Spotlight, 'available').get(() => false);
+ try {
+ let win = await loadAdvancedPane();
+ let section = win.document.getElementById('zotero-prefpane-advanced-spotlight');
+ assert.isTrue(section.hidden);
+ win.close();
+ }
+ finally {
+ stub.restore();
+ }
+ });
+
+ // The full controls only matter to the profile that's actually indexing.
+ var CONTROL_GROUPS = [
+ 'spotlight-controls',
+ 'spotlight-libraries-group',
+ 'spotlight-privacy-group',
+ 'spotlight-format-group',
+ 'spotlight-rebuild'
+ ];
+
+ async function withOwner(record, fn) {
+ let prev = await IOUtils.readUTF8(ownerPath()).catch(() => null);
+ await IOUtils.makeDirectory(PathUtils.parent(ownerPath()), {
+ createAncestors: true,
+ ignoreExisting: true
+ });
+ if (record === null) {
+ await IOUtils.remove(ownerPath()).catch(() => {});
+ }
+ else {
+ await IOUtils.writeUTF8(ownerPath(), JSON.stringify(record));
+ }
+ try {
+ await fn();
+ }
+ finally {
+ if (prev === null) {
+ await IOUtils.remove(ownerPath()).catch(() => {});
+ }
+ else {
+ await IOUtils.writeUTF8(ownerPath(), prev);
+ }
+ }
+ }
+
+ it("should show the full controls when this profile owns the index", async function () {
+ Zotero.Prefs.set('spotlight.enabled', true);
+ // No owner recorded -> this profile would index, so show everything.
+ await withOwner(null, async function () {
+ let win = await loadAdvancedPane();
+ try {
+ await win.Zotero_Preferences.Spotlight._updateEnabledUI();
+ assert.isTrue(win.document.getElementById('spotlight-owner-notice').hidden);
+ for (let id of CONTROL_GROUPS) {
+ assert.isFalse(win.document.getElementById(id).hidden, id);
+ }
+ }
+ finally {
+ win.close();
+ }
+ });
+ });
+
+ it("should show only the take-over button when another profile owns the index", async function () {
+ Zotero.Prefs.set('spotlight.enabled', true);
+ await withOwner({ dataDir: '/some/other/profile', bundleID: 'org.zotero.zotero' }, async function () {
+ let win = await loadAdvancedPane();
+ try {
+ await win.Zotero_Preferences.Spotlight._updateEnabledUI();
+ // Take-over notice shown...
+ assert.isFalse(win.document.getElementById('spotlight-owner-notice').hidden);
+ // ...and every indexing control hidden.
+ for (let id of CONTROL_GROUPS) {
+ assert.isTrue(win.document.getElementById(id).hidden, id);
+ }
+ }
+ finally {
+ win.close();
+ }
+ });
+ });
+ });
+
+ describe("zotero://spotlight click action", function () {
+ var win, zp;
+
+ function runHandler(url) {
+ let handler = Services.io.getProtocolHandler('zotero').wrappedJSObject;
+ let uri = Services.io.newURI(url, null, null);
+ return handler._extensions['zotero://spotlight'].newChannel(uri);
+ }
+
+ async function waitForItemSelect(item, timeout = 5000) {
+ let start = Date.now();
+ while (Date.now() - start < timeout) {
+ if (zp.getSelectedItems().includes(item)) {
+ return true;
+ }
+ await Zotero.Promise.delay(20);
+ }
+ return false;
+ }
+
+ before(async function () {
+ win = await loadZoteroPane();
+ zp = win.ZoteroPane;
+ });
+
+ after(function () {
+ if (win) {
+ win.close();
+ }
+ });
+
+ it("should show the item in the library when set to \"Show in Library\"", async function () {
+ Zotero.Prefs.set('spotlight.openOnConfirm', false);
+ let item = await createDataObject('item', { title: 'Spotlight Select' });
+ runHandler(`zotero://spotlight/library/items/${item.key}`);
+ assert.isTrue(await waitForItemSelect(item));
+ });
+
+ it("should open the best attachment when set to \"Open File\"", async function () {
+ Zotero.Prefs.set('spotlight.openOnConfirm', true);
+ let item = await createDataObject('item', { title: 'Spotlight Open' });
+ let attachment = await importPDFAttachment(item);
+ let stub = sinon.stub(Zotero.FileHandlers, 'open').resolves(true);
+ try {
+ runHandler(`zotero://spotlight/library/items/${item.key}`);
+ await waitForCallback(() => stub.called, 50, 10);
+ assert.isTrue(stub.calledOnce);
+ assert.equal(stub.firstCall.args[0].id, attachment.id);
+ }
+ finally {
+ stub.restore();
+ Zotero.Prefs.set('spotlight.openOnConfirm', false);
+ }
+ });
+
+ it("should fall back to selecting when there is nothing to open", async function () {
+ Zotero.Prefs.set('spotlight.openOnConfirm', true);
+ let item = await createDataObject('item', { title: 'Spotlight No Attachment' });
+ try {
+ runHandler(`zotero://spotlight/library/items/${item.key}`);
+ assert.isTrue(await waitForItemSelect(item));
+ }
+ finally {
+ Zotero.Prefs.set('spotlight.openOnConfirm', false);
+ }
+ });
+
+ it("should resolve a group-library item path", async function () {
+ Zotero.Prefs.set('spotlight.openOnConfirm', false);
+ let group = await createGroup();
+ let item = await createDataObject('item', { libraryID: group.libraryID, title: 'Spotlight Group' });
+ runHandler(`zotero://spotlight/groups/${group.id}/items/${item.key}`);
+ assert.isTrue(await waitForItemSelect(item));
+ });
+ });
+
+ describe("ownership", function () {
+ // owner.json is restored by the outer after().
+
+ it("should report a foreign owner from another profile", async function () {
+ await IOUtils.makeDirectory(PathUtils.parent(ownerPath()), {
+ createAncestors: true,
+ ignoreExisting: true
+ });
+ await IOUtils.writeUTF8(ownerPath(), JSON.stringify({
+ dataDir: '/some/other/profile',
+ bundleID: 'org.zotero.zotero'
+ }));
+ let owner = await Zotero.Spotlight.getForeignOwner();
+ assert.ok(owner);
+ assert.equal(owner.dataDir, '/some/other/profile');
+ });
+
+ it("should take ownership for this profile", async function () {
+ await Zotero.Spotlight.takeOwnership();
+ // No longer foreign once it's us
+ assert.isNull(await Zotero.Spotlight.getForeignOwner());
+ let written = JSON.parse(await IOUtils.readUTF8(ownerPath()));
+ assert.equal(written.dataDir, Zotero.DataDirectory.dir);
+ });
+ });
+
+ describe("data directory exclusion", function () {
+ before(function () {
+ // The exclusion only applies while the feature is enabled.
+ Zotero.Prefs.set('spotlight.enabled', true);
+ });
+
+ it("should write .metadata_never_index when the exclusion is on", async function () {
+ Zotero.Prefs.set('spotlight.excludeDataDir', false);
+ assert.isTrue(await waitForFileState(neverIndexPath(), false));
+ Zotero.Prefs.set('spotlight.excludeDataDir', true);
+ assert.isTrue(await waitForFileState(neverIndexPath(), true),
+ ".metadata_never_index should be created");
+ });
+
+ it("should remove .metadata_never_index when the exclusion is off", async function () {
+ Zotero.Prefs.set('spotlight.excludeDataDir', true);
+ assert.isTrue(await waitForFileState(neverIndexPath(), true));
+ Zotero.Prefs.set('spotlight.excludeDataDir', false);
+ assert.isTrue(await waitForFileState(neverIndexPath(), false),
+ ".metadata_never_index should be removed");
+ Zotero.Prefs.set('spotlight.excludeDataDir', true);
+ });
+ });
+
+ describe("indexing round-trip", function () {
+ before(async function () {
+ if (!Zotero.Spotlight.indexingAvailable()) {
+ Zotero.debug("Spotlight: indexing unavailable (unsigned build or "
+ + "Spotlight off); skipping round-trip tests");
+ this.skip();
+ return;
+ }
+ Zotero.Prefs.set('spotlight.enabled', true);
+ Zotero.Prefs.set('spotlight.indexFullText', true);
+ Zotero.Prefs.set('spotlight.titleTemplate', '{{ title }}');
+ Zotero.Prefs.set('spotlight.descriptionTemplate', '{{ firstCreator }}{{ year prefix=" (" suffix=")" }}');
+ Zotero.Prefs.set('spotlight.excludedLibraries', '[]');
+ // Own the (shared) index so the live notifier path runs. This resets
+ // the channel's Core Spotlight domain -- authorized for this profile.
+ await Zotero.Spotlight.takeOwnership();
+ });
+
+ it("should index a new item via the notifier", async function () {
+ let tok = token();
+ let item = await createDataObject('item', { title: tok + ' new' });
+ let path = `library/items/${item.key}`;
+ let results = await searchUntil(`title == "*${tok}*"c`, r => r.includes(path));
+ assert.include(results, path);
+ });
+
+ it("should re-index an item when it is modified", async function () {
+ let tok1 = token();
+ let item = await createDataObject('item', { title: tok1 });
+ let path = `library/items/${item.key}`;
+ assert.include(await searchUntil(`title == "*${tok1}*"c`, r => r.includes(path)), path);
+
+ let tok2 = token();
+ item.setField('title', tok2);
+ await item.saveTx();
+ let results = await searchUntil(`title == "*${tok2}*"c`, r => r.includes(path));
+ assert.include(results, path);
+ });
+
+ it("should remove an item from the index when it is trashed", async function () {
+ let tok = token();
+ let item = await createDataObject('item', { title: tok });
+ let path = `library/items/${item.key}`;
+ // Confirm it's indexed first, so the removal check can't pass vacuously.
+ assert.include(await searchUntil(`title == "*${tok}*"c`, r => r.includes(path)), path);
+
+ item.deleted = true;
+ await item.saveTx();
+ let results = await searchUntil(`title == "*${tok}*"c`, r => !r.includes(path));
+ assert.notInclude(results, path);
+ });
+
+ it("should remove an item from the index when it is deleted", async function () {
+ let tok = token();
+ let item = await createDataObject('item', { title: tok });
+ let path = `library/items/${item.key}`;
+ // Confirm it's indexed first, so the removal check can't pass vacuously.
+ assert.include(await searchUntil(`title == "*${tok}*"c`, r => r.includes(path)), path);
+
+ await item.eraseTx();
+ let results = await searchUntil(`title == "*${tok}*"c`, r => !r.includes(path));
+ assert.notInclude(results, path);
+ });
+
+ it("should index the whole library via rebuild()", async function () {
+ let tok = token();
+ let item = await createDataObject('item', { title: tok });
+ let path = `library/items/${item.key}`;
+ await Zotero.Spotlight.rebuild();
+ let results = await searchUntil(`title == "*${tok}*"c`, r => r.includes(path));
+ assert.include(results, path);
+ });
+
+ it("should put item fields into searchable content", async function () {
+ let tok = token();
+ let item = await createDataObject('item', { itemType: 'journalArticle', title: 'Field Content' });
+ item.setField('abstractNote', 'abstract body ' + tok);
+ await item.saveTx();
+ let path = `library/items/${item.key}`;
+ // The token is only in a field, so it must surface via textContent.
+ let results = await searchUntil(`textContent == "*${tok}*"c`, r => r.includes(path));
+ assert.include(results, path);
+ });
+
+ it("should put child note text into searchable content", async function () {
+ let tok = token();
+ let parent = await createDataObject('item', { itemType: 'journalArticle', title: 'Note Parent ' + token() });
+ let note = new Zotero.Item('note');
+ note.parentItemID = parent.id;
+ note.setNote(`note body ${tok}
`);
+ await note.saveTx();
+ let path = `library/items/${parent.key}`;
+ // Drop the note from the cache so indexing must load its data itself,
+ // otherwise getNote() would throw on the unloaded note.
+ Zotero.Items.unload(note.id);
+ await Zotero.Spotlight.rebuild();
+ // The token is only in the child note's HTML body.
+ let results = await searchUntil(`textContent == "*${tok}*"c`, r => r.includes(path));
+ assert.include(results, path);
+ });
+
+ it("should index attachment full text only when enabled", async function () {
+ let tok = token();
+ let parent = await createDataObject('item', { title: 'FullText Parent ' + token() });
+ let path = `library/items/${parent.key}`;
+
+ // A plain-text attachment whose *file contents* (not its name/title)
+ // hold the token, so a textContent hit can only come from full text.
+ let tmpFile = Zotero.getTempDirectory();
+ tmpFile.append('attachment.txt');
+ await Zotero.File.putContentsAsync(tmpFile.path, 'attachment full text ' + tok);
+ await Zotero.Attachments.importFromFile({
+ file: tmpFile,
+ parentItemID: parent.id,
+ contentType: 'text/plain',
+ charset: 'utf-8',
+ title: 'plainfile'
+ });
+
+ Zotero.Prefs.set('spotlight.indexFullText', true);
+ await Zotero.Spotlight.rebuild();
+ let withFT = await searchUntil(`textContent == "*${tok}*"c`, r => r.includes(path));
+ assert.include(withFT, path, "full text should be indexed when enabled");
+
+ Zotero.Prefs.set('spotlight.indexFullText', false);
+ await Zotero.Spotlight.rebuild();
+ let withoutFT = await searchUntil(`textContent == "*${tok}*"c`, r => !r.includes(path));
+ assert.notInclude(withoutFT, path, "full text should not be indexed when disabled");
+
+ Zotero.Prefs.set('spotlight.indexFullText', true);
+ });
+
+ it("should still index full text when a huge note would fill the budget", async function () {
+ let tok = token();
+ let parent = await createDataObject('item', { title: 'Reserve Parent ' + token() });
+ let path = `library/items/${parent.key}`;
+
+ // A note longer than the whole content budget. Without a reserved
+ // slice for full text, this note alone would crowd it out entirely.
+ let note = new Zotero.Item('note');
+ note.parentItemID = parent.id;
+ note.setNote('' + 'filler '.repeat(10000) + '
');
+ await note.saveTx();
+
+ // Full text (token only in the file contents) must still land.
+ let tmpFile = Zotero.getTempDirectory();
+ tmpFile.append('reserve.txt');
+ await Zotero.File.putContentsAsync(tmpFile.path, 'reserved full text ' + tok);
+ await Zotero.Attachments.importFromFile({
+ file: tmpFile,
+ parentItemID: parent.id,
+ contentType: 'text/plain',
+ charset: 'utf-8',
+ title: 'reservefile'
+ });
+
+ Zotero.Prefs.set('spotlight.indexFullText', true);
+ await Zotero.Spotlight.rebuild();
+ let results = await searchUntil(`textContent == "*${tok}*"c`, r => r.includes(path));
+ assert.include(results, path, "full text should survive a budget-filling note");
+ });
+
+ it("should skip excluded libraries and flag needsReindex", async function () {
+ let tok = token();
+ let item = await createDataObject('item', { title: tok });
+ let path = `library/items/${item.key}`;
+ await Zotero.Spotlight.rebuild();
+ // Confirm it's indexed first, so the exclusion check can't pass vacuously.
+ assert.include(await searchUntil(`title == "*${tok}*"c`, r => r.includes(path)), path);
+
+ // Excluding the user library should make a rebuild needed...
+ Zotero.Prefs.set('spotlight.excludedLibraries',
+ JSON.stringify([Zotero.Libraries.userLibraryID]));
+ assert.isTrue(Zotero.Spotlight.needsReindex());
+
+ // ...and after rebuilding, the item should be gone.
+ await Zotero.Spotlight.rebuild();
+ let excluded = await searchUntil(`title == "*${tok}*"c`, r => !r.includes(path));
+ assert.notInclude(excluded, path);
+
+ // Re-including brings it back.
+ Zotero.Prefs.set('spotlight.excludedLibraries', '[]');
+ await Zotero.Spotlight.rebuild();
+ let included = await searchUntil(`title == "*${tok}*"c`, r => r.includes(path));
+ assert.include(included, path);
+ });
+
+ it("should apply the title template to the indexed title", async function () {
+ let tok = token();
+ Zotero.Prefs.set('spotlight.titleTemplate', tok + ' {{ title }}');
+ let item = await createDataObject('item', { title: 'Templated' });
+ let path = `library/items/${item.key}`;
+ await Zotero.Spotlight.rebuild();
+ // The template prefix only appears in the title if templating ran.
+ let results = await searchUntil(`title == "*${tok}*"c`, r => r.includes(path));
+ assert.include(results, path);
+ Zotero.Prefs.set('spotlight.titleTemplate', '{{ title }}');
+ });
+
+ it("should support shared filename-template variables and modifiers", async function () {
+ // replaceFrom/replaceTo is a file-rename modifier the old renderer
+ // lacked -- proving Spotlight now shares that engine.
+ let tokA = token();
+ let tokB = token();
+ Zotero.Prefs.set('spotlight.titleTemplate', `{{ title replaceFrom="${tokA}" replaceTo="${tokB}" }}`);
+ let item = await createDataObject('item', { title: tokA });
+ let path = `library/items/${item.key}`;
+ await Zotero.Spotlight.rebuild();
+ // Title indexed as the replacement...
+ let replaced = await searchUntil(`title == "*${tokB}*"c`, r => r.includes(path));
+ assert.include(replaced, path);
+ // ...not the original.
+ let original = await searchUntil(`title == "*${tokA}*"c`, r => !r.includes(path));
+ assert.notInclude(original, path);
+ Zotero.Prefs.set('spotlight.titleTemplate', '{{ title }}');
+ });
+
+ it("should not be visible to mdfind (Core Spotlight is separate)", async function () {
+ let tok = token();
+ let item = await createDataObject('item', { title: tok });
+ let path = `library/items/${item.key}`;
+ // In the real index per our query...
+ let results = await searchUntil(`title == "*${tok}*"c`, r => r.includes(path));
+ assert.include(results, path);
+ // ...but mdfind (file-metadata index) can't see Core Spotlight content.
+ let out = await Zotero.Utilities.Internal.subprocess('/usr/bin/mdfind', [tok]);
+ assert.notInclude(out, item.key);
+ });
+ });
+});