Implement MacOS Spotlight Support

Use a in-app dylib for indexing control
Patch the xul lib for spotlight action handling
This commit is contained in:
windingwind 2026-06-29 17:40:42 +02:00
parent 2006cdac6d
commit 45a4cb11a7
17 changed files with 2478 additions and 34 deletions

View file

@ -727,7 +727,20 @@ if [ $BUILD_MAC == 1 ]; then
mkdir "$CONTENTSDIR/Resources/integration"
cp -RH "$CALLDIR/modules/zotero-libreoffice-integration/install" "$CONTENTSDIR/Resources/integration/libreoffice"
cp -RH "$CALLDIR/modules/zotero-word-for-mac-integration/install" "$CONTENTSDIR/Resources/integration/word-for-mac"
# Build the Core Spotlight bridge dylib
if command -v clang >/dev/null 2>&1; then
clang -dynamiclib -fobjc-arc -O2 -mmacosx-version-min=11.0 \
-arch x86_64 -arch arm64 \
-framework Foundation -framework AppKit -framework CoreServices \
-framework CoreSpotlight -framework UniformTypeIdentifiers \
-o "$CONTENTSDIR/Resources/libZoteroSpotlight.dylib" \
"$CALLDIR/mac/spotlight/ZoteroSpotlight.m" \
|| echo " ! libZoteroSpotlight.dylib build failed -- continuing without Spotlight"
else
echo " ! clang not found -- skipping libZoteroSpotlight.dylib build"
fi
# Delete extraneous files
find "$CONTENTSDIR" -depth -type d -name .git -exec rm -rf {} \;
find "$CONTENTSDIR" \( -name .DS_Store -or -name update.rdf \) -exec rm -f {} \;
@ -813,6 +826,18 @@ if [ $BUILD_MAC == 1 ]; then
echo
/usr/bin/codesign --verify -vvvv "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
fi
else
# Ad-hoc sign unsigned (source/dev) builds: Core Spotlight only indexes
# for a signed app. No hardened runtime here, so Gecko's JIT still works.
echo "Ad-hoc signing $APP_NAME.app (unsigned build)"
/usr/bin/xattr -cr "$APPDIR"
# Sign our dylib, then the whole bundle (shallow if --deep trips on a
# nested component, so ad-hoc signing can't fail the build).
if [ -f "$APPDIR/Contents/Resources/libZoteroSpotlight.dylib" ]; then
/usr/bin/codesign --force --sign - "$APPDIR/Contents/Resources/libZoteroSpotlight.dylib"
fi
/usr/bin/codesign --force --deep --sign - "$APPDIR" \
|| /usr/bin/codesign --force --sign - "$APPDIR"
fi
# Build and notarize disk image

View file

@ -200,5 +200,9 @@
<true/>
<key>LSMinimumSystemVersion</key>
<string>10.9.0</string>
<key>NSUserActivityTypes</key>
<array>
<string>com.apple.corespotlightitem</string>
</array>
</dict>
</plist>

33
app/mac/mozilla-140.patch Normal file
View file

@ -0,0 +1,33 @@
diff --git a/toolkit/xre/MacApplicationDelegate.mm b/toolkit/xre/MacApplicationDelegate.mm
index b7db989..dc0798b 100644
--- a/toolkit/xre/MacApplicationDelegate.mm
+++ b/toolkit/xre/MacApplicationDelegate.mm
@@ -334,6 +334,28 @@ - (BOOL)application:(NSApplication*)application
continueUserActivity:(NSUserActivity*)userActivity
restorationHandler:
(void (^)(NSArray<id<NSUserActivityRestoring>>*))restorationHandler {
+
+ // Handle a clicked Core Spotlight result: its identifier is
+ // "<bundleid>:<zotero-path>", so route it as zotero://spotlight/<path> for the
+ // JS handler to open or select the item, even on a cold launch.
+ if ([userActivity.activityType isEqualToString:@"com.apple.corespotlightitem"]) {
+ NSString* zoteroIdent =
+ userActivity.userInfo[@"kCSSearchableItemActivityIdentifier"];
+ if ([zoteroIdent isKindOfClass:[NSString class]]) {
+ NSRange zoteroSep = [zoteroIdent rangeOfString:@":"];
+ if (zoteroSep.location != NSNotFound) {
+ NSURL* zoteroURL = [NSURL
+ URLWithString:[@"zotero://spotlight/"
+ stringByAppendingString:
+ [zoteroIdent
+ substringFromIndex:zoteroSep.location + 1]]];
+ if (zoteroURL) {
+ return [self openURLs:@[ zoteroURL ]];
+ }
+ }
+ }
+ }
+
if (![userActivity.activityType
isEqualToString:NSUserActivityTypeBrowsingWeb]) {
return NO;

View file

@ -0,0 +1,385 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2026 Corporation for Digital Scholarship
Vienna, Virginia, USA
https://www.zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
// In-process Core Spotlight bridge, built as a dylib and loaded via js-ctypes.
// Indexed items are attributed to Zotero.app's own bundle id.
// The C entry points below are called from spotlight.js.
// Entry activities are handled in XUL (the patched MacApplicationDelegate),
// as on a cold launch the activity arrives before this dylib is loaded.
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <CoreSpotlight/CoreSpotlight.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#define EXPORT __attribute__((visibility("default")))
// Spotlight domain is the app's own bundle id (per channel), so channels never collide.
static NSString *ZSDomain(void) {
static NSString *domain = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
domain = [[NSBundle mainBundle] bundleIdentifier] ?: @"org.zotero.zotero";
});
return domain;
}
static NSString *ZSUniqueIdentifier(NSString *path) {
return [NSString stringWithFormat:@"%@:%@", ZSDomain(), path];
}
// Per-library subdomain of the channel domain, e.g. "<bundleid>.library" or
// "<bundleid>.groups.123". Domain deletes are hierarchical, so deleting the
// channel domain clears everything while a library subdomain clears just one.
static NSString *ZSDomainForLibrary(NSString *token) {
if (![token isKindOfClass:[NSString class]] || !token.length) {
return ZSDomain();
}
return [NSString stringWithFormat:@"%@.%@", ZSDomain(), token];
}
static CSSearchableIndex *ZSIndex(void) {
static CSSearchableIndex *index = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
index = [[CSSearchableIndex alloc] initWithName:ZSDomain()];
});
return index;
}
EXPORT const char *zotero_spotlight_bundle_id(void) {
static const char *cached = NULL;
if (!cached) {
cached = strdup([ZSDomain() UTF8String]);
}
return cached;
}
// Diagnostics for why indexing might fail (Core Spotlight silently no-ops for an unsigned app).
static NSString *gLastError = nil;
static void ZSSetError(NSError *error) {
@synchronized ([CSSearchableIndex class]) {
gLastError = error.localizedDescription;
}
if (error) {
NSLog(@"ZoteroSpotlight error: %@", error);
}
}
EXPORT int zotero_spotlight_indexing_available(void) {
return [CSSearchableIndex isIndexingAvailable] ? 1 : 0;
}
EXPORT const char *zotero_spotlight_last_error(void) {
static char *buf = NULL;
@synchronized ([CSSearchableIndex class]) {
if (buf) {
free(buf);
buf = NULL;
}
if (gLastError) {
buf = strdup([gLastError UTF8String]);
}
}
return buf;
}
static NSData *ZSAppIconPNG(void) {
static NSData *png = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSImage *icon = [NSApp applicationIconImage];
if (!icon) {
return;
}
NSSize size = NSMakeSize(64, 64);
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc]
initWithBitmapDataPlanes:NULL
pixelsWide:(NSInteger)size.width
pixelsHigh:(NSInteger)size.height
bitsPerSample:8
samplesPerPixel:4
hasAlpha:YES
isPlanar:NO
colorSpaceName:NSDeviceRGBColorSpace
bytesPerRow:0
bitsPerPixel:0];
if (!rep) {
return;
}
[NSGraphicsContext saveGraphicsState];
NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep];
[icon drawInRect:NSMakeRect(0, 0, size.width, size.height)];
[NSGraphicsContext restoreGraphicsState];
png = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
});
return png;
}
static NSDate *ZSParseYear(NSString *date) {
if (![date isKindOfClass:[NSString class]] || date.length < 4) {
return nil;
}
NSDateComponents *comps = [[NSDateComponents alloc] init];
comps.year = [[date substringToIndex:4] integerValue];
if (comps.year <= 0) {
return nil;
}
comps.month = 1;
comps.day = 1;
return [[NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian] dateFromComponents:comps];
}
static id ZSParseJSON(const char *json) {
if (!json) {
return nil;
}
NSData *data = [NSData dataWithBytes:json length:strlen(json)];
return [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL];
}
// recordsJSON: array of { path, title, library?, description?, keywords?, content?, date? }
EXPORT int zotero_spotlight_index(const char *recordsJSON) {
@autoreleasepool {
id parsed = ZSParseJSON(recordsJSON);
if (![parsed isKindOfClass:[NSArray class]]) {
return 1;
}
NSData *icon = ZSAppIconPNG();
NSMutableArray<CSSearchableItem *> *items = [NSMutableArray array];
for (NSDictionary *record in (NSArray *)parsed) {
if (![record isKindOfClass:[NSDictionary class]]) {
continue;
}
NSString *path = record[@"path"];
NSString *title = record[@"title"];
if (![path isKindOfClass:[NSString class]] || ![title isKindOfClass:[NSString class]]) {
continue;
}
CSSearchableItemAttributeSet *attrs =
[[CSSearchableItemAttributeSet alloc] initWithContentType:UTTypeText];
attrs.title = title;
if ([record[@"description"] isKindOfClass:[NSString class]]) {
attrs.contentDescription = record[@"description"];
}
if ([record[@"keywords"] isKindOfClass:[NSArray class]]) {
attrs.keywords = record[@"keywords"];
}
// Searchable but not displayed -- fields, notes, filenames, full text.
if ([record[@"content"] isKindOfClass:[NSString class]]) {
attrs.textContent = record[@"content"];
}
NSDate *date = ZSParseYear(record[@"date"]);
if (date) {
attrs.contentCreationDate = date;
}
if (icon) {
attrs.thumbnailData = icon;
}
[items addObject:[[CSSearchableItem alloc]
initWithUniqueIdentifier:ZSUniqueIdentifier(path)
domainIdentifier:ZSDomainForLibrary(record[@"library"])
attributeSet:attrs]];
}
if (items.count) {
[ZSIndex() indexSearchableItems:items
completionHandler:^(NSError *error) {
ZSSetError(error);
}];
}
return 0;
}
}
// pathsJSON: array of zotero paths (e.g. "library/items/KEY")
EXPORT int zotero_spotlight_remove(const char *pathsJSON) {
@autoreleasepool {
id parsed = ZSParseJSON(pathsJSON);
if (![parsed isKindOfClass:[NSArray class]]) {
return 1;
}
NSMutableArray<NSString *> *ids = [NSMutableArray array];
for (NSString *path in (NSArray *)parsed) {
if ([path isKindOfClass:[NSString class]]) {
[ids addObject:ZSUniqueIdentifier(path)];
}
}
if (ids.count) {
[ZSIndex() deleteSearchableItemsWithIdentifiers:ids
completionHandler:^(NSError *error) {
ZSSetError(error);
}];
}
return 0;
}
}
// tokensJSON: array of library domain tokens (e.g. "groups.123"). Deletes every
// indexed item under each "<bundleid>.<token>" domain (and its subdomains).
EXPORT int zotero_spotlight_remove_libraries(const char *tokensJSON) {
@autoreleasepool {
id parsed = ZSParseJSON(tokensJSON);
if (![parsed isKindOfClass:[NSArray class]]) {
return 1;
}
NSMutableArray<NSString *> *domains = [NSMutableArray array];
for (NSString *token in (NSArray *)parsed) {
if ([token isKindOfClass:[NSString class]] && token.length) {
[domains addObject:ZSDomainForLibrary(token)];
}
}
if (domains.count) {
[ZSIndex() deleteSearchableItemsWithDomainIdentifiers:domains
completionHandler:^(NSError *error) {
ZSSetError(error);
}];
}
return 0;
}
}
EXPORT int zotero_spotlight_reset(void) {
@autoreleasepool {
[ZSIndex() deleteSearchableItemsWithDomainIdentifiers:@[ZSDomain()]
completionHandler:^(NSError *error) {
ZSSetError(error);
}];
return 0;
}
}
// Read back the index for tests/diagnostics (mdfind can't see Core Spotlight):
// run a query (e.g. `title == "*term*"c`) -> JSON array of matching ids. Blocks briefly.
EXPORT const char *zotero_spotlight_query(const char *queryStringC) {
static char *buf = NULL;
@autoreleasepool {
if (buf) {
free(buf);
buf = NULL;
}
if (!queryStringC) {
return NULL;
}
NSString *queryString = [NSString stringWithUTF8String:queryStringC];
if (!queryString.length) {
return NULL;
}
NSMutableArray<NSString *> *found = [NSMutableArray array];
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
// initWithQueryString:attributes: is deprecated in favor of the
// queryContext: variant (macOS 12+), but works back to our 11.0 minimum.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
CSSearchQuery *query = [[CSSearchQuery alloc]
initWithQueryString:queryString
attributes:@[@"title", @"displayName"]];
#pragma clang diagnostic pop
query.foundItemsHandler = ^(NSArray<CSSearchableItem *> *items) {
@synchronized (found) {
for (CSSearchableItem *item in items) {
if (item.uniqueIdentifier) {
[found addObject:item.uniqueIdentifier];
}
}
}
};
query.completionHandler = ^(NSError *error) {
ZSSetError(error);
dispatch_semaphore_signal(sem);
};
@try {
[query start];
dispatch_semaphore_wait(sem,
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(8 * NSEC_PER_SEC)));
}
@catch (NSException *e) {
// An invalid query string raises rather than calling the handler.
NSLog(@"ZoteroSpotlight query error: %@", e);
}
NSArray *snapshot;
@synchronized (found) {
snapshot = [found copy];
}
NSData *json = [NSJSONSerialization dataWithJSONObject:snapshot options:0 error:NULL];
if (json) {
NSString *str = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding];
if (str) {
buf = strdup([str UTF8String]);
}
}
}
return buf;
}
EXPORT int zotero_spotlight_set_client_state(const char *stateC) {
@autoreleasepool {
NSData *state = stateC
? [NSData dataWithBytes:stateC length:strlen(stateC)]
: [NSData data];
CSSearchableIndex *index = ZSIndex();
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
[index beginIndexBatch];
[index endIndexBatchWithClientState:state completionHandler:^(NSError *error) {
ZSSetError(error);
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem,
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(8 * NSEC_PER_SEC)));
return 0;
}
}
EXPORT const char *zotero_spotlight_get_client_state(void) {
static char *buf = NULL;
@autoreleasepool {
if (buf) {
free(buf);
buf = NULL;
}
__block NSData *state = nil;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
[ZSIndex()
fetchLastClientStateWithCompletionHandler:^(NSData *clientState, NSError *error) {
ZSSetError(error);
state = clientState;
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem,
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(8 * NSEC_PER_SEC)));
if (state.length) {
NSString *str = [[NSString alloc] initWithData:state encoding:NSUTF8StringEncoding];
if (str) {
buf = strdup([str UTF8String]);
return buf;
}
}
return NULL;
}
}

View file

@ -681,7 +681,78 @@ export function ZoteroProtocolHandler() {
this.doAction(uri);
}
};
/**
* zotero://spotlight/library/items/[itemKey]
* zotero://spotlight/groups/[groupID]/items/[itemKey]
*
* Target of a clicked macOS Core Spotlight result.
*/
var SpotlightExtension = {
noContent: true,
// Resolve the item for the two path formats spotlight.js indexes
// (library/items/KEY and groups/GROUPID/items/KEY).
_getItem: async function (uri) {
var path = uri.pathQueryRef;
if (!path) {
return null;
}
path = path.substring(1);
var params = { objectType: 'item' };
var router = new Zotero.Router(params);
router.add('library/items/:objectKey', function () {
params.libraryID = Zotero.Libraries.userLibraryID;
});
router.add('groups/:groupID/items/:objectKey');
router.run(path);
Zotero.API.parseParams(params);
if (!params.objectKey && !params.objectID && !params.itemKey) {
return null;
}
var results = await Zotero.API.getResultsFromParams(params);
return results.length ? results[0] : null;
},
doAction: async function (uri) {
if (Zotero.Prefs.get('spotlight.openOnConfirm')) {
let item = await this._getItem(uri);
let attachment = null;
if (item) {
if (item.isFileAttachment()) {
attachment = item;
}
else if (item.isRegularItem()) {
attachment = await item.getBestAttachment();
}
}
if (attachment && attachment.isFileAttachment()) {
try {
await Zotero.FileHandlers.open(attachment, {
openInWindow: Zotero.Prefs.get('openReaderInNewWindow'),
});
Zotero.Notifier.trigger('open', 'file', attachment.id);
return;
}
catch (e) {
Zotero.logError(e);
// Fall through to selecting the item
}
}
}
// Default, and fallback when there's nothing to open
await SelectExtension.doAction(uri);
},
newChannel: function (uri) {
this.doAction(uri);
}
};
/*
zotero://debug/
*/
@ -888,6 +959,7 @@ export function ZoteroProtocolHandler() {
this._extensions[ZOTERO_SCHEME + "://data"] = DataExtension;
this._extensions[ZOTERO_SCHEME + "://report"] = ReportExtension;
this._extensions[ZOTERO_SCHEME + "://select"] = SelectExtension;
this._extensions[ZOTERO_SCHEME + "://spotlight"] = SpotlightExtension;
this._extensions[ZOTERO_SCHEME + "://debug"] = DebugExtension;
this._extensions[ZOTERO_SCHEME + "://pdf.js"] = PDFJSExtension;
this._extensions[ZOTERO_SCHEME + "://open"] = OpenExtension;

View file

@ -64,6 +64,10 @@ Zotero_Preferences.Advanced = {
document.getElementById('zotero-prefpane-advanced-enable-local-api').addEventListener('synctopreference', () => {
this.updateLocalAPIUI();
});
if (Zotero_Preferences.Spotlight) {
Zotero_Preferences.Spotlight.init();
}
},

View file

@ -281,9 +281,102 @@
<label value="&zotero.preferences.search.indexStats.words;"/>
<label id="fulltext-stats-words"/>
</vbox>
</groupbox>
</groupbox>
</vbox>
<vbox class="main-section" id="zotero-prefpane-advanced-spotlight" role="group" hidden="true"
aria-labelledby="zotero-prefpane-advanced-spotlight-title">
<html:h1 id="zotero-prefpane-advanced-spotlight-title" data-l10n-id="preferences-spotlight-title"/>
<groupbox>
<checkbox
id="spotlight-enable"
data-l10n-id="preferences-spotlight-enable"
preference="extensions.zotero.spotlight.enabled"
native="true"
/>
<vbox id="spotlight-controls">
<hbox align="center" class="indented-pref">
<label data-l10n-id="preferences-spotlight-result-action" control="spotlight-result-action"/>
<menulist id="spotlight-result-action" native="true">
<menupopup>
<menuitem data-l10n-id="preferences-spotlight-result-action-show" value="false"/>
<menuitem data-l10n-id="preferences-spotlight-result-action-open" value="true"/>
</menupopup>
</menulist>
</hbox>
<checkbox
id="spotlight-index-fulltext"
class="indented-pref"
data-l10n-id="preferences-spotlight-index-fulltext"
preference="extensions.zotero.spotlight.indexFullText"
native="true"
/>
</vbox>
<vbox id="spotlight-owner-notice" hidden="true">
<hbox align="center">
<label id="spotlight-owner-text" class="text-muted"/>
</hbox>
<hbox>
<button id="spotlight-take-ownership"
data-l10n-id="preferences-spotlight-take-ownership"
oncommand="Zotero_Preferences.Spotlight.takeOwnership()"/>
</hbox>
</vbox>
</groupbox>
<hbox align="center">
<button id="spotlight-rebuild" data-l10n-id="preferences-spotlight-rebuild"
oncommand="Zotero_Preferences.Spotlight.rebuild()"/>
</hbox>
<description id="spotlight-status" class="text-muted" hidden="true"/>
<groupbox id="spotlight-libraries-group">
<label><html:h2 data-l10n-id="preferences-spotlight-libraries-title"/></label>
<vbox id="spotlight-libraries"/>
</groupbox>
<groupbox id="spotlight-privacy-group">
<label><html:h2 data-l10n-id="preferences-spotlight-privacy-title"/></label>
<description data-l10n-id="preferences-spotlight-privacy-description"/>
<checkbox
id="spotlight-exclude-data-dir"
data-l10n-id="preferences-spotlight-exclude-data-dir"
preference="extensions.zotero.spotlight.excludeDataDir"
native="true"
/>
</groupbox>
<groupbox id="spotlight-format-group">
<label><html:h2 data-l10n-id="preferences-spotlight-format-title"/></label>
<description data-l10n-id="preferences-spotlight-format-description">
<label is="zotero-text-link"
href="https://www.zotero.org/support/file_renaming"
data-l10n-name="spotlight-format-help-link"/>
</description>
<hbox style="display: flex; align-items: center;">
<label data-l10n-id="preferences-spotlight-title-template" control="spotlight-title-template"/>
<html:input
id="spotlight-title-template"
type="text"
style="flex: 1;"
preference="extensions.zotero.spotlight.titleTemplate"
/>
</hbox>
<hbox style="display: flex; align-items: center;">
<label data-l10n-id="preferences-spotlight-description-template" control="spotlight-description-template"/>
<html:input
id="spotlight-description-template"
type="text"
style="flex: 1;"
preference="extensions.zotero.spotlight.descriptionTemplate"
/>
</hbox>
</groupbox>
</vbox>
<vbox>
<hbox id="zotero-prefpane-advanced-open-buttons" align="center">
<button id="config-editor"

View file

@ -0,0 +1,194 @@
/*
***** 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 *****
*/
Zotero_Preferences.Spotlight = {
init: function () {
let section = document.getElementById('zotero-prefpane-advanced-spotlight');
if (!section) {
return;
}
if (!Zotero.isMac || !Zotero.Spotlight.available) {
section.hidden = true;
return;
}
section.hidden = false;
this._buildLibraryList();
this._wireResultAction();
this._updateEnabledUI();
document.getElementById('spotlight-enable').addEventListener(
'synctopreference', () => this._updateEnabledUI()
);
for (let id of ['spotlight-title-template', 'spotlight-description-template', 'spotlight-index-fulltext']) {
document.getElementById(id).addEventListener(
'synctopreference', () => this._updateStatus()
);
}
},
_wireResultAction: function () {
let menulist = document.getElementById('spotlight-result-action');
if (!menulist) {
return;
}
menulist.value = Zotero.Prefs.get('spotlight.openOnConfirm') ? 'true' : 'false';
menulist.addEventListener('command', () => {
Zotero.Prefs.set('spotlight.openOnConfirm', menulist.value === 'true');
});
},
_updateEnabledUI: async function () {
let enabled = !!Zotero.Prefs.get('spotlight.enabled');
let owner = enabled ? await Zotero.Spotlight.getForeignOwner() : null;
let ownerNotice = document.getElementById('spotlight-owner-notice');
ownerNotice.hidden = !owner;
if (owner) {
document.l10n.setAttributes(
document.getElementById('spotlight-owner-text'),
'preferences-spotlight-owner-other',
{ path: owner.dataDir }
);
}
let showControls = enabled && !owner;
for (let id of [
'spotlight-controls',
'spotlight-libraries-group',
'spotlight-privacy-group',
'spotlight-format-group',
'spotlight-rebuild'
]) {
let el = document.getElementById(id);
if (el) {
el.hidden = !showControls;
}
}
if (showControls) {
this._updateStatus();
}
else {
document.getElementById('spotlight-status').hidden = true;
}
},
_updateStatus: function () {
let status = document.getElementById('spotlight-status');
if (!status) {
return;
}
if (Zotero.Spotlight.needsReindex()) {
document.l10n.setAttributes(status, 'preferences-spotlight-reindex-notice');
status.hidden = false;
}
else {
status.removeAttribute('data-l10n-id');
status.textContent = '';
status.hidden = true;
}
},
takeOwnership: async function () {
let button = document.getElementById('spotlight-take-ownership');
if (button) {
button.disabled = true;
}
try {
await Zotero.Spotlight.takeOwnership();
}
catch (e) {
Zotero.logError(e);
}
finally {
if (button) {
button.disabled = false;
}
// Refresh in place so the full controls replace the take-over button.
await this._updateEnabledUI();
}
},
_excludedLibraries: function () {
try {
let raw = Zotero.Prefs.get('spotlight.excludedLibraries');
let arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr.map(Number) : [];
}
catch {
return [];
}
},
_buildLibraryList: function () {
let container = document.getElementById('spotlight-libraries');
container.textContent = '';
let excluded = this._excludedLibraries();
let libraries = Zotero.Libraries.getAll()
.filter(library => library.libraryType === 'user' || library.libraryType === 'group');
for (let library of libraries) {
let checkbox = document.createXULElement('checkbox');
checkbox.setAttribute('native', 'true');
checkbox.setAttribute('label', library.name);
checkbox.checked = !excluded.includes(library.libraryID);
checkbox.addEventListener('command', () => {
this._setLibraryIncluded(library.libraryID, checkbox.checked);
});
container.appendChild(checkbox);
}
},
_setLibraryIncluded: function (libraryID, included) {
let excluded = new Set(this._excludedLibraries());
if (included) {
excluded.delete(libraryID);
}
else {
excluded.add(libraryID);
}
Zotero.Prefs.set('spotlight.excludedLibraries', JSON.stringify([...excluded]));
this._updateStatus();
},
rebuild: async function () {
let button = document.getElementById('spotlight-rebuild');
button.disabled = true;
button.removeAttribute('data-l10n-id');
button.label = Zotero.getString('general.processing');
try {
await Zotero.Spotlight.rebuild();
}
catch (e) {
Zotero.logError(e);
}
finally {
document.l10n.setAttributes(button, 'preferences-spotlight-rebuild');
button.disabled = false;
this._updateStatus();
}
}
};

View file

@ -2286,40 +2286,19 @@ Zotero.Attachments = new function () {
}
/*
* Returns a formatted string to use as the basename of an attachment
* based on the metadata of the specified item and a format string
*
* (Optional) |formatString| specifies the format string -- otherwise
* the 'attachmentRenameTemplate' synced setting for the user's library is used
/**
* Build the {{ variable }} template variables for an item, shared by file
* renaming (getFileBaseNameFromItem) and display rendering
* (getStringFromItemTemplate).
*
* @param {Zotero.Item} item
* @param {String} formatString
* @param {String} attachmentTitle
* @return {{ vars: Object, chunks: Array, protectedLiterals: Set }}
*/
this.getFileBaseNameFromItem = function (item, options = {}) {
if (!(item instanceof Zotero.Item)) {
throw new Error("'item' must be a Zotero.Item");
}
if (!item.libraryID) {
throw new Error("Item must have a libraryID");
}
if (typeof options === 'string') {
Zotero.warn("Zotero.Attachments.getFileBaseNameFromItem(item, formatString) is deprecated -- use Zotero.Attachments.getFileBaseNameFromItem(item, options)");
options = { formatString: options };
}
let { formatString = null, attachmentTitle = '' } = options;
if (!formatString) {
const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
formatString = Zotero.SyncedSettings.get(item.libraryID, 'attachmentRenameTemplate') ?? DEFAULT_ATTACHMENT_RENAME_TEMPLATE;
}
function _buildItemTemplateVars(item, attachmentTitle) {
let chunks = [];
let protectedLiterals = new Set();
formatString = formatString.replace(/\r?\n|\r/g, "").trim();
const getSlicedCreatorsOfType = (creatorType, slice) => {
let creatorTypeIDs;
switch (creatorType) {
@ -2544,6 +2523,40 @@ Zotero.Attachments = new function () {
};
const vars = { ...fields, ...creatorFields, attachmentTitle: attachmentTitleFn, firstCreator, itemType, year, accessDate };
return { vars, chunks, protectedLiterals };
}
/**
* Returns a formatted string to use as the basename of an attachment
* based on the metadata of the specified item and a format string
*
* @param {Zotero.Item} item
* @param {Object} [options]
* @param {String} options.formatString - specifies the format string -- otherwise
* the 'attachmentRenameTemplate' synced setting for the user's library is used
*/
this.getFileBaseNameFromItem = function (item, options = {}) {
if (!(item instanceof Zotero.Item)) {
throw new Error("'item' must be a Zotero.Item");
}
if (!item.libraryID) {
throw new Error("Item must have a libraryID");
}
if (typeof options === 'string') {
Zotero.warn("Zotero.Attachments.getFileBaseNameFromItem(item, formatString) is deprecated -- use Zotero.Attachments.getFileBaseNameFromItem(item, options)");
options = { formatString: options };
}
let { formatString = null, attachmentTitle = '' } = options;
if (!formatString) {
const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
formatString = Zotero.SyncedSettings.get(item.libraryID, 'attachmentRenameTemplate') ?? DEFAULT_ATTACHMENT_RENAME_TEMPLATE;
}
formatString = formatString.replace(/\r?\n|\r/g, "").trim();
let { vars, chunks, protectedLiterals } = _buildItemTemplateVars(item, attachmentTitle);
// Final name is generated twice. In the first pass we collect all affixed values and determine protected literals.
// This is done in order to remove repeated suffixes, except if these appear in the value or the format string itself.
@ -2584,6 +2597,26 @@ Zotero.Attachments = new function () {
return formatted;
};
/**
* Render an item-metadata template to a display string, using the same
* {{ variable }} syntax and variables as file renaming.
*
* @param {Zotero.Item} item
* @param {Object} [options]
* @param {String} options.formatString
* @return {String}
*/
this.getStringFromItemTemplate = function (item, options = {}) {
if (!(item instanceof Zotero.Item)) {
throw new Error("'item' must be a Zotero.Item");
}
let { formatString = '', attachmentTitle = '' } = options;
formatString = String(formatString ?? '').replace(/\r?\n|\r/g, "").trim();
let { vars } = _buildItemTemplateVars(item, attachmentTitle);
let formatted = Zotero.Utilities.Internal.generateHTMLFromTemplate(formatString, vars);
return Zotero.Utilities.cleanTags(formatted).trim();
};
/**
* @returns {String} Current file extension for the attachment, if it appears to be a valid file extension.
* Otherwise, attempts to guess the file extension from the attachment's content type.

View file

@ -111,6 +111,13 @@ var ZoteroCommandLineHandler = {
// Only open main window if we aren't handling an integration command
else if (!Zotero.getMainWindow()) {
Zotero.openMainWindow();
for (let i = 0; i < 600; i++) {
let win = Zotero.getMainWindow();
if (win?.ZoteroPane?.collectionsView) {
break;
}
await Zotero.Promise.delay(50);
}
}
await Zotero.CommandLineIngester.ingest();

View file

@ -77,7 +77,10 @@ Zotero.PreferencePanes = {
label: 'zotero.preferences.prefpane.advanced',
image: 'chrome://zotero/skin/20/universal/wrench-screwdriver.svg',
src: 'chrome://zotero/content/preferences/preferences_advanced.xhtml',
scripts: ['chrome://zotero/content/preferences/preferences_advanced.js'],
scripts: [
'chrome://zotero/content/preferences/preferences_advanced.js',
'chrome://zotero/content/preferences/preferences_spotlight.js'
],
defaultXUL: true,
helpURL: 'https://www.zotero.org/support/preferences/advanced',
},

View file

@ -0,0 +1,967 @@
/*
***** 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 *****
*/
/**
* macOS Spotlight integration: indexes library items into the system Core Spotlight
* index via an in-process bridge dylib, so they're findable from ⌘-Space when Zotero is closed.
* @namespace Zotero.Spotlight
*/
Zotero.Spotlight = new function () {
const INDEX_SCHEMA_VERSION = 1;
const DEBOUNCE_MS = 2000;
// records per index() call, so a rebuild can yield between chunks
const INDEX_CHUNK = 100;
const MAX_CONTENT_CHARS = 50000;
const FULLTEXT_RESERVE_CHARS = 20000;
const PREF = 'spotlight.';
var _available = false;
var _enabled = false;
var _appBundleID = null;
// Only the "owner" install indexes at a time (see Ownership internals).
var _dataDir = null;
var _isOwner = false;
var _notifierID = null;
var _prefObserverIDs = [];
var _pendingIndex = new Set(); // item ids to (re)index
var _pendingRemove = new Set(); // zotero paths to remove
var _reindexing = false;
var _rebuildQueued = false;
// configurable so tests can stub it
Object.defineProperty(this, 'available', { get: () => _available, configurable: true });
this.indexingAvailable = function () {
return SpotlightBridge.indexingAvailable();
};
this.init = async function () {
if (!Zotero.isMac) {
return;
}
let darwin = parseInt(Services.sysinfo.getProperty("version"));
let supported = !darwin || darwin >= 20; // macOS 11+
if (!supported) {
Zotero.debug("Spotlight: macOS < 11; integration disabled");
return;
}
_available = await SpotlightBridge.load();
if (!_available) {
Zotero.debug("Spotlight: bridge dylib unavailable; integration disabled");
return;
}
_dataDir = Zotero.DataDirectory.dir;
_appBundleID = SpotlightBridge.bundleID();
Zotero.debug(`Spotlight: bridge loaded (bundle=${_appBundleID}, `
+ `indexingAvailable=${this.indexingAvailable()})`);
_registerPrefObservers();
_enabled = !!Zotero.Prefs.get(PREF + 'enabled');
// Applied regardless of ownership
_applyDataDirExclusion().catch(e => Zotero.logError(e));
if (!_enabled) {
return;
}
try {
_isOwner = await SpotlightOwner.claim();
if (_isOwner) {
await this.start();
}
else {
Zotero.debug("Spotlight: another install/profile owns the index; not indexing");
await _cleanUpAsNonOwner();
}
}
catch (e) {
Zotero.logError(e);
}
};
this.start = async function () {
if (!_available || !_isOwner) {
return;
}
_registerNotifier();
this.reconcile().catch(e => Zotero.logError(e));
};
this.stop = function () {
_unregisterNotifier();
_pendingIndex.clear();
_pendingRemove.clear();
};
this.uninit = function () {
this.stop();
_unregisterPrefObservers();
};
function _yield() {
return new Promise(resolve => setTimeout(resolve, 0));
}
this.rebuild = async function () {
if (!_available) {
return;
}
if (_reindexing) {
_rebuildQueued = true;
return;
}
_reindexing = true;
try {
SpotlightBridge.reset();
for (let library of Zotero.Libraries.getAll()) {
if (!SpotlightLibraries.indexable(library)) {
continue;
}
let items = await Zotero.Items.getAll(library.libraryID, true, false, false);
let chunk = [];
for (let item of items) {
let record = await SpotlightRecord.build(item, library);
if (!record) {
continue;
}
chunk.push(record);
if (chunk.length >= INDEX_CHUNK) {
SpotlightBridge.index(chunk);
chunk = [];
await _yield();
}
}
if (chunk.length) {
SpotlightBridge.index(chunk);
await _yield();
}
}
let signature = SpotlightLibraries.signature();
Zotero.Prefs.set(PREF + 'indexSchemaVersion', INDEX_SCHEMA_VERSION);
Zotero.Prefs.set(PREF + 'lastSignature', signature);
SpotlightBridge.setClientState(INDEX_SCHEMA_VERSION + ':' + signature);
await _yield();
let err = SpotlightBridge.lastError();
Zotero.debug(`Spotlight: rebuild complete (indexingAvailable=`
+ `${this.indexingAvailable()}${err ? `, lastError=${err}` : ''})`);
}
finally {
_reindexing = false;
if (_rebuildQueued) {
_rebuildQueued = false;
this.rebuild().catch(e => Zotero.logError(e));
}
}
};
this.reconcile = async function () {
if (!_enabled || !_available || !_isOwner || _reindexing) {
return;
}
let signature = SpotlightLibraries.signature();
let schema = Zotero.Prefs.get(PREF + 'indexSchemaVersion');
let lastSig = Zotero.Prefs.get(PREF + 'lastSignature');
let indexed = SpotlightBridge.clientState() === INDEX_SCHEMA_VERSION + ':' + signature;
if (schema === INDEX_SCHEMA_VERSION && lastSig === signature && indexed) {
Zotero.debug("Spotlight: index up to date; skipping reconcile");
return;
}
await this.rebuild();
};
this.needsReindex = function () {
if (!_enabled || !_available || !_isOwner) {
return false;
}
return Zotero.Prefs.get(PREF + 'indexSchemaVersion') !== INDEX_SCHEMA_VERSION
|| Zotero.Prefs.get(PREF + 'lastSignature') !== SpotlightLibraries.signature();
};
this.clearIndex = async function () {
if (!_available) {
return;
}
SpotlightBridge.reset();
SpotlightBridge.setClientState('');
Zotero.Prefs.clear(PREF + 'lastSignature');
};
/**
* Query the index and return the matching zotero paths (e.g. "library/items/KEY").
* For tests only.
*
* @param {String} query - Core Spotlight query, e.g. `title == "*x*"c`.
* @return {String[]}
*/
this.search = function (query) {
let ids;
try {
ids = JSON.parse(SpotlightBridge.query(query));
}
catch (e) {
Zotero.logError(e);
return [];
}
if (!Array.isArray(ids)) {
return [];
}
// The dylib keys items as "<bundleid>:<path>"; 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 {}
}
}
};
};

View file

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

View file

@ -136,6 +136,7 @@ const xpcomFilesLocal = [
'server/server_localAPI',
'server/saveSession',
'session',
'spotlight',
'streamer',
'style',
'sync',

View file

@ -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 <label data-l10n-name="spotlight-format-help-link">documentation</label> 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 }

View file

@ -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);

580
test/tests/spotlightTest.js Normal file
View file

@ -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(`<p>note body ${tok}</p>`);
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('<p>' + 'filler '.repeat(10000) + '</p>');
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);
});
});
});