mirror of
https://github.com/zotero/zotero.git
synced 2026-10-11 03:38:25 +00:00
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:
parent
2006cdac6d
commit
45a4cb11a7
17 changed files with 2478 additions and 34 deletions
27
app/build.sh
27
app/build.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
33
app/mac/mozilla-140.patch
Normal 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;
|
||||
385
app/mac/spotlight/ZoteroSpotlight.m
Normal file
385
app/mac/spotlight/ZoteroSpotlight.m
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
194
chrome/content/zotero/preferences/preferences_spotlight.js
Normal file
194
chrome/content/zotero/preferences/preferences_spotlight.js
Normal 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
967
chrome/content/zotero/xpcom/spotlight.js
Normal file
967
chrome/content/zotero/xpcom/spotlight.js
Normal 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 {}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ const xpcomFilesLocal = [
|
|||
'server/server_localAPI',
|
||||
'server/saveSession',
|
||||
'session',
|
||||
'spotlight',
|
||||
'streamer',
|
||||
'style',
|
||||
'sync',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
580
test/tests/spotlightTest.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue