Wox/wox.core/plugin/manager.go

1708 lines
57 KiB
Go

package plugin
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"wox/ai"
"wox/common"
"wox/i18n"
"wox/setting"
"wox/util"
"wox/util/notifier"
"wox/util/selection"
"github.com/Masterminds/semver/v3"
"github.com/fsnotify/fsnotify"
"github.com/google/uuid"
"github.com/jinzhu/copier"
"github.com/samber/lo"
"github.com/wissance/stringFormatter"
)
var managerInstance *Manager
var managerOnce sync.Once
var logger *util.Log
type debounceTimer struct {
timer *time.Timer
onStop func()
}
type Manager struct {
instances []*Instance
ui common.UI
resultCache *util.HashMap[string, *QueryResultCache]
debounceQueryTimer *util.HashMap[string, *debounceTimer]
aiProviders *util.HashMap[common.ProviderName, ai.Provider]
activeBrowserUrl string //active browser url before wox is activated
// Script plugin monitoring
scriptPluginWatcher *fsnotify.Watcher
scriptReloadTimers *util.HashMap[string, *time.Timer]
}
func GetPluginManager() *Manager {
managerOnce.Do(func() {
managerInstance = &Manager{
resultCache: util.NewHashMap[string, *QueryResultCache](),
debounceQueryTimer: util.NewHashMap[string, *debounceTimer](),
aiProviders: util.NewHashMap[common.ProviderName, ai.Provider](),
scriptReloadTimers: util.NewHashMap[string, *time.Timer](),
}
logger = util.GetLogger()
})
return managerInstance
}
func (m *Manager) Start(ctx context.Context, ui common.UI) error {
m.ui = ui
loadErr := m.loadPlugins(ctx)
if loadErr != nil {
return fmt.Errorf("failed to load plugins: %w", loadErr)
}
// Start script plugin monitoring
util.Go(ctx, "start script plugin monitoring", func() {
m.startScriptPluginMonitoring(util.NewTraceContext())
})
util.Go(ctx, "start store manager", func() {
GetStoreManager().Start(util.NewTraceContext())
})
return nil
}
func (m *Manager) Stop(ctx context.Context) {
// Stop script plugin monitoring
if m.scriptPluginWatcher != nil {
m.scriptPluginWatcher.Close()
}
for _, host := range AllHosts {
host.Stop(ctx)
}
}
func (m *Manager) SetActiveBrowserUrl(url string) {
m.activeBrowserUrl = url
}
func (m *Manager) loadPlugins(ctx context.Context) error {
logger.Info(ctx, "start loading plugins")
// load system plugin first
m.loadSystemPlugins(ctx)
logger.Debug(ctx, "start loading user plugin metadata")
basePluginDirectory := util.GetLocation().GetPluginDirectory()
pluginDirectories, readErr := os.ReadDir(basePluginDirectory)
if readErr != nil {
return fmt.Errorf("failed to read plugin directory: %w", readErr)
}
var metaDataList []MetadataWithDirectory
for _, entry := range pluginDirectories {
if entry.Name() == ".DS_Store" {
continue
}
if !entry.IsDir() {
continue
}
pluginDirectory := path.Join(basePluginDirectory, entry.Name())
metadata, metadataErr := m.ParseMetadata(ctx, pluginDirectory)
if metadataErr != nil {
logger.Error(ctx, metadataErr.Error())
continue
}
//check if metadata already exist, only add newer version
existMetadata, exist := lo.Find(metaDataList, func(item MetadataWithDirectory) bool {
return item.Metadata.Id == metadata.Id
})
if exist {
existVersion, existVersionErr := semver.NewVersion(existMetadata.Metadata.Version)
currentVersion, currentVersionErr := semver.NewVersion(metadata.Version)
if existVersionErr == nil && currentVersionErr == nil {
if existVersion.GreaterThan(currentVersion) || existVersion.Equal(currentVersion) {
logger.Info(ctx, fmt.Sprintf("skip parse %s(%s) metadata, because it's already parsed(%s)", metadata.Name, metadata.Version, existMetadata.Metadata.Version))
continue
} else {
// remove older version
logger.Info(ctx, fmt.Sprintf("remove older metadata version %s(%s)", existMetadata.Metadata.Name, existMetadata.Metadata.Version))
var newMetaDataList []MetadataWithDirectory
for _, item := range metaDataList {
if item.Metadata.Id != existMetadata.Metadata.Id {
newMetaDataList = append(newMetaDataList, item)
}
}
metaDataList = newMetaDataList
}
}
}
metaDataList = append(metaDataList, MetadataWithDirectory{Metadata: metadata, Directory: pluginDirectory})
}
// Load script plugins
scriptMetaDataList, err := m.loadScriptPlugins(ctx)
if err != nil {
logger.Error(ctx, fmt.Sprintf("failed to load script plugins: %s", err.Error()))
} else {
metaDataList = append(metaDataList, scriptMetaDataList...)
}
logger.Info(ctx, fmt.Sprintf("start loading user plugins, found %d user plugins", len(metaDataList)))
for _, host := range AllHosts {
util.Go(ctx, fmt.Sprintf("[%s] start host", host.GetRuntime(ctx)), func() {
newCtx := util.NewTraceContext()
hostErr := host.Start(newCtx)
if hostErr != nil {
logger.Error(newCtx, fmt.Errorf("[%s HOST] %w", host.GetRuntime(newCtx), hostErr).Error())
return
}
for _, metadata := range metaDataList {
if !strings.EqualFold(metadata.Metadata.Runtime, string(host.GetRuntime(newCtx))) {
continue
}
loadErr := m.loadHostPlugin(newCtx, host, metadata)
if loadErr != nil {
logger.Error(newCtx, fmt.Errorf("[%s HOST] %w", host.GetRuntime(newCtx), loadErr).Error())
continue
}
}
})
}
return nil
}
// loadScriptPlugins loads script plugins from the user script plugins directory
func (m *Manager) loadScriptPlugins(ctx context.Context) ([]MetadataWithDirectory, error) {
logger.Debug(ctx, "start loading script plugin metadata")
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
scriptFiles, readErr := os.ReadDir(userScriptPluginDirectory)
if readErr != nil {
return nil, fmt.Errorf("failed to read user script plugin directory: %w", readErr)
}
var metaDataList []MetadataWithDirectory
for _, entry := range scriptFiles {
if entry.Name() == ".DS_Store" || entry.Name() == "README.md" {
continue
}
if entry.IsDir() {
continue
}
scriptPath := path.Join(userScriptPluginDirectory, entry.Name())
metadata, metadataErr := m.ParseScriptMetadata(ctx, scriptPath)
if metadataErr != nil {
logger.Error(ctx, fmt.Sprintf("failed to parse script plugin metadata for %s: %s", entry.Name(), metadataErr.Error()))
continue
}
// Create a virtual directory for the script plugin
virtualDirectory := path.Join(userScriptPluginDirectory, metadata.Id)
metaDataList = append(metaDataList, MetadataWithDirectory{
Metadata: metadata,
Directory: virtualDirectory,
})
}
logger.Debug(ctx, fmt.Sprintf("found %d script plugins", len(metaDataList)))
return metaDataList, nil
}
func (m *Manager) ReloadPlugin(ctx context.Context, metadata MetadataWithDirectory) error {
logger.Info(ctx, fmt.Sprintf("start reloading dev plugin: %s", metadata.Metadata.Name))
pluginHost, exist := lo.Find(AllHosts, func(item Host) bool {
return strings.ToLower(string(item.GetRuntime(ctx))) == strings.ToLower(metadata.Metadata.Runtime)
})
if !exist {
return fmt.Errorf("unsupported runtime: %s", metadata.Metadata.Runtime)
}
pluginInstance, pluginInstanceExist := lo.Find(m.instances, func(item *Instance) bool {
return item.Metadata.Id == metadata.Metadata.Id
})
if pluginInstanceExist {
logger.Info(ctx, fmt.Sprintf("plugin(%s) is loaded, unload first", metadata.Metadata.Name))
m.UnloadPlugin(ctx, pluginInstance)
} else {
logger.Info(ctx, fmt.Sprintf("plugin(%s) is not loaded, skip unload", metadata.Metadata.Name))
}
loadErr := m.loadHostPlugin(ctx, pluginHost, metadata)
if loadErr != nil {
return loadErr
}
return nil
}
func (m *Manager) loadHostPlugin(ctx context.Context, host Host, metadata MetadataWithDirectory) error {
loadStartTimestamp := util.GetSystemTimestamp()
plugin, loadErr := host.LoadPlugin(ctx, metadata.Metadata, metadata.Directory)
if loadErr != nil {
logger.Error(ctx, fmt.Errorf("[%s HOST] failed to load plugin: %w", host.GetRuntime(ctx), loadErr).Error())
return loadErr
}
loadFinishTimestamp := util.GetSystemTimestamp()
instance := &Instance{
Metadata: metadata.Metadata,
PluginDirectory: metadata.Directory,
Plugin: plugin,
Host: host,
LoadStartTimestamp: loadStartTimestamp,
LoadFinishedTimestamp: loadFinishTimestamp,
IsDevPlugin: metadata.IsDev,
DevPluginDirectory: metadata.DevPluginDirectory,
}
instance.API = NewAPI(instance)
pluginSetting, settingErr := setting.GetSettingManager().LoadPluginSetting(ctx, metadata.Metadata.Id, metadata.Metadata.SettingDefinitions.ToMap())
if settingErr != nil {
instance.API.Log(ctx, LogLevelError, fmt.Errorf("[SYS] failed to load plugin[%s] setting: %w", metadata.Metadata.Name, settingErr).Error())
return settingErr
}
instance.Setting = pluginSetting
m.instances = append(m.instances, instance)
if pluginSetting.Disabled.Get() {
logger.Info(ctx, fmt.Errorf("[%s HOST] plugin is disabled by user, skip init: %s", host.GetRuntime(ctx), metadata.Metadata.Name).Error())
instance.API.Log(ctx, LogLevelWarning, fmt.Sprintf("[SYS] plugin is disabled by user, skip init: %s", metadata.Metadata.Name))
return nil
}
util.Go(ctx, fmt.Sprintf("[%s] init plugin", metadata.Metadata.Name), func() {
m.initPlugin(ctx, instance)
})
return nil
}
func (m *Manager) LoadPlugin(ctx context.Context, pluginDirectory string) error {
metadata, parseErr := m.ParseMetadata(ctx, pluginDirectory)
if parseErr != nil {
return parseErr
}
pluginHost, exist := lo.Find(AllHosts, func(item Host) bool {
return strings.ToLower(string(item.GetRuntime(ctx))) == strings.ToLower(metadata.Runtime)
})
if !exist {
return fmt.Errorf("unsupported runtime: %s", metadata.Runtime)
}
loadErr := m.loadHostPlugin(ctx, pluginHost, MetadataWithDirectory{Metadata: metadata, Directory: pluginDirectory})
if loadErr != nil {
return loadErr
}
return nil
}
func (m *Manager) UnloadPlugin(ctx context.Context, pluginInstance *Instance) {
for _, callback := range pluginInstance.UnloadCallbacks {
callback()
}
pluginInstance.Host.UnloadPlugin(ctx, pluginInstance.Metadata)
var newInstances []*Instance
for _, instance := range m.instances {
if instance.Metadata.Id != pluginInstance.Metadata.Id {
newInstances = append(newInstances, instance)
}
}
m.instances = newInstances
}
func (m *Manager) loadSystemPlugins(ctx context.Context) {
start := util.GetSystemTimestamp()
logger.Info(ctx, fmt.Sprintf("start loading system plugins, found %d system plugins", len(AllSystemPlugin)))
for _, plugin := range AllSystemPlugin {
util.Go(ctx, fmt.Sprintf("load system plugin <%s>", plugin.GetMetadata().Name), func() {
metadata := plugin.GetMetadata()
instance := &Instance{
Metadata: metadata,
Plugin: plugin,
Host: nil,
IsSystemPlugin: true,
LoadStartTimestamp: util.GetSystemTimestamp(),
LoadFinishedTimestamp: util.GetSystemTimestamp(),
}
instance.API = NewAPI(instance)
startTimestamp := util.GetSystemTimestamp()
pluginSetting, settingErr := setting.GetSettingManager().LoadPluginSetting(ctx, metadata.Id, metadata.SettingDefinitions.ToMap())
if settingErr != nil {
logger.Error(ctx, fmt.Sprintf("failed to load system plugin[%s] setting, use default plugin setting. err: %s", metadata.Name, settingErr.Error()))
return
}
instance.Setting = pluginSetting
if util.GetSystemTimestamp()-startTimestamp > 100 {
logger.Warn(ctx, fmt.Sprintf("load system plugin[%s] setting too slow, cost %d ms", metadata.Name, util.GetSystemTimestamp()-startTimestamp))
}
m.instances = append(m.instances, instance)
m.initPlugin(util.NewTraceContext(), instance)
})
}
logger.Debug(ctx, fmt.Sprintf("finish loading system plugins, cost %d ms", util.GetSystemTimestamp()-start))
}
func (m *Manager) initPlugin(ctx context.Context, instance *Instance) {
logger.Info(ctx, fmt.Sprintf("start init plugin: %s", instance.Metadata.Name))
instance.InitStartTimestamp = util.GetSystemTimestamp()
instance.Plugin.Init(ctx, InitParams{
API: instance.API,
PluginDirectory: instance.PluginDirectory,
})
instance.InitFinishedTimestamp = util.GetSystemTimestamp()
logger.Info(ctx, fmt.Sprintf("init plugin %s finished, cost %d ms", instance.Metadata.Name, instance.InitFinishedTimestamp-instance.InitStartTimestamp))
}
func (m *Manager) ParseMetadata(ctx context.Context, pluginDirectory string) (Metadata, error) {
configPath := path.Join(pluginDirectory, "plugin.json")
if _, statErr := os.Stat(configPath); statErr != nil {
return Metadata{}, fmt.Errorf("missing plugin.json file in %s", configPath)
}
metadataJson, err := os.ReadFile(configPath)
if err != nil {
return Metadata{}, fmt.Errorf("failed to read plugin.json file: %w", err)
}
var metadata Metadata
unmarshalErr := json.Unmarshal(metadataJson, &metadata)
if unmarshalErr != nil {
return Metadata{}, fmt.Errorf("failed to unmarshal plugin.json file (%s): %w", pluginDirectory, unmarshalErr)
}
if len(metadata.TriggerKeywords) == 0 {
return Metadata{}, fmt.Errorf("missing trigger keywords in plugin.json file (%s)", pluginDirectory)
}
if !IsSupportedRuntime(metadata.Runtime) {
return Metadata{}, fmt.Errorf("unsupported runtime in plugin.json file (%s), runtime=%s", pluginDirectory, metadata.Runtime)
}
if !IsSupportedOSAny(metadata.SupportedOS) {
return Metadata{}, fmt.Errorf("unsupported os in plugin.json file (%s), os=%s", pluginDirectory, metadata.SupportedOS)
}
return metadata, nil
}
// ParseScriptMetadata parses metadata from script plugin file comments
func (m *Manager) ParseScriptMetadata(ctx context.Context, scriptPath string) (Metadata, error) {
content, err := os.ReadFile(scriptPath)
if err != nil {
return Metadata{}, fmt.Errorf("failed to read script file: %w", err)
}
lines := strings.Split(string(content), "\n")
metadata := Metadata{
Runtime: string(PLUGIN_RUNTIME_SCRIPT),
Entry: filepath.Base(scriptPath),
Version: "1.0.0", // Default version
}
// Parse metadata from comments
for _, line := range lines {
line = strings.TrimSpace(line)
// Stop parsing when we reach non-comment lines (except shebang)
if !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "//") && !strings.HasPrefix(line, "#!/") {
if line != "" {
break
}
continue
}
// Remove comment markers
line = strings.TrimPrefix(line, "#")
line = strings.TrimPrefix(line, "//")
line = strings.TrimPrefix(line, "#!/usr/bin/env")
line = strings.TrimPrefix(line, "#!/bin/")
line = strings.TrimSpace(line)
// Parse @wox.xxx metadata
if strings.HasPrefix(line, "@wox.") {
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimPrefix(parts[0], "@wox.")
value := strings.TrimSpace(parts[1])
switch key {
case "id":
metadata.Id = value
case "name":
metadata.Name = value
case "author":
metadata.Author = value
case "version":
metadata.Version = value
case "description":
metadata.Description = value
case "icon":
metadata.Icon = value
case "keywords":
// Split keywords by comma or space
keywords := strings.FieldsFunc(value, func(c rune) bool {
return c == ',' || c == ' '
})
for i, keyword := range keywords {
keywords[i] = strings.TrimSpace(keyword)
}
metadata.TriggerKeywords = keywords
case "minWoxVersion":
metadata.MinWoxVersion = value
}
}
}
// Validate required fields
if metadata.Id == "" {
return Metadata{}, fmt.Errorf("missing required field: @wox.id")
}
if metadata.Name == "" {
return Metadata{}, fmt.Errorf("missing required field: @wox.name")
}
if len(metadata.TriggerKeywords) == 0 {
return Metadata{}, fmt.Errorf("missing required field: @wox.keywords")
}
// Set default values
if metadata.Author == "" {
metadata.Author = "Unknown"
}
if metadata.Description == "" {
metadata.Description = "A script plugin"
}
if metadata.Icon == "" {
metadata.Icon = "📝"
}
if metadata.MinWoxVersion == "" {
metadata.MinWoxVersion = "2.0.0"
}
// Set supported OS to all platforms by default
metadata.SupportedOS = []string{"Windows", "Linux", "Macos"}
return metadata, nil
}
// startScriptPluginMonitoring starts monitoring the user script plugins directory for changes
func (m *Manager) startScriptPluginMonitoring(ctx context.Context) {
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
// Create file system watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to create script plugin watcher: %s", err.Error()))
return
}
m.scriptPluginWatcher = watcher
// Add the script plugins directory to the watcher
err = watcher.Add(userScriptPluginDirectory)
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to watch script plugin directory: %s", err.Error()))
watcher.Close()
return
}
logger.Info(ctx, fmt.Sprintf("Started monitoring script plugins directory: %s", userScriptPluginDirectory))
// Start watching for events
for {
select {
case event, ok := <-watcher.Events:
if !ok {
logger.Info(ctx, "Script plugin watcher closed")
return
}
m.handleScriptPluginEvent(ctx, event)
case err, ok := <-watcher.Errors:
if !ok {
logger.Info(ctx, "Script plugin watcher error channel closed")
return
}
logger.Error(ctx, fmt.Sprintf("Script plugin watcher error: %s", err.Error()))
case <-ctx.Done():
logger.Info(ctx, "Script plugin monitoring stopped due to context cancellation")
watcher.Close()
return
}
}
}
// handleScriptPluginEvent handles file system events for script plugins
func (m *Manager) handleScriptPluginEvent(ctx context.Context, event fsnotify.Event) {
// Skip non-script files and temporary files
fileName := filepath.Base(event.Name)
if strings.HasPrefix(fileName, ".") || strings.HasSuffix(fileName, "~") || strings.HasSuffix(fileName, ".tmp") {
return
}
// Skip directories
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
return
}
logger.Info(ctx, fmt.Sprintf("Script plugin file event: %s (%s)", event.Name, event.Op))
switch event.Op {
case fsnotify.Create, fsnotify.Write:
// File created or modified - reload the plugin
m.debounceScriptPluginReload(ctx, event.Name, "file changed")
case fsnotify.Remove, fsnotify.Rename:
// File deleted or renamed - unload the plugin
m.unloadScriptPluginByPath(ctx, event.Name)
case fsnotify.Chmod:
// File permissions changed - ignore for now
logger.Debug(ctx, fmt.Sprintf("Script plugin file permissions changed: %s", event.Name))
}
}
// debounceScriptPluginReload debounces script plugin reload to avoid multiple reloads in a short time
func (m *Manager) debounceScriptPluginReload(ctx context.Context, scriptPath, reason string) {
fileName := filepath.Base(scriptPath)
// Cancel existing timer if any
if timer, exists := m.scriptReloadTimers.Load(fileName); exists {
timer.Stop()
}
// Create new timer
timer := time.AfterFunc(2*time.Second, func() {
m.reloadScriptPlugin(util.NewTraceContext(), scriptPath, reason)
m.scriptReloadTimers.Delete(fileName)
})
m.scriptReloadTimers.Store(fileName, timer)
}
// reloadScriptPlugin reloads a script plugin
func (m *Manager) reloadScriptPlugin(ctx context.Context, scriptPath, reason string) {
logger.Info(ctx, fmt.Sprintf("Reloading script plugin: %s, reason: %s", scriptPath, reason))
// Check if file still exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
logger.Warn(ctx, fmt.Sprintf("Script plugin file no longer exists: %s", scriptPath))
return
}
// Parse metadata from the script file
metadata, err := m.ParseScriptMetadata(ctx, scriptPath)
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to parse script plugin metadata: %s", err.Error()))
return
}
// Find and unload existing plugin instance if any
existingInstance, exists := lo.Find(m.instances, func(instance *Instance) bool {
return instance.Metadata.Id == metadata.Id
})
if exists {
logger.Info(ctx, fmt.Sprintf("Unloading existing script plugin instance: %s", metadata.Name))
m.UnloadPlugin(ctx, existingInstance)
}
// Create metadata with directory for loading
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
virtualDirectory := path.Join(userScriptPluginDirectory, metadata.Id)
metadataWithDirectory := MetadataWithDirectory{
Metadata: metadata,
Directory: virtualDirectory,
}
// Find script plugin host
scriptHost, hostExists := lo.Find(AllHosts, func(host Host) bool {
return host.GetRuntime(ctx) == PLUGIN_RUNTIME_SCRIPT
})
if !hostExists {
logger.Error(ctx, "Script plugin host not found")
return
}
// Load the plugin
err = m.loadHostPlugin(ctx, scriptHost, metadataWithDirectory)
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to reload script plugin: %s", err.Error()))
return
}
logger.Info(ctx, fmt.Sprintf("Successfully reloaded script plugin: %s", metadata.Name))
}
// unloadScriptPluginByPath unloads a script plugin by its file path
func (m *Manager) unloadScriptPluginByPath(ctx context.Context, scriptPath string) {
fileName := filepath.Base(scriptPath)
logger.Info(ctx, fmt.Sprintf("Unloading script plugin: %s", fileName))
// Find plugin instance by script file name
var pluginToUnload *Instance
for _, instance := range m.instances {
if instance.Metadata.Runtime == string(PLUGIN_RUNTIME_SCRIPT) && instance.Metadata.Entry == fileName {
pluginToUnload = instance
break
}
}
if pluginToUnload != nil {
logger.Info(ctx, fmt.Sprintf("Found script plugin to unload: %s", pluginToUnload.Metadata.Name))
m.UnloadPlugin(ctx, pluginToUnload)
} else {
logger.Debug(ctx, fmt.Sprintf("No script plugin found for file: %s", fileName))
}
}
func (m *Manager) GetPluginInstances() []*Instance {
return m.instances
}
func (m *Manager) canOperateQuery(ctx context.Context, pluginInstance *Instance, query Query) bool {
if pluginInstance.Setting.Disabled.Get() {
return false
}
if query.Type == QueryTypeSelection {
isPluginSupportSelection := pluginInstance.Metadata.IsSupportFeature(MetadataFeatureQuerySelection)
return isPluginSupportSelection
}
var validGlobalQuery = lo.Contains(pluginInstance.GetTriggerKeywords(), "*") && query.TriggerKeyword == ""
var validNonGlobalQuery = lo.Contains(pluginInstance.GetTriggerKeywords(), query.TriggerKeyword)
if !validGlobalQuery && !validNonGlobalQuery {
return false
}
return true
}
func (m *Manager) queryForPlugin(ctx context.Context, pluginInstance *Instance, query Query) (results []QueryResult) {
defer util.GoRecover(ctx, fmt.Sprintf("<%s> query panic", pluginInstance.Metadata.Name), func(err error) {
// if plugin query panic, return error result
failedResult := m.GetResultForFailedQuery(ctx, pluginInstance.Metadata, query, err)
results = []QueryResult{
m.PolishResult(ctx, pluginInstance, query, failedResult),
}
})
logger.Info(ctx, fmt.Sprintf("<%s> start query: %s", pluginInstance.Metadata.Name, query.RawQuery))
start := util.GetSystemTimestamp()
// set query env base on plugin's feature
currentEnv := query.Env
newEnv := QueryEnv{}
if pluginInstance.Metadata.IsSupportFeature(MetadataFeatureQueryEnv) {
queryEnvParams, err := pluginInstance.Metadata.GetFeatureParamsForQueryEnv()
if err != nil {
logger.Error(ctx, fmt.Sprintf("<%s> invalid query env config: %s", pluginInstance.Metadata.Name, err))
} else {
if queryEnvParams.RequireActiveWindowName {
newEnv.ActiveWindowTitle = currentEnv.ActiveWindowTitle
}
if queryEnvParams.RequireActiveWindowPid {
newEnv.ActiveWindowPid = currentEnv.ActiveWindowPid
}
if queryEnvParams.RequireActiveBrowserUrl {
newEnv.ActiveBrowserUrl = currentEnv.ActiveBrowserUrl
}
}
}
query.Env = newEnv
results = pluginInstance.Plugin.Query(ctx, query)
logger.Debug(ctx, fmt.Sprintf("<%s> finish query, result count: %d, cost: %dms", pluginInstance.Metadata.Name, len(results), util.GetSystemTimestamp()-start))
for i := range results {
if results[i].Group == "" {
defaultActions := m.getDefaultActions(ctx, pluginInstance, query, results[i].Title, results[i].SubTitle)
results[i].Actions = append(results[i].Actions, defaultActions...)
}
results[i] = m.PolishResult(ctx, pluginInstance, query, results[i])
}
if query.Type == QueryTypeSelection && query.Search != "" {
woxSetting := setting.GetSettingManager().GetWoxSetting(ctx)
results = lo.Filter(results, func(item QueryResult, _ int) bool {
match, _ := util.IsStringMatchScore(item.Title, query.Search, woxSetting.UsePinYin.Get())
return match
})
}
return results
}
func (m *Manager) GetResultForFailedQuery(ctx context.Context, pluginMetadata Metadata, query Query, err error) QueryResult {
overlayIcon := common.NewWoxImageEmoji("🚫")
pluginIcon := common.ParseWoxImageOrDefault(pluginMetadata.Icon, overlayIcon)
icon := pluginIcon.OverlayFullPercentage(overlayIcon, 0.6)
return QueryResult{
Title: fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "plugin_manager_query_failed"), pluginMetadata.Name),
SubTitle: util.EllipsisEnd(err.Error(), 20),
Icon: icon,
Preview: WoxPreview{
PreviewType: WoxPreviewTypeText,
PreviewData: err.Error(),
},
}
}
func (m *Manager) getDefaultActions(ctx context.Context, pluginInstance *Instance, query Query, title, subTitle string) (defaultActions []QueryResultAction) {
if setting.GetSettingManager().IsFavoriteResult(ctx, pluginInstance.Metadata.Id, title, subTitle) {
defaultActions = append(defaultActions, QueryResultAction{
Name: "i18n:plugin_manager_remove_from_favorite",
Icon: RemoveFromFavIcon,
IsSystemAction: true,
Action: func(ctx context.Context, actionContext ActionContext) {
setting.GetSettingManager().RemoveFavoriteResult(ctx, pluginInstance.Metadata.Id, title, subTitle)
},
})
} else {
defaultActions = append(defaultActions, QueryResultAction{
Name: "i18n:plugin_manager_add_to_favorite",
Icon: AddToFavIcon,
IsSystemAction: true,
Action: func(ctx context.Context, actionContext ActionContext) {
setting.GetSettingManager().AddFavoriteResult(ctx, pluginInstance.Metadata.Id, title, subTitle)
},
})
}
return defaultActions
}
func (m *Manager) PolishResult(ctx context.Context, pluginInstance *Instance, query Query, result QueryResult) QueryResult {
// set default id
if result.Id == "" {
result.Id = uuid.NewString()
}
for actionIndex := range result.Actions {
if result.Actions[actionIndex].Id == "" {
result.Actions[actionIndex].Id = uuid.NewString()
}
if result.Actions[actionIndex].Icon.IsEmpty() {
// set default action icon if not present
result.Actions[actionIndex].Icon = DefaultActionIcon
}
}
originalIcon := result.Icon
// convert icon
result.Icon = common.ConvertIcon(ctx, result.Icon, pluginInstance.PluginDirectory)
for i := range result.Tails {
if result.Tails[i].Type == QueryResultTailTypeImage {
result.Tails[i].Image = common.ConvertIcon(ctx, result.Tails[i].Image, pluginInstance.PluginDirectory)
}
}
// add default preview for selection query if no preview is set
if query.Type == QueryTypeSelection && result.Preview.PreviewType == "" {
if query.Selection.Type == selection.SelectionTypeText {
result.Preview = WoxPreview{
PreviewType: WoxPreviewTypeText,
PreviewData: query.Selection.Text,
}
}
if query.Selection.Type == selection.SelectionTypeFile {
result.Preview = WoxPreview{
PreviewType: WoxPreviewTypeMarkdown,
PreviewData: m.formatFileListPreview(ctx, query.Selection.FilePaths),
}
}
}
// translate title
result.Title = m.translatePlugin(ctx, pluginInstance, result.Title)
// translate subtitle
result.SubTitle = m.translatePlugin(ctx, pluginInstance, result.SubTitle)
// translate preview properties
var previewProperties = make(map[string]string)
for key, value := range result.Preview.PreviewProperties {
translatedKey := m.translatePlugin(ctx, pluginInstance, key)
previewProperties[translatedKey] = value
}
result.Preview.PreviewProperties = previewProperties
// translate action names
for actionIndex := range result.Actions {
result.Actions[actionIndex].Name = m.translatePlugin(ctx, pluginInstance, result.Actions[actionIndex].Name)
}
// translate preview data if preview type is text
if result.Preview.PreviewType == WoxPreviewTypeText || result.Preview.PreviewType == WoxPreviewTypeMarkdown {
result.Preview.PreviewData = m.translatePlugin(ctx, pluginInstance, result.Preview.PreviewData)
}
// set first action as default if no default action is set
defaultActionCount := lo.CountBy(result.Actions, func(item QueryResultAction) bool {
return item.IsDefault
})
if defaultActionCount == 0 && len(result.Actions) > 0 {
result.Actions[0].IsDefault = true
result.Actions[0].Hotkey = "Enter"
}
//move default action to first one of the actions
sort.Slice(result.Actions, func(i, j int) bool {
return result.Actions[i].IsDefault
})
var resultCache = &QueryResultCache{
ResultId: result.Id,
ResultTitle: result.Title,
ResultSubTitle: result.SubTitle,
ContextData: result.ContextData,
Icon: originalIcon,
PluginInstance: pluginInstance,
Query: query,
Actions: util.NewHashMap[string, func(ctx context.Context, actionContext ActionContext)](),
}
// store actions for ui invoke later
for actionIndex := range result.Actions {
var action = result.Actions[actionIndex]
// if default action's hotkey is empty, set it as Enter
if action.IsDefault && action.Hotkey == "" {
result.Actions[actionIndex].Hotkey = "Enter"
}
// replace hotkey modifiers for platform specific, E.g. replace win to cmd on macos, replace cmd to win on windows
if util.IsMacOS() {
result.Actions[actionIndex].Hotkey = strings.ReplaceAll(result.Actions[actionIndex].Hotkey, "win", "cmd")
result.Actions[actionIndex].Hotkey = strings.ReplaceAll(result.Actions[actionIndex].Hotkey, "windows", "cmd")
result.Actions[actionIndex].Hotkey = strings.ReplaceAll(result.Actions[actionIndex].Hotkey, "alt", "option")
}
if util.IsWindows() || util.IsLinux() {
result.Actions[actionIndex].Hotkey = strings.ReplaceAll(result.Actions[actionIndex].Hotkey, "cmd", "win")
result.Actions[actionIndex].Hotkey = strings.ReplaceAll(result.Actions[actionIndex].Hotkey, "command", "win")
result.Actions[actionIndex].Hotkey = strings.ReplaceAll(result.Actions[actionIndex].Hotkey, "option", "alt")
}
if action.Action != nil {
resultCache.Actions.Store(action.Id, action.Action)
}
}
// if query is input and trigger keyword is global, disable preview and group
if query.IsGlobalQuery() {
result.Preview = WoxPreview{}
result.Group = ""
result.GroupScore = 0
}
// store preview for ui invoke later
// because preview may contain some heavy data (E.g. image or large text),
// we will store preview in cache and only send preview to ui when user select the result
var maximumPreviewSize = 1024
if !result.Preview.IsEmpty() && result.Preview.PreviewType != WoxPreviewTypeRemote && len(result.Preview.PreviewData) > maximumPreviewSize {
resultCache.Preview = result.Preview
result.Preview = WoxPreview{
PreviewType: WoxPreviewTypeRemote,
PreviewData: fmt.Sprintf("/preview?id=%s", result.Id),
}
}
if result.RefreshInterval > 0 && result.OnRefresh != nil {
newInterval := int(math.Floor(float64(result.RefreshInterval)/100) * 100)
if result.RefreshInterval != newInterval {
logger.Info(ctx, fmt.Sprintf("[%s] result(%s) refresh interval %d is not divisible by 100, use %d instead", pluginInstance.Metadata.Name, result.Id, result.RefreshInterval, newInterval))
result.RefreshInterval = newInterval
}
resultCache.Refresh = result.OnRefresh
}
ignoreAutoScore := pluginInstance.Metadata.IsSupportFeature(MetadataFeatureIgnoreAutoScore)
if !ignoreAutoScore {
score := m.calculateResultScore(ctx, pluginInstance.Metadata.Id, result.Title, result.SubTitle, query.RawQuery)
if score > 0 {
logger.Debug(ctx, fmt.Sprintf("<%s> result(%s) add score: %d", pluginInstance.Metadata.Name, result.Title, score))
result.Score += score
}
}
// check if result is favorite result
// favorite result will not be affected by ignoreAutoScore setting, so we add score here
if setting.GetSettingManager().IsFavoriteResult(ctx, pluginInstance.Metadata.Id, result.Title, result.SubTitle) {
favScore := int64(100000)
logger.Debug(ctx, fmt.Sprintf("<%s> result(%s) is favorite result, add score: %d", pluginInstance.Metadata.Name, result.Title, favScore))
result.Score += favScore
}
m.resultCache.Store(result.Id, resultCache)
return result
}
func (m *Manager) formatFileListPreview(ctx context.Context, filePaths []string) string {
totalFiles := len(filePaths)
if totalFiles == 0 {
return i18n.GetI18nManager().TranslateWox(ctx, "selection_no_files_selected")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "selection_selected_files_count"), totalFiles))
sb.WriteString("\n\n")
maxDisplayFiles := 10
for i, filePath := range filePaths {
if i < maxDisplayFiles {
sb.WriteString(fmt.Sprintf("- `%s`\n", filePath))
} else {
remainingFiles := totalFiles - maxDisplayFiles
sb.WriteString("\n")
sb.WriteString(fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "selection_remaining_files_not_shown"), remainingFiles))
break
}
}
return sb.String()
}
func (m *Manager) calculateResultScore(ctx context.Context, pluginId, title, subTitle string, currentQuery string) int64 {
var score int64 = 0
resultHash := setting.NewResultHash(pluginId, title, subTitle)
woxSetting := setting.GetSettingManager().GetWoxSetting(ctx)
actionResults, ok := woxSetting.ActionedResults.Get().Load(resultHash)
if !ok {
return score
}
// actioned score are based on actioned counts, the more actioned, the more score
// also, action timestamp will be considered, the more recent actioned, the more score weight. If action is in recent 7 days, it will be considered as recent actioned and add score weight
// we will use fibonacci sequence to calculate score, the more recent actioned, the more score: 5, 8, 13, 21, 34, 55, 89
// that means, actions in day one, we will add weight 89, day two, we will add weight 55, day three, we will add weight 34, and so on
// E.g. if actioned 3 times in day one, 2 times in day two, 1 time in day three, the score will be: 89*3 + 55*2 + 34*1 = 450
for _, actionResult := range actionResults {
var weight int64 = 2
hours := (util.GetSystemTimestamp() - actionResult.Timestamp) / 1000 / 60 / 60
if hours < 24*7 {
fibonacciIndex := int(math.Ceil(float64(hours) / 24))
if fibonacciIndex > 7 {
fibonacciIndex = 7
}
if fibonacciIndex < 1 {
fibonacciIndex = 1
}
fibonacci := []int64{5, 8, 13, 21, 34, 55, 89}
score += fibonacci[7-fibonacciIndex]
}
// If the current query is within the historical selected actions, it indicates a stronger connection and increases the score.
if currentQuery != "" && actionResult.Query == currentQuery {
score += 20
}
score += weight
}
return score
}
func (m *Manager) polishRefreshableResult(ctx context.Context, resultCache *QueryResultCache, result RefreshableResult) RefreshableResult {
pluginInstance := resultCache.PluginInstance
for actionIndex := range result.Actions {
if result.Actions[actionIndex].Id == "" {
result.Actions[actionIndex].Id = uuid.NewString()
}
if result.Actions[actionIndex].Icon.IsEmpty() {
// set default action icon if not present
result.Actions[actionIndex].Icon = DefaultActionIcon
}
}
// set first action as default if no default action is set
defaultActionCount := lo.CountBy(result.Actions, func(item QueryResultAction) bool {
return item.IsDefault
})
if defaultActionCount == 0 && len(result.Actions) > 0 {
result.Actions[0].IsDefault = true
result.Actions[0].Hotkey = "Enter"
}
//move default action to first one of the actions
sort.Slice(result.Actions, func(i, j int) bool {
return result.Actions[i].IsDefault
})
// convert icon
result.Icon = common.ConvertIcon(ctx, result.Icon, pluginInstance.PluginDirectory)
for i := range result.Tails {
if result.Tails[i].Type == QueryResultTailTypeImage {
result.Tails[i].Image = common.ConvertIcon(ctx, result.Tails[i].Image, pluginInstance.PluginDirectory)
}
}
// translate title
result.Title = m.translatePlugin(ctx, pluginInstance, result.Title)
// translate subtitle
result.SubTitle = m.translatePlugin(ctx, pluginInstance, result.SubTitle)
// translate tail text
for i := range result.Tails {
if result.Tails[i].Type == QueryResultTailTypeText {
result.Tails[i].Text = m.translatePlugin(ctx, pluginInstance, result.Tails[i].Text)
}
}
// translate preview properties
var previewProperties = make(map[string]string)
for key, value := range result.Preview.PreviewProperties {
translatedKey := m.translatePlugin(ctx, pluginInstance, key)
previewProperties[translatedKey] = value
}
result.Preview.PreviewProperties = previewProperties
// translate action names
for actionIndex := range result.Actions {
result.Actions[actionIndex].Name = m.translatePlugin(ctx, pluginInstance, result.Actions[actionIndex].Name)
}
// update result cache
resultCache.ResultTitle = result.Title
resultCache.ResultSubTitle = result.SubTitle
resultCache.ContextData = result.ContextData
resultCache.Actions = util.NewHashMap[string, func(ctx context.Context, actionContext ActionContext)]()
for _, newAction := range result.Actions {
if newAction.Action != nil {
resultCache.Actions.Store(newAction.Id, newAction.Action)
}
}
// convert non-remote preview to remote preview
// because preview may contain some heavy data (E.g. image or large text),
// we will store preview in cache and only send preview to ui when user select the result
if !result.Preview.IsEmpty() && result.Preview.PreviewType != WoxPreviewTypeRemote {
resultCache.Preview = result.Preview
result.Preview = WoxPreview{
PreviewType: WoxPreviewTypeRemote,
PreviewData: fmt.Sprintf("/preview?id=%s", resultCache.ResultId),
}
}
return result
}
func (m *Manager) Query(ctx context.Context, query Query) (results chan []QueryResultUI, done chan bool) {
results = make(chan []QueryResultUI, 10)
done = make(chan bool)
// clear old result cache
m.resultCache.Clear()
counter := &atomic.Int32{}
counter.Store(int32(len(m.instances)))
for _, pluginInstance := range m.instances {
if !m.canOperateQuery(ctx, pluginInstance, query) {
counter.Add(-1)
if counter.Load() == 0 {
done <- true
}
continue
}
if pluginInstance.Metadata.IsSupportFeature(MetadataFeatureDebounce) {
debounceParams, err := pluginInstance.Metadata.GetFeatureParamsForDebounce()
if err == nil {
logger.Debug(ctx, fmt.Sprintf("[%s] debounce query, will execute in %d ms", pluginInstance.Metadata.Name, debounceParams.IntervalMs))
if v, ok := m.debounceQueryTimer.Load(pluginInstance.Metadata.Id); ok {
if v.timer.Stop() {
v.onStop()
}
}
timer := time.AfterFunc(time.Duration(debounceParams.IntervalMs)*time.Millisecond, func() {
m.queryParallel(ctx, pluginInstance, query, results, done, counter)
})
onStop := func() {
logger.Debug(ctx, fmt.Sprintf("[%s] previous debounced query cancelled", pluginInstance.Metadata.Name))
counter.Add(-1)
if counter.Load() == 0 {
done <- true
}
}
m.debounceQueryTimer.Store(pluginInstance.Metadata.Id, &debounceTimer{
timer: timer,
onStop: onStop,
})
continue
} else {
logger.Error(ctx, fmt.Sprintf("[%s] %s, query directlly", pluginInstance.Metadata.Name, err))
}
}
m.queryParallel(ctx, pluginInstance, query, results, done, counter)
}
return
}
func (m *Manager) QuerySilent(ctx context.Context, query Query) bool {
var startTimestamp = util.GetSystemTimestamp()
var results []QueryResultUI
resultChan, doneChan := m.Query(ctx, query)
for {
select {
case r := <-resultChan:
results = append(results, r...)
case <-doneChan:
logger.Info(ctx, fmt.Sprintf("silent query done, total results: %d, cost %d ms", len(results), util.GetSystemTimestamp()-startTimestamp))
// execute default action if only one result
if len(results) == 1 {
result := results[0]
for _, action := range result.Actions {
if action.IsDefault {
m.ExecuteAction(ctx, result.Id, action.Id)
return true
}
}
} else {
notifier.Notify(fmt.Sprintf("Silent query failed, there shouldbe only one result, but got %d", len(results)))
}
return false
case <-time.After(time.Minute):
logger.Error(ctx, "silent query timeout")
return false
}
}
}
func (m *Manager) QueryFallback(ctx context.Context, query Query, queryPlugin *Instance) (results []QueryResultUI) {
var queryResults []QueryResult
if query.IsGlobalQuery() {
for _, pluginInstance := range m.instances {
if v, ok := pluginInstance.Plugin.(FallbackSearcher); ok {
fallbackResults := v.QueryFallback(ctx, query)
for _, fallbackResult := range fallbackResults {
polishedFallbackResult := m.PolishResult(ctx, pluginInstance, query, fallbackResult)
queryResults = append(queryResults, polishedFallbackResult)
}
continue
}
}
} else {
if query.Command != "" {
return results
}
// search query commands
commands := lo.Filter(queryPlugin.GetQueryCommands(), func(item MetadataCommand, index int) bool {
return strings.Contains(item.Command, query.Search) || query.Search == ""
})
queryResults = lo.Map(commands, func(item MetadataCommand, index int) QueryResult {
return QueryResult{
Title: item.Command,
SubTitle: item.Description,
Icon: common.ParseWoxImageOrDefault(queryPlugin.Metadata.Icon, common.NewWoxImageEmoji("🔍")),
Actions: []QueryResultAction{
{
Name: "Execute",
PreventHideAfterAction: true,
Action: func(ctx context.Context, actionContext ActionContext) {
m.ui.ChangeQuery(ctx, common.PlainQuery{
QueryType: QueryTypeInput,
QueryText: fmt.Sprintf("%s %s ", query.TriggerKeyword, item.Command),
})
},
},
},
}
})
for i := range queryResults {
queryResults[i] = m.PolishResult(ctx, queryPlugin, query, queryResults[i])
}
}
queryResultsUI := lo.Map(queryResults, func(item QueryResult, index int) QueryResultUI {
return item.ToUI()
})
results = append(results, queryResultsUI...)
return results
}
func (m *Manager) queryParallel(ctx context.Context, pluginInstance *Instance, query Query, results chan []QueryResultUI, done chan bool, counter *atomic.Int32) {
util.Go(ctx, fmt.Sprintf("[%s] parallel query", pluginInstance.Metadata.Name), func() {
queryResults := m.queryForPlugin(ctx, pluginInstance, query)
results <- lo.Map(queryResults, func(item QueryResult, index int) QueryResultUI {
return item.ToUI()
})
counter.Add(-1)
if counter.Load() == 0 {
done <- true
}
}, func() {
counter.Add(-1)
if counter.Load() == 0 {
done <- true
}
})
}
func (m *Manager) translatePlugin(ctx context.Context, pluginInstance *Instance, key string) string {
if !strings.HasPrefix(key, "i18n:") {
return key
}
if pluginInstance.IsSystemPlugin {
return i18n.GetI18nManager().TranslateWox(ctx, key)
} else {
return i18n.GetI18nManager().TranslatePlugin(ctx, key, pluginInstance.PluginDirectory)
}
}
func (m *Manager) GetUI() common.UI {
return m.ui
}
func (m *Manager) NewQuery(ctx context.Context, plainQuery common.PlainQuery) (Query, *Instance, error) {
if plainQuery.QueryType == QueryTypeInput {
newQuery := plainQuery.QueryText
woxSetting := setting.GetSettingManager().GetWoxSetting(ctx)
if len(woxSetting.QueryShortcuts.Get()) > 0 {
originQuery := plainQuery.QueryText
expandedQuery := m.expandQueryShortcut(ctx, plainQuery.QueryText, woxSetting.QueryShortcuts.Get())
if originQuery != expandedQuery {
logger.Info(ctx, fmt.Sprintf("expand query shortcut: %s -> %s", originQuery, expandedQuery))
newQuery = expandedQuery
}
}
query, instance := newQueryInputWithPlugins(newQuery, GetPluginManager().GetPluginInstances())
query.Env.ActiveWindowTitle = m.GetUI().GetActiveWindowName()
query.Env.ActiveWindowPid = m.GetUI().GetActiveWindowPid()
query.Env.ActiveBrowserUrl = m.getActiveBrowserUrl(ctx)
return query, instance, nil
}
if plainQuery.QueryType == QueryTypeSelection {
query := Query{
Type: QueryTypeSelection,
RawQuery: plainQuery.QueryText,
Search: plainQuery.QueryText,
Selection: plainQuery.QuerySelection,
}
query.Env.ActiveWindowTitle = m.GetUI().GetActiveWindowName()
query.Env.ActiveWindowPid = m.GetUI().GetActiveWindowPid()
query.Env.ActiveBrowserUrl = m.getActiveBrowserUrl(ctx)
return query, nil, nil
}
return Query{}, nil, errors.New("invalid query type")
}
func (m *Manager) getActiveBrowserUrl(ctx context.Context) string {
activeWindowName := m.GetUI().GetActiveWindowName()
isGoogleChrome := strings.ToLower(activeWindowName) == "google chrome"
if !isGoogleChrome {
return ""
}
return m.activeBrowserUrl
}
func (m *Manager) expandQueryShortcut(ctx context.Context, query string, queryShorts []setting.QueryShortcut) (newQuery string) {
newQuery = query
//sort query shorts by shortcut length, we will expand the longest shortcut first
slices.SortFunc(queryShorts, func(i, j setting.QueryShortcut) int {
return len(j.Shortcut) - len(i.Shortcut)
})
for _, shortcut := range queryShorts {
if strings.HasPrefix(query, shortcut.Shortcut) {
if !shortcut.HasPlaceholder() {
newQuery = strings.Replace(query, shortcut.Shortcut, shortcut.Query, 1)
break
} else {
queryWithoutShortcut := strings.Replace(query, shortcut.Shortcut, "", 1)
queryWithoutShortcut = strings.TrimLeft(queryWithoutShortcut, " ")
parameters := strings.Split(queryWithoutShortcut, " ")
placeholderCount := shortcut.PlaceholderCount()
var paramsCount = 0
var params []any
var nonPrams string
for _, param := range parameters {
if paramsCount < placeholderCount {
paramsCount++
params = append(params, param)
} else {
nonPrams += " " + param
}
}
newQuery = stringFormatter.Format(shortcut.Query, params...) + nonPrams
break
}
}
}
return newQuery
}
func (m *Manager) ExecuteAction(ctx context.Context, resultId string, actionId string) error {
resultCache, found := m.resultCache.Load(resultId)
if !found {
return fmt.Errorf("result cache not found for result id (execute action): %s", resultId)
}
action, exist := resultCache.Actions.Load(actionId)
if !exist {
return fmt.Errorf("action not found for result id: %s, action id: %s", resultId, actionId)
}
action(ctx, ActionContext{
ContextData: resultCache.ContextData,
})
util.Go(ctx, fmt.Sprintf("[%s] post execute action", resultCache.PluginInstance.Metadata.Name), func() {
m.postExecuteAction(ctx, resultCache)
})
return nil
}
func (m *Manager) postExecuteAction(ctx context.Context, resultCache *QueryResultCache) {
// Add actioned result for statistics
setting.GetSettingManager().AddActionedResult(ctx, resultCache.PluginInstance.Metadata.Id, resultCache.ResultTitle, resultCache.ResultSubTitle, resultCache.Query.RawQuery)
// Add to MRU if plugin supports it
if resultCache.PluginInstance.Metadata.IsSupportFeature(MetadataFeatureMRU) {
mruItem := setting.MRUItem{
PluginID: resultCache.PluginInstance.Metadata.Id,
Title: resultCache.ResultTitle,
SubTitle: resultCache.ResultSubTitle,
Icon: resultCache.Icon,
ContextData: resultCache.ContextData,
}
if err := setting.GetSettingManager().AddMRUItem(ctx, mruItem); err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("failed to add MRU item: %s", err.Error()))
}
}
// Add to query history only if query is not empty (skip empty queries like MRU)
if resultCache.Query.RawQuery != "" {
plainQuery := common.PlainQuery{
QueryType: resultCache.Query.Type,
QueryText: resultCache.Query.RawQuery,
}
setting.GetSettingManager().AddQueryHistory(ctx, plainQuery)
}
}
func (m *Manager) ExecuteRefresh(ctx context.Context, refreshableResultWithId RefreshableResultWithResultId) (RefreshableResultWithResultId, error) {
var refreshableResult RefreshableResult
copyErr := copier.Copy(&refreshableResult, &refreshableResultWithId)
if copyErr != nil {
return RefreshableResultWithResultId{}, fmt.Errorf("failed to copy refreshable result: %w", copyErr)
}
// maybe user has changed the query, which may flush the result cache
resultCache, found := m.resultCache.Load(refreshableResultWithId.ResultId)
if !found {
return refreshableResultWithId, fmt.Errorf("result cache not found for result id (execute refresh): %s", refreshableResultWithId.ResultId)
}
//restore actions in cache
refreshableResult.Actions = []QueryResultAction{}
for _, action := range refreshableResultWithId.Actions {
// get actual action from cache
actionFunc, exist := resultCache.Actions.Load(action.Id)
if !exist {
continue
}
refreshableResult.Actions = append(refreshableResult.Actions, QueryResultAction{
Id: action.Id,
Name: action.Name,
Icon: action.Icon,
IsDefault: action.IsDefault,
PreventHideAfterAction: action.PreventHideAfterAction,
Hotkey: action.Hotkey,
Action: actionFunc,
IsSystemAction: action.IsSystemAction,
})
}
newResult := resultCache.Refresh(ctx, refreshableResult)
// add default actions if there is no system action
if lo.CountBy(newResult.Actions, func(action QueryResultAction) bool {
return action.IsSystemAction
}) == 0 {
defaultActions := m.getDefaultActions(ctx, resultCache.PluginInstance, resultCache.Query, newResult.Title, newResult.SubTitle)
newResult.Actions = append(newResult.Actions, defaultActions...)
}
newResult = m.polishRefreshableResult(ctx, resultCache, newResult)
return RefreshableResultWithResultId{
ResultId: refreshableResultWithId.ResultId,
Title: newResult.Title,
SubTitle: newResult.SubTitle,
Icon: newResult.Icon,
Tails: newResult.Tails,
Preview: newResult.Preview,
ContextData: newResult.ContextData,
RefreshInterval: newResult.RefreshInterval,
Actions: lo.Map(newResult.Actions, func(action QueryResultAction, index int) QueryResultActionUI {
return QueryResultActionUI{
Id: action.Id,
Name: action.Name,
Icon: action.Icon,
IsDefault: action.IsDefault,
PreventHideAfterAction: action.PreventHideAfterAction,
Hotkey: action.Hotkey,
IsSystemAction: action.IsSystemAction,
}
}),
}, nil
}
func (m *Manager) GetResultPreview(ctx context.Context, resultId string) (WoxPreview, error) {
resultCache, found := m.resultCache.Load(resultId)
if !found {
return WoxPreview{}, fmt.Errorf("result cache not found for result id (get preview): %s", resultId)
}
preview := m.polishPreview(ctx, resultCache.Preview)
// if preview text is too long, ellipsis it, otherwise UI maybe freeze when render
if preview.PreviewType == WoxPreviewTypeText {
preview.PreviewData = util.EllipsisMiddle(preview.PreviewData, 2000)
// translate preview data if preview type is text
preview.PreviewData = m.translatePlugin(ctx, resultCache.PluginInstance, preview.PreviewData)
}
return preview, nil
}
func (m *Manager) polishPreview(ctx context.Context, preview WoxPreview) WoxPreview {
if preview.PreviewType == WoxPreviewTypeImage {
woxImage, err := common.ParseWoxImage(preview.PreviewData)
if err != nil {
logger.Error(ctx, fmt.Sprintf("failed to parse wox image for preview: %s", err.Error()))
return preview
}
if woxImage.ImageType == common.WoxImageTypeAbsolutePath {
newWoxImage := common.ConvertLocalImageToUrl(ctx, woxImage)
return WoxPreview{
PreviewType: WoxPreviewTypeImage,
PreviewData: newWoxImage.String(),
PreviewProperties: preview.PreviewProperties,
}
}
}
return preview
}
func (m *Manager) ReplaceQueryVariable(ctx context.Context, query string) string {
if strings.Contains(query, QueryVariableSelectedText) {
selected, selectedErr := selection.GetSelected(ctx)
if selectedErr != nil {
logger.Error(ctx, fmt.Sprintf("failed to get selected text: %s", selectedErr.Error()))
} else {
if selected.Type == selection.SelectionTypeText {
query = strings.ReplaceAll(query, QueryVariableSelectedText, selected.Text)
} else {
logger.Error(ctx, fmt.Sprintf("selected data is not text, type: %s", selected.Type))
}
}
}
if strings.Contains(query, QueryVariableActiveBrowserUrl) {
activeBrowserUrl := m.activeBrowserUrl
query = strings.ReplaceAll(query, QueryVariableActiveBrowserUrl, activeBrowserUrl)
}
return query
}
func (m *Manager) IsHostStarted(ctx context.Context, runtime Runtime) bool {
if runtime == PLUGIN_RUNTIME_GO {
return true
}
for _, host := range AllHosts {
if host.GetRuntime(ctx) == runtime {
return host.IsStarted(ctx)
}
}
return false
}
func (m *Manager) IsTriggerKeywordAIChat(ctx context.Context, triggerKeyword string) bool {
aiChatPluginInstance := m.GetAIChatPluginInstance(ctx)
if aiChatPluginInstance == nil {
return false
}
return lo.Contains(aiChatPluginInstance.GetTriggerKeywords(), triggerKeyword)
}
func (m *Manager) GetAIChatPluginInstance(ctx context.Context) *Instance {
aiChatPlugin := m.GetPluginInstances()
aiChatPluginInstance, exist := lo.Find(aiChatPlugin, func(item *Instance) bool {
return item.Metadata.Id == "a9cfd85a-6e53-415c-9d44-68777aa6323d"
})
if exist {
return aiChatPluginInstance
}
return nil
}
func (m *Manager) GetAIChatPluginChater(ctx context.Context) common.AIChater {
aiChatPluginInstance := m.GetAIChatPluginInstance(ctx)
if aiChatPluginInstance == nil {
return nil
}
chater, ok := aiChatPluginInstance.Plugin.(common.AIChater)
if ok {
return chater
}
return nil
}
func (m *Manager) GetAIProvider(ctx context.Context, provider common.ProviderName) (ai.Provider, error) {
if v, exist := m.aiProviders.Load(provider); exist {
return v, nil
}
//check if provider has setting
aiProviderSettings := setting.GetSettingManager().GetWoxSetting(ctx).AIProviders.Get()
providerSetting, providerSettingExist := lo.Find(aiProviderSettings, func(item setting.AIProvider) bool {
return item.Name == provider
})
if !providerSettingExist {
return nil, fmt.Errorf("ai provider setting not found: %s", provider)
}
newProvider, newProviderErr := ai.NewProvider(ctx, providerSetting)
if newProviderErr != nil {
return nil, newProviderErr
}
m.aiProviders.Store(provider, newProvider)
return newProvider, nil
}
func (m *Manager) ExecutePluginDeeplink(ctx context.Context, pluginId string, arguments map[string]string) {
pluginInstance, exist := lo.Find(m.instances, func(item *Instance) bool {
return item.Metadata.Id == pluginId
})
if !exist {
logger.Error(ctx, fmt.Sprintf("plugin not found: %s", pluginId))
return
}
for _, callback := range pluginInstance.DeepLinkCallbacks {
util.Go(ctx, fmt.Sprintf("[%s] execute deeplink callback", pluginInstance.Metadata.Name), func() {
callback(arguments)
})
}
}
func (m *Manager) QueryMRU(ctx context.Context) []QueryResultUI {
mruItems, err := setting.GetSettingManager().GetMRUItems(ctx, 10)
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("failed to get MRU items: %s", err.Error()))
return []QueryResultUI{}
}
var results []QueryResultUI
for _, item := range mruItems {
pluginInstance := m.getPluginInstance(item.PluginID)
if pluginInstance == nil {
continue
}
if !pluginInstance.Metadata.IsSupportFeature(MetadataFeatureMRU) {
continue
}
if restored := m.restoreFromMRU(ctx, pluginInstance, item); restored != nil {
polishedResult := m.PolishResult(ctx, pluginInstance, Query{}, *restored)
results = append(results, polishedResult.ToUI())
}
}
return results
}
// getPluginInstance finds a plugin instance by ID
func (m *Manager) getPluginInstance(pluginID string) *Instance {
pluginInstance, found := lo.Find(m.instances, func(item *Instance) bool {
return item.Metadata.Id == pluginID
})
if found {
return pluginInstance
}
return nil
}
// restoreFromMRU attempts to restore a QueryResult from MRU data
func (m *Manager) restoreFromMRU(ctx context.Context, pluginInstance *Instance, item setting.MRUItem) *QueryResult {
// For Go plugins, call MRU restore callbacks directly
if len(pluginInstance.MRURestoreCallbacks) > 0 {
mruData := MRUData{
PluginID: item.PluginID,
Title: item.Title,
SubTitle: item.SubTitle,
Icon: item.Icon,
ContextData: item.ContextData,
LastUsed: item.LastUsed,
UseCount: item.UseCount,
}
// Call the first (and typically only) MRU restore callback
if restored, err := pluginInstance.MRURestoreCallbacks[0](mruData); err == nil {
return restored
} else {
util.GetLogger().Debug(ctx, fmt.Sprintf("MRU restore failed for plugin %s: %s", pluginInstance.Metadata.Name, err.Error()))
}
}
// For external plugins (Python/Node.js), MRU support will be implemented later
// Currently only Go plugins support MRU functionality
if pluginInstance.Host != nil {
util.GetLogger().Debug(ctx, fmt.Sprintf("External plugin MRU restore not yet implemented for plugin %s", pluginInstance.Metadata.Name))
}
return nil
}