refactor(clipboard): reduce memory usage

- Implemented ClipboardPlugin to manage clipboard history including text and image data.
- Added functionality to keep track of clipboard history with options to retain text and image history.
- Introduced a SQLite database (ClipboardDB) to store clipboard records, including metadata such as file path, dimensions, and size for images.
- Implemented migration from legacy clipboard data to the new database structure.
- Added settings for managing clipboard history retention duration and primary action preferences.
- Enhanced query capabilities to retrieve recent and favorite clipboard records.
- Implemented periodic cleanup routine to remove expired records and orphaned cache files.
- Added localization support for new features in Chinese (zh_CN).
This commit is contained in:
qianlifeng 2025-07-04 22:01:16 +08:00
parent f219a2efce
commit d2da6beea6
No known key found for this signature in database
14 changed files with 1325 additions and 729 deletions

View File

@ -69,6 +69,7 @@ require (
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/metoro-io/mcp-golang v0.8.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect

View File

@ -72,6 +72,8 @@ github.com/mat/besticon v0.0.0-20231103204413-ee089084f347/go.mod h1:bzMBPMkFE6o
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/metoro-io/mcp-golang v0.8.0 h1:DkigHa3w7WwMFomcEz5wiMDX94DsvVm/3mCV3d1obnc=
github.com/metoro-io/mcp-golang v0.8.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=

View File

@ -34,6 +34,8 @@ import (
_ "wox/plugin/system/converter"
_ "wox/plugin/system/file"
_ "wox/plugin/system/clipboard"
)
func main() {

View File

@ -925,8 +925,10 @@ func (m *Manager) PolishResult(ctx context.Context, pluginInstance *Instance, qu
}
// 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
if !result.Preview.IsEmpty() && result.Preview.PreviewType != WoxPreviewTypeRemote {
// 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,

View File

@ -214,7 +214,7 @@ func (c *Plugin) querySelection(ctx context.Context, query plugin.Query) []plugi
}
// paste to active window
pasteToActiveWindowAction, pasteToActiveWindowErr := getPasteToActiveWindowAction(ctx, c.api, func() {
pasteToActiveWindowAction, pasteToActiveWindowErr := GetPasteToActiveWindowAction(ctx, c.api, func() {
clipboard.WriteText(content)
})
if pasteToActiveWindowErr == nil {
@ -436,7 +436,7 @@ func (c *Plugin) queryCommand(ctx context.Context, query plugin.Query) []plugin.
}
// paste to active window
pasteToActiveWindowAction, pasteToActiveWindowErr := getPasteToActiveWindowAction(ctx, c.api, func() {
pasteToActiveWindowAction, pasteToActiveWindowErr := GetPasteToActiveWindowAction(ctx, c.api, func() {
_, content := processAIThinking(result.ContextData)
clipboard.WriteText(content)
})

View File

@ -1,721 +0,0 @@
package system
import (
"context"
"encoding/json"
"fmt"
"image"
"os"
"path"
"slices"
"strconv"
"strings"
"wox/common"
"wox/plugin"
"wox/setting/definition"
"wox/util"
"wox/util/clipboard"
"github.com/cdfmlr/ellipsis"
"github.com/disintegration/imaging"
"github.com/google/uuid"
"github.com/samber/lo"
)
var clipboardIcon = plugin.PluginClipboardIcon
var isKeepTextHistorySettingKey = "is_keep_text_history"
var textHistoryDaysSettingKey = "text_history_days"
var isKeepImageHistorySettingKey = "is_keep_image_history"
var imageHistoryDaysSettingKey = "image_history_days"
var primaryActionSettingKey = "primary_action"
var primaryActionValueCopy = "copy"
var primaryActionValuePaste = "paste"
func init() {
plugin.AllSystemPlugin = append(plugin.AllSystemPlugin, &ClipboardPlugin{
maxHistoryCount: 5000,
})
}
type ClipboardHistory struct {
Id string
Data clipboard.Data
Icon common.WoxImage
Timestamp int64
IsFavorite bool
}
type ClipboardHistoryJson struct {
Id string
DataType clipboard.Type
Data []byte
Icon common.WoxImage
Timestamp int64
IsFavorite bool
}
func (c *ClipboardHistory) MarshalJSON() ([]byte, error) {
var data = ClipboardHistoryJson{
Id: c.Id,
Icon: c.Icon,
Timestamp: c.Timestamp,
IsFavorite: c.IsFavorite,
}
if c.Data != nil {
marshalJSON, err := c.Data.MarshalJSON()
if err != nil {
return nil, err
}
data.DataType = c.Data.GetType()
data.Data = marshalJSON
}
return json.Marshal(data)
}
func (c *ClipboardHistory) UnmarshalJSON(data []byte) error {
var clipboardHistoryJson ClipboardHistoryJson
err := json.Unmarshal(data, &clipboardHistoryJson)
if err != nil {
return err
}
c.Id = clipboardHistoryJson.Id
c.Icon = clipboardHistoryJson.Icon
c.Timestamp = clipboardHistoryJson.Timestamp
c.IsFavorite = clipboardHistoryJson.IsFavorite
if clipboardHistoryJson.Data != nil {
var clipboardDataType = clipboardHistoryJson.DataType
if clipboardDataType == clipboard.ClipboardTypeText {
var textData = clipboard.TextData{}
unmarshalErr := json.Unmarshal(clipboardHistoryJson.Data, &textData)
if unmarshalErr != nil {
return unmarshalErr
}
c.Data = &textData
} else if clipboardDataType == clipboard.ClipboardTypeFile {
var filePathData = clipboard.FilePathData{}
unmarshalErr := json.Unmarshal(clipboardHistoryJson.Data, &filePathData)
if unmarshalErr != nil {
return unmarshalErr
}
c.Data = &filePathData
} else if clipboardDataType == clipboard.ClipboardTypeImage {
var imageData = clipboard.ImageData{}
unmarshalErr := json.Unmarshal(clipboardHistoryJson.Data, &imageData)
if unmarshalErr != nil {
return unmarshalErr
}
c.Data = &imageData
} else {
return fmt.Errorf("unsupported clipboard data type: %s", clipboardDataType)
}
}
return nil
}
type clipboardImageCache struct {
preview common.WoxImage
icon common.WoxImage
}
type ClipboardPlugin struct {
api plugin.API
history []ClipboardHistory
favHistory []ClipboardHistory
maxHistoryCount int
}
func (c *ClipboardPlugin) GetMetadata() plugin.Metadata {
return plugin.Metadata{
Id: "5f815d98-27f5-488d-a756-c317ea39935b",
Name: "Clipboard History",
Author: "Wox Launcher",
Website: "https://github.com/Wox-launcher/Wox",
Version: "1.0.0",
MinWoxVersion: "2.0.0",
Runtime: "Go",
Description: "Clipboard history for Wox",
Icon: clipboardIcon.String(),
Entry: "",
TriggerKeywords: []string{
"cb",
},
Features: []plugin.MetadataFeature{
{
Name: plugin.MetadataFeatureIgnoreAutoScore,
},
},
Commands: []plugin.MetadataCommand{
{
Command: "fav",
Description: "List favorite clipboard history",
},
},
SupportedOS: []string{
"Windows",
"Macos",
"Linux",
},
SettingDefinitions: []definition.PluginSettingDefinitionItem{
{
Type: definition.PluginSettingDefinitionTypeCheckBox,
Value: &definition.PluginSettingValueCheckBox{
Key: isKeepTextHistorySettingKey,
DefaultValue: "true",
Style: definition.PluginSettingValueStyle{
PaddingRight: 10,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeTextBox,
Value: &definition.PluginSettingValueTextBox{
Key: textHistoryDaysSettingKey,
Label: "i18n:plugin_clipboard_keep_text_history",
Suffix: "i18n:plugin_clipboard_days",
DefaultValue: "90",
Style: definition.PluginSettingValueStyle{
Width: 50,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeNewLine,
},
{
Type: definition.PluginSettingDefinitionTypeCheckBox,
Value: &definition.PluginSettingValueCheckBox{
Key: isKeepImageHistorySettingKey,
DefaultValue: "true",
Style: definition.PluginSettingValueStyle{
PaddingRight: 10,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeTextBox,
Value: &definition.PluginSettingValueTextBox{
Key: imageHistoryDaysSettingKey,
Label: "i18n:plugin_clipboard_keep_image_history",
Suffix: "i18n:plugin_clipboard_days",
DefaultValue: "3",
Style: definition.PluginSettingValueStyle{
Width: 50,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeNewLine,
},
{
Type: definition.PluginSettingDefinitionTypeSelect,
Value: &definition.PluginSettingValueSelect{
Key: primaryActionSettingKey,
Label: "i18n:plugin_clipboard_primary_action",
DefaultValue: primaryActionValuePaste,
Options: []definition.PluginSettingValueSelectOption{
{Label: "i18n:plugin_clipboard_primary_action_copy_to_clipboard", Value: primaryActionValueCopy},
{Label: "i18n:plugin_clipboard_primary_action_paste_to_active_app", Value: primaryActionValuePaste},
},
},
},
},
}
}
func (c *ClipboardPlugin) Init(ctx context.Context, initParams plugin.InitParams) {
c.api = initParams.API
c.loadHistory(ctx)
clipboard.Watch(func(data clipboard.Data) {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("clipboard data changed, type=%s", data.GetType()))
// ignore file type
if data.GetType() == clipboard.ClipboardTypeFile {
return
}
if data.GetType() == clipboard.ClipboardTypeText && !c.isKeepTextHistory(ctx) {
return
}
if data.GetType() == clipboard.ClipboardTypeImage && !c.isKeepImageHistory(ctx) {
return
}
icon := c.getDefaultTextIcon()
if data.GetType() == clipboard.ClipboardTypeText {
textData := data.(*clipboard.TextData)
if len(textData.Text) == 0 {
return
}
if strings.TrimSpace(textData.Text) == "" {
return
}
if iconImage, iconErr := getActiveWindowIcon(ctx); iconErr == nil {
icon = iconImage
}
}
// if last history is same with current changed one, ignore it
if len(c.history) > 0 {
lastHistory := c.history[len(c.history)-1]
if lastHistory.Data.GetType() == data.GetType() {
if data.GetType() == clipboard.ClipboardTypeText {
changedTextData := data.(*clipboard.TextData)
lastTextData := lastHistory.Data.(*clipboard.TextData)
if lastTextData.Text == changedTextData.Text {
c.history[len(c.history)-1].Timestamp = util.GetSystemTimestamp()
return
}
}
if data.GetType() == clipboard.ClipboardTypeImage {
changedImageData := data.(*clipboard.ImageData)
lastImageData := lastHistory.Data.(*clipboard.ImageData)
// if image size is same, ignore it
if lastImageData.Image.Bounds().Eq(changedImageData.Image.Bounds()) {
c.history[len(c.history)-1].Timestamp = util.GetSystemTimestamp()
return
}
}
}
}
history := ClipboardHistory{
Id: uuid.NewString(),
Data: data,
Timestamp: util.GetSystemTimestamp(),
Icon: icon,
IsFavorite: false,
}
if data.GetType() == clipboard.ClipboardTypeImage {
c.generateHistoryPreviewAndIconImage(ctx, history)
}
c.history = append(c.history, history)
util.Go(ctx, "save history", func() {
c.saveHistory(ctx)
})
})
}
func (c *ClipboardPlugin) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult {
var results []plugin.QueryResult
if query.Command == "fav" {
for i := range c.favHistory {
results = append(results, c.convertClipboardData(ctx, c.favHistory[i], query))
}
return results
}
if query.Search == "" {
// return all favorite clipboard history
for i := range c.favHistory {
results = append(results, c.convertClipboardData(ctx, c.favHistory[i], query))
}
//return top 50 clipboard history order by desc
var count = 0
for i := len(c.history) - 1; i >= 0; i-- {
history := c.history[i]
// favorite history already in result, skip it
if !history.IsFavorite {
results = append(results, c.convertClipboardData(ctx, history, query))
count++
if count >= 50 {
break
}
}
}
return results
}
//only text support search
for i := len(c.history) - 1; i >= 0; i-- {
history := c.history[i]
if history.Data.GetType() == clipboard.ClipboardTypeText {
historyData := history.Data.(*clipboard.TextData)
if strings.Contains(strings.ToLower(historyData.Text), strings.ToLower(query.Search)) {
results = append(results, c.convertClipboardData(ctx, history, query))
}
}
}
return results
}
func (c *ClipboardPlugin) convertClipboardData(ctx context.Context, history ClipboardHistory, query plugin.Query) plugin.QueryResult {
if history.Data.GetType() == clipboard.ClipboardTypeText {
historyData := history.Data.(*clipboard.TextData)
if history.Icon.ImageType == common.WoxImageTypeAbsolutePath {
// if image doesn't exist, use default icon
if _, err := os.Stat(history.Icon.ImageData); err != nil {
history.Icon = c.getDefaultTextIcon()
}
}
primaryActionCode := c.api.GetSetting(ctx, primaryActionSettingKey)
actions := []plugin.QueryResultAction{
{
Name: "Copy",
Icon: plugin.CopyIcon,
IsDefault: primaryActionValueCopy == primaryActionCode,
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
c.moveHistoryToTop(ctx, history.Id)
clipboard.Write(history.Data)
},
},
}
// paste to active window
pasteToActiveWindowAction, pasteToActiveWindowErr := getPasteToActiveWindowAction(ctx, c.api, func() {
c.moveHistoryToTop(ctx, history.Id)
clipboard.Write(history.Data)
})
if pasteToActiveWindowErr == nil {
actions = append(actions, pasteToActiveWindowAction)
}
if !history.IsFavorite {
actions = append(actions, plugin.QueryResultAction{
Name: "Mark as favorite",
Icon: plugin.AddToFavIcon,
PreventHideAfterAction: true,
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
needSave := false
for i := range c.history {
if c.history[i].Id == history.Id {
c.history[i].IsFavorite = true
needSave = true
// if history is not in favorite list, add it
_, exist := lo.Find(c.favHistory, func(i ClipboardHistory) bool {
return i.Id == history.Id
})
if !exist {
c.favHistory = append(c.favHistory, c.history[i])
}
break
}
}
if needSave {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("save history as favorite, id=%s", history.Id))
util.Go(ctx, "save history", func() {
c.saveHistory(ctx)
})
refreshQuery(ctx, c.api, query)
}
},
})
} else {
actions = append(actions, plugin.QueryResultAction{
Name: "Cancel favorite",
Icon: plugin.RemoveFromFavIcon,
PreventHideAfterAction: true,
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
needSave := false
for i := range c.history {
if c.history[i].Id == history.Id {
c.history[i].IsFavorite = false
needSave = true
// if history is in favorite list, remove it
_, index, _ := lo.FindIndexOf(c.favHistory, func(i ClipboardHistory) bool {
return i.Id == history.Id
})
if index != -1 {
c.favHistory = append(c.favHistory[:index], c.favHistory[index+1:]...)
}
break
}
}
if needSave {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("cancel history favorite, id=%s", history.Id))
util.Go(ctx, "save history", func() {
c.saveHistory(ctx)
})
refreshQuery(ctx, c.api, query)
}
},
})
}
group, groupScore := c.getResultGroup(ctx, history)
return plugin.QueryResult{
Title: strings.TrimSpace(ellipsis.Centering(historyData.Text, 80)),
Icon: history.Icon,
Group: group,
GroupScore: groupScore,
Preview: plugin.WoxPreview{
PreviewType: plugin.WoxPreviewTypeText,
PreviewData: historyData.Text,
PreviewProperties: map[string]string{
"i18n:plugin_clipboard_copy_date": util.FormatTimestamp(history.Timestamp),
"i18n:plugin_clipboard_copy_characters": fmt.Sprintf("%d", len(historyData.Text)),
},
},
Score: history.Timestamp,
Actions: actions,
}
}
if history.Data.GetType() == clipboard.ClipboardTypeImage {
historyData := history.Data.(*clipboard.ImageData)
previewWoxImage, iconWoxImage := c.generateHistoryPreviewAndIconImage(ctx, history)
group, groupScore := c.getResultGroup(ctx, history)
return plugin.QueryResult{
Title: fmt.Sprintf("Image (%d*%d) (%s)", historyData.Image.Bounds().Dx(), historyData.Image.Bounds().Dy(), c.getImageSize(ctx, historyData.Image)),
Icon: iconWoxImage,
Group: group,
GroupScore: groupScore,
Preview: plugin.WoxPreview{
PreviewType: plugin.WoxPreviewTypeImage,
PreviewData: previewWoxImage.String(),
PreviewProperties: map[string]string{
"i18n:plugin_clipboard_copy_date": util.FormatTimestamp(history.Timestamp),
"i18n:plugin_clipboard_image_width": fmt.Sprintf("%d", historyData.Image.Bounds().Dx()),
"i18n:plugin_clipboard_image_height": fmt.Sprintf("%d", historyData.Image.Bounds().Dy()),
},
},
Score: history.Timestamp,
Actions: []plugin.QueryResultAction{
{
Name: "Copy to clipboard",
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
clipboard.Write(history.Data)
},
},
},
}
}
return plugin.QueryResult{
Title: "ERR: Unknown history data type",
}
}
func (c *ClipboardPlugin) getResultGroup(ctx context.Context, history ClipboardHistory) (string, int64) {
if history.IsFavorite {
return "Favorites", 100
}
if util.GetSystemTimestamp()-history.Timestamp < 1000*60*60*24 {
return "Today", 90
}
if util.GetSystemTimestamp()-history.Timestamp < 1000*60*60*24*2 {
return "Yesterday", 80
}
return "History", 10
}
func (c *ClipboardPlugin) generateHistoryPreviewAndIconImage(ctx context.Context, history ClipboardHistory) (previewImg, iconImg common.WoxImage) {
imagePreviewFile := path.Join(util.GetLocation().GetImageCacheDirectory(), fmt.Sprintf("clipboard_%s_preview.png", history.Id))
imageIconFile := path.Join(util.GetLocation().GetImageCacheDirectory(), fmt.Sprintf("clipboard_%s_icon.png", history.Id))
if util.IsFileExists(imagePreviewFile) {
previewImg = common.NewWoxImageAbsolutePath(imagePreviewFile)
iconImg = common.NewWoxImageAbsolutePath(imageIconFile)
return
}
historyData := history.Data.(*clipboard.ImageData)
compressedPreviewImg := imaging.Resize(historyData.Image, 400, 0, imaging.Lanczos)
compressedIconImg := imaging.Resize(historyData.Image, 40, 0, imaging.Lanczos)
previewImage, err := common.NewWoxImage(compressedPreviewImg)
if err != nil {
previewImage = c.getDefaultTextIcon()
}
iconImage, iconErr := common.NewWoxImage(compressedIconImg)
if iconErr != nil {
iconImage = plugin.PreviewIcon
}
if saveErr := imaging.Save(compressedPreviewImg, imagePreviewFile); saveErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("save clipboard image preview cache failed, err=%s", saveErr.Error()))
}
if saveErr := imaging.Save(compressedIconImg, imageIconFile); saveErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("save clipboard image icon cache failed, err=%s", saveErr.Error()))
}
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("generate history image preview and icon cache, id=%s", history.Id))
return previewImage, iconImage
}
func (c *ClipboardPlugin) getImageSize(ctx context.Context, image image.Image) string {
bounds := image.Bounds()
sizeMb := float64(bounds.Dx()*bounds.Dy()) * 24 / 8 / 1024 / 1024
return fmt.Sprintf("%.2f MB", sizeMb)
}
func (c *ClipboardPlugin) moveHistoryToTop(ctx context.Context, id string) {
for i := range c.history {
if c.history[i].Id == id {
c.history[i].Timestamp = util.GetSystemTimestamp()
break
}
}
// sort history by timestamp asc
slices.SortStableFunc(c.history, func(i, j ClipboardHistory) int {
return int(i.Timestamp - j.Timestamp)
})
}
func (c *ClipboardPlugin) saveHistory(ctx context.Context) {
startTimestamp := util.GetSystemTimestamp()
var favoriteHistories []ClipboardHistory
var normalHistories []ClipboardHistory
for i := len(c.history) - 1; i >= 0; i-- {
if c.history[i].Data == nil {
continue
}
if c.history[i].IsFavorite {
favoriteHistories = append(favoriteHistories, c.history[i])
continue
}
if c.history[i].Data.GetType() == clipboard.ClipboardTypeText {
if util.GetSystemTimestamp()-c.history[i].Timestamp > int64(c.getTextHistoryDays(ctx))*24*60*60*1000 {
continue
}
}
if c.history[i].Data.GetType() == clipboard.ClipboardTypeImage {
if util.GetSystemTimestamp()-c.history[i].Timestamp > int64(c.getImageHistoryDays(ctx))*24*60*60*1000 {
continue
}
}
normalHistories = append(normalHistories, c.history[i])
}
histories := append(favoriteHistories, normalHistories...)
// sort history by timestamp asc
slices.SortStableFunc(histories, func(i, j ClipboardHistory) int {
return int(i.Timestamp - j.Timestamp)
})
historyJson, marshalErr := json.Marshal(histories)
if marshalErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("marshal clipboard text history failed, err=%s", marshalErr.Error()))
return
}
c.api.SaveSetting(ctx, "history", string(historyJson), false)
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("save clipboard history, count:%d, cost:%dms", len(c.history), util.GetSystemTimestamp()-startTimestamp))
}
func (c *ClipboardPlugin) loadHistory(ctx context.Context) {
historyJson := c.api.GetSetting(ctx, "history")
if historyJson == "" {
return
}
startTimestamp := util.GetSystemTimestamp()
var history []ClipboardHistory
unmarshalErr := json.Unmarshal([]byte(historyJson), &history)
if unmarshalErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("unmarshal clipboard text history failed, err=%s", unmarshalErr.Error()))
}
//sort history by timestamp asc
slices.SortStableFunc(history, func(i, j ClipboardHistory) int {
return int(i.Timestamp - j.Timestamp)
})
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("load clipboard history, count=%d, cost=%dms", len(history), util.GetSystemTimestamp()-startTimestamp))
c.history = history
//load favorite history
var favHistory []ClipboardHistory
for i := len(c.history) - 1; i >= 0; i-- {
if c.history[i].IsFavorite {
favHistory = append(favHistory, c.history[i])
}
}
c.favHistory = favHistory
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("load favorite clipboard history, count=%d", len(c.favHistory)))
util.Go(ctx, "convert favorite history image", func() {
for i := range c.favHistory {
if c.favHistory[i].Data.GetType() == clipboard.ClipboardTypeImage {
c.generateHistoryPreviewAndIconImage(ctx, c.favHistory[i])
}
}
})
}
func (c *ClipboardPlugin) getDefaultTextIcon() common.WoxImage {
return plugin.TextIcon
}
func (c *ClipboardPlugin) isKeepTextHistory(ctx context.Context) bool {
isKeepTextHistory := c.api.GetSetting(ctx, isKeepTextHistorySettingKey)
if isKeepTextHistory == "" {
return true
}
isKeepTextHistoryBool, err := strconv.ParseBool(isKeepTextHistory)
if err != nil {
return true
}
return isKeepTextHistoryBool
}
func (c *ClipboardPlugin) getTextHistoryDays(ctx context.Context) int {
textHistoryDays := c.api.GetSetting(ctx, textHistoryDaysSettingKey)
if textHistoryDays == "" {
return 90
}
textHistoryDaysInt, err := strconv.Atoi(textHistoryDays)
if err != nil {
return 90
}
return textHistoryDaysInt
}
func (c *ClipboardPlugin) isKeepImageHistory(ctx context.Context) bool {
isKeepImageHistory := c.api.GetSetting(ctx, isKeepImageHistorySettingKey)
if isKeepImageHistory == "" {
return true
}
isKeepImageHistoryBool, err := strconv.ParseBool(isKeepImageHistory)
if err != nil {
return true
}
return isKeepImageHistoryBool
}
func (c *ClipboardPlugin) getImageHistoryDays(ctx context.Context) int {
imageHistoryDays := c.api.GetSetting(ctx, imageHistoryDaysSettingKey)
if imageHistoryDays == "" {
return 3
}
imageHistoryDaysInt, err := strconv.Atoi(imageHistoryDays)
if err != nil {
return 3
}
return imageHistoryDaysInt
}

View File

@ -0,0 +1,854 @@
package system
import (
"context"
"encoding/json"
"fmt"
"image"
"os"
"path"
"strconv"
"strings"
"time"
"wox/common"
"wox/plugin"
"wox/plugin/system"
"wox/setting/definition"
"wox/util"
"wox/util/clipboard"
"github.com/cdfmlr/ellipsis"
"github.com/disintegration/imaging"
"github.com/google/uuid"
)
var clipboardIcon = plugin.PluginClipboardIcon
var isKeepTextHistorySettingKey = "is_keep_text_history"
var textHistoryDaysSettingKey = "text_history_days"
var isKeepImageHistorySettingKey = "is_keep_image_history"
var imageHistoryDaysSettingKey = "image_history_days"
var primaryActionSettingKey = "primary_action"
var primaryActionValueCopy = "copy"
var primaryActionValuePaste = "paste"
func init() {
plugin.AllSystemPlugin = append(plugin.AllSystemPlugin, &ClipboardPlugin{
maxHistoryCount: 5000,
imageCache: make(map[string]*ImageCacheEntry),
})
}
// ImageCacheEntry represents cached preview and icon images
type ImageCacheEntry struct {
Preview common.WoxImage
Icon common.WoxImage
}
type ClipboardPlugin struct {
api plugin.API
db *ClipboardDB
maxHistoryCount int
// Cache for generated preview and icon images to avoid regeneration
imageCache map[string]*ImageCacheEntry
}
func (c *ClipboardPlugin) GetMetadata() plugin.Metadata {
return plugin.Metadata{
Id: "5f815d98-27f5-488d-a756-c317ea39935b",
Name: "Clipboard History",
Author: "Wox Launcher",
Website: "https://github.com/Wox-launcher/Wox",
Version: "1.0.0",
MinWoxVersion: "2.0.0",
Runtime: "Go",
Description: "Clipboard history for Wox",
Icon: clipboardIcon.String(),
Entry: "",
TriggerKeywords: []string{
"cb",
},
Features: []plugin.MetadataFeature{
{
Name: plugin.MetadataFeatureIgnoreAutoScore,
},
},
Commands: []plugin.MetadataCommand{
{
Command: "fav",
Description: "List favorite clipboard history",
},
},
SupportedOS: []string{
"Windows",
"Macos",
"Linux",
},
SettingDefinitions: []definition.PluginSettingDefinitionItem{
{
Type: definition.PluginSettingDefinitionTypeCheckBox,
Value: &definition.PluginSettingValueCheckBox{
Key: isKeepTextHistorySettingKey,
DefaultValue: "true",
Style: definition.PluginSettingValueStyle{
PaddingRight: 10,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeTextBox,
Value: &definition.PluginSettingValueTextBox{
Key: textHistoryDaysSettingKey,
Label: "i18n:plugin_clipboard_keep_text_history",
Suffix: "i18n:plugin_clipboard_days",
DefaultValue: "90",
Style: definition.PluginSettingValueStyle{
Width: 50,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeNewLine,
},
{
Type: definition.PluginSettingDefinitionTypeCheckBox,
Value: &definition.PluginSettingValueCheckBox{
Key: isKeepImageHistorySettingKey,
DefaultValue: "true",
Style: definition.PluginSettingValueStyle{
PaddingRight: 10,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeTextBox,
Value: &definition.PluginSettingValueTextBox{
Key: imageHistoryDaysSettingKey,
Label: "i18n:plugin_clipboard_keep_image_history",
Suffix: "i18n:plugin_clipboard_days",
DefaultValue: "3",
Style: definition.PluginSettingValueStyle{
Width: 50,
},
},
},
{
Type: definition.PluginSettingDefinitionTypeNewLine,
},
{
Type: definition.PluginSettingDefinitionTypeSelect,
Value: &definition.PluginSettingValueSelect{
Key: primaryActionSettingKey,
Label: "i18n:plugin_clipboard_primary_action",
DefaultValue: primaryActionValuePaste,
Options: []definition.PluginSettingValueSelectOption{
{Label: "i18n:plugin_clipboard_primary_action_copy_to_clipboard", Value: primaryActionValueCopy},
{Label: "i18n:plugin_clipboard_primary_action_paste_to_active_app", Value: primaryActionValuePaste},
},
},
},
},
}
}
func (c *ClipboardPlugin) Init(ctx context.Context, initParams plugin.InitParams) {
c.api = initParams.API
// Initialize database
db, err := NewClipboardDB(ctx, c.GetMetadata().Id)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to initialize clipboard database: %s", err.Error()))
return
}
c.db = db
// Migrate legacy clipboard data from plugin settings
historyJson := c.api.GetSetting(ctx, "history")
if historyJson != "" {
var history []ClipboardHistory
unmarshalErr := json.Unmarshal([]byte(historyJson), &history)
if unmarshalErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to unmarshal legacy clipboard history: %s", unmarshalErr.Error()))
} else {
// Migrate legacy data
for _, item := range history {
if item.IsFavorite {
if err := c.db.migrateLegacyItem(ctx, item); err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to migrate legacy clipboard item: %s", err.Error()))
}
}
}
}
c.api.SaveSetting(ctx, "history", "", false)
}
// Register unload callback to close database connection
c.api.OnUnload(ctx, func() {
if c.db != nil {
c.db.Close()
}
})
// Start periodic cleanup routine
util.Go(ctx, "clipboard cleanup routine", func() {
c.startCleanupRoutine(ctx)
})
// Log initial database statistics
c.logDatabaseStats(ctx)
clipboard.Watch(func(data clipboard.Data) {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("clipboard data changed, type=%s", data.GetType()))
// ignore file type
if data.GetType() == clipboard.ClipboardTypeFile {
return
}
if data.GetType() == clipboard.ClipboardTypeText && !c.isKeepTextHistory(ctx) {
return
}
if data.GetType() == clipboard.ClipboardTypeImage && !c.isKeepImageHistory(ctx) {
return
}
// Validate text data
if data.GetType() == clipboard.ClipboardTypeText {
textData := data.(*clipboard.TextData)
if len(textData.Text) == 0 || strings.TrimSpace(textData.Text) == "" {
return
}
}
// Check for duplicate content by querying the most recent record
if c.isDuplicateContent(ctx, data) {
c.api.Log(ctx, plugin.LogLevelInfo, "duplicate clipboard content, skipping")
return
}
// Create new record
record := ClipboardRecord{
ID: uuid.NewString(),
Type: string(data.GetType()),
Timestamp: util.GetSystemTimestamp(),
IsFavorite: false,
CreatedAt: time.Now(),
}
// Handle different data types
if data.GetType() == clipboard.ClipboardTypeText {
textData := data.(*clipboard.TextData)
record.Content = textData.Text
// Try to get active window icon for text clipboard
if iconImage, iconErr := system.GetActiveWindowIcon(ctx); iconErr == nil {
iconStr := iconImage.String()
record.IconData = &iconStr
}
} else if data.GetType() == clipboard.ClipboardTypeImage {
// Save image to disk
imageData := data.(*clipboard.ImageData)
imageFilePath := path.Join(util.GetLocation().GetImageCacheDirectory(), fmt.Sprintf("clipboard_%s.png", record.ID))
if saveErr := imaging.Save(imageData.Image, imageFilePath); saveErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to save image to disk: %s", saveErr.Error()))
return
}
// Get image dimensions
width := imageData.Image.Bounds().Dx()
height := imageData.Image.Bounds().Dy()
// Get file size
var fileSize int64
if fileInfo, err := os.Stat(imageFilePath); err == nil {
fileSize = fileInfo.Size()
}
record.FilePath = imageFilePath
record.Width = &width
record.Height = &height
record.FileSize = &fileSize
record.Content = fmt.Sprintf("Image (%d×%d) (%s)", width, height, c.formatFileSize(fileSize))
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("saved clipboard image to disk: %s", imageFilePath))
}
// Insert into database
if err := c.db.Insert(ctx, record); err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to insert clipboard record: %s", err.Error()))
return
}
// Enforce max count limit
if deletedCount, err := c.db.EnforceMaxCount(ctx, c.maxHistoryCount); err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to enforce max count: %s", err.Error()))
} else if deletedCount > 0 {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("enforced max count, deleted %d old records", deletedCount))
}
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("saved clipboard %s to database", data.GetType()))
})
}
func (c *ClipboardPlugin) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult {
var results []plugin.QueryResult
if c.db == nil {
c.api.Log(ctx, plugin.LogLevelError, "database not initialized")
return results
}
if query.Command == "fav" {
// Get favorite records from database
favorites, err := c.db.GetFavorites(ctx)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get favorites: %s", err.Error()))
return results
}
for _, record := range favorites {
results = append(results, c.convertRecordToResult(ctx, record, query))
}
return results
}
if query.Search == "" {
// Get favorites first
favorites, err := c.db.GetFavorites(ctx)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get favorites: %s", err.Error()))
} else {
for _, record := range favorites {
results = append(results, c.convertRecordToResult(ctx, record, query))
}
}
// Get recent non-favorite records
recent, err := c.db.GetRecent(ctx, 50, 0)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get recent records: %s", err.Error()))
} else {
for _, record := range recent {
if !record.IsFavorite {
results = append(results, c.convertRecordToResult(ctx, record, query))
}
}
}
return results
}
// Search in text content
searchResults, err := c.db.SearchText(ctx, query.Search, 100)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to search text: %s", err.Error()))
return results
}
for _, record := range searchResults {
results = append(results, c.convertRecordToResult(ctx, record, query))
}
return results
}
// isDuplicateContent checks if the content is duplicate by comparing with the most recent record
func (c *ClipboardPlugin) isDuplicateContent(ctx context.Context, data clipboard.Data) bool {
recent, err := c.db.GetRecent(ctx, 1, 0)
if err != nil || len(recent) == 0 {
return false
}
lastRecord := recent[0]
if lastRecord.Type != string(data.GetType()) {
return false
}
if data.GetType() == clipboard.ClipboardTypeText {
textData := data.(*clipboard.TextData)
if lastRecord.Content == textData.Text {
// Update timestamp of existing record
c.db.UpdateTimestamp(ctx, lastRecord.ID, util.GetSystemTimestamp())
return true
}
}
if data.GetType() == clipboard.ClipboardTypeImage {
imageData := data.(*clipboard.ImageData)
currentSize := fmt.Sprintf("image(%dx%d)", imageData.Image.Bounds().Dx(), imageData.Image.Bounds().Dy())
if lastRecord.Content == currentSize {
// Update timestamp of existing record
c.db.UpdateTimestamp(ctx, lastRecord.ID, util.GetSystemTimestamp())
return true
}
}
return false
}
// convertRecordToResult converts a database record to a query result
func (c *ClipboardPlugin) convertRecordToResult(ctx context.Context, record ClipboardRecord, query plugin.Query) plugin.QueryResult {
if record.Type == string(clipboard.ClipboardTypeText) {
return c.convertTextRecord(ctx, record, query)
} else if record.Type == string(clipboard.ClipboardTypeImage) {
return c.convertImageRecord(ctx, record, query)
}
return plugin.QueryResult{
Title: "ERR: Unknown record type",
}
}
// convertTextRecord converts a text record to a query result
func (c *ClipboardPlugin) convertTextRecord(ctx context.Context, record ClipboardRecord, query plugin.Query) plugin.QueryResult {
primaryActionCode := c.api.GetSetting(ctx, primaryActionSettingKey)
actions := []plugin.QueryResultAction{
{
Name: "Copy",
Icon: plugin.CopyIcon,
IsDefault: primaryActionValueCopy == primaryActionCode,
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
c.moveRecordToTop(ctx, record.ID)
clipboard.WriteText(record.Content)
},
},
}
// paste to active window
pasteToActiveWindowAction, pasteToActiveWindowErr := system.GetPasteToActiveWindowAction(ctx, c.api, func() {
c.moveRecordToTop(ctx, record.ID)
clipboard.WriteText(record.Content)
})
if pasteToActiveWindowErr == nil {
actions = append(actions, pasteToActiveWindowAction)
}
if !record.IsFavorite {
actions = append(actions, plugin.QueryResultAction{
Name: "Mark as favorite",
Icon: plugin.AddToFavIcon,
PreventHideAfterAction: true,
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
if err := c.db.SetFavorite(ctx, record.ID, true); err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to set favorite: %s", err.Error()))
} else {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("marked record as favorite: %s", record.ID))
system.RefreshQuery(ctx, c.api, query)
}
},
})
} else {
actions = append(actions, plugin.QueryResultAction{
Name: "Cancel favorite",
Icon: plugin.RemoveFromFavIcon,
PreventHideAfterAction: true,
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
if err := c.db.SetFavorite(ctx, record.ID, false); err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to cancel favorite: %s", err.Error()))
} else {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("cancelled record favorite: %s", record.ID))
system.RefreshQuery(ctx, c.api, query)
}
},
})
}
group, groupScore := c.getResultGroup(ctx, record)
// Use stored icon data if available, otherwise use default text icon
icon := c.getDefaultTextIcon()
if record.IconData != nil && *record.IconData != "" {
if iconImage, err := common.ParseWoxImage(*record.IconData); err == nil {
icon = iconImage
}
}
return plugin.QueryResult{
Title: strings.TrimSpace(ellipsis.Centering(record.Content, 80)),
Icon: icon,
Group: group,
GroupScore: groupScore,
Preview: plugin.WoxPreview{
PreviewType: plugin.WoxPreviewTypeText,
PreviewData: record.Content,
PreviewProperties: map[string]string{
"i18n:plugin_clipboard_copy_date": util.FormatTimestamp(record.Timestamp),
"i18n:plugin_clipboard_copy_characters": fmt.Sprintf("%d", len(record.Content)),
},
},
Score: record.Timestamp,
Actions: actions,
}
}
// convertImageRecord converts an image record to a query result
func (c *ClipboardPlugin) convertImageRecord(ctx context.Context, record ClipboardRecord, query plugin.Query) plugin.QueryResult {
previewWoxImage, iconWoxImage := c.generateImagePreviewAndIcon(ctx, record)
group, groupScore := c.getResultGroup(ctx, record)
// Build preview properties with available information
previewProperties := map[string]string{
"i18n:plugin_clipboard_copy_date": util.FormatTimestamp(record.Timestamp),
}
if record.Width != nil {
previewProperties["i18n:plugin_clipboard_image_width"] = fmt.Sprintf("%d", *record.Width)
}
if record.Height != nil {
previewProperties["i18n:plugin_clipboard_image_height"] = fmt.Sprintf("%d", *record.Height)
}
if record.FileSize != nil {
previewProperties["i18n:plugin_clipboard_image_size"] = c.formatFileSize(*record.FileSize)
}
return plugin.QueryResult{
Title: record.Content, // Already formatted as "Image (WxH) (size)"
Icon: iconWoxImage,
Group: group,
GroupScore: groupScore,
Preview: plugin.WoxPreview{
PreviewType: plugin.WoxPreviewTypeImage,
PreviewData: previewWoxImage.String(),
PreviewProperties: previewProperties,
},
Score: record.Timestamp,
Actions: []plugin.QueryResultAction{
{
Name: "Copy to clipboard",
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
c.moveRecordToTop(ctx, record.ID)
// Load image from disk and copy to clipboard
if record.FilePath != "" && util.IsFileExists(record.FilePath) {
if img := c.loadImageFromFile(ctx, record.FilePath); img != nil {
clipboard.Write(&clipboard.ImageData{Image: img})
}
}
},
},
},
}
}
// moveRecordToTop updates the timestamp of a record to move it to the top
func (c *ClipboardPlugin) moveRecordToTop(ctx context.Context, id string) {
if err := c.db.UpdateTimestamp(ctx, id, util.GetSystemTimestamp()); err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to move record to top: %s", err.Error()))
}
}
// getResultGroup returns the group and score for a result
func (c *ClipboardPlugin) getResultGroup(ctx context.Context, record ClipboardRecord) (string, int64) {
if record.IsFavorite {
return "Favorites", 100
}
if util.GetSystemTimestamp()-record.Timestamp < 1000*60*60*24 {
return "Today", 90
}
if util.GetSystemTimestamp()-record.Timestamp < 1000*60*60*24*2 {
return "Yesterday", 80
}
return "History", 10
}
// getDefaultTextIcon returns the default text icon
func (c *ClipboardPlugin) getDefaultTextIcon() common.WoxImage {
return plugin.TextIcon
}
// generateImagePreviewAndIcon generates preview and icon for image records
func (c *ClipboardPlugin) generateImagePreviewAndIcon(ctx context.Context, record ClipboardRecord) (previewImg, iconImg common.WoxImage) {
// Check memory cache first
if cached, exists := c.imageCache[record.ID]; exists {
return cached.Preview, cached.Icon
}
imagePreviewFile := path.Join(util.GetLocation().GetImageCacheDirectory(), fmt.Sprintf("clipboard_%s_preview.png", record.ID))
imageIconFile := path.Join(util.GetLocation().GetImageCacheDirectory(), fmt.Sprintf("clipboard_%s_icon.png", record.ID))
if util.IsFileExists(imagePreviewFile) && util.IsFileExists(imageIconFile) {
previewImg = common.NewWoxImageAbsolutePath(imagePreviewFile)
iconImg = common.NewWoxImageAbsolutePath(imageIconFile)
// Cache the result in memory for faster access
c.imageCache[record.ID] = &ImageCacheEntry{
Preview: previewImg,
Icon: iconImg,
}
return
}
// Load original image and generate preview/icon
sourceImage := c.loadImageFromFile(ctx, record.FilePath)
if sourceImage == nil {
// Return default icons if image is not available
previewImage := c.getDefaultTextIcon()
iconImage := plugin.PreviewIcon
return previewImage, iconImage
}
compressedPreviewImg := imaging.Resize(sourceImage, 400, 0, imaging.Lanczos)
compressedIconImg := imaging.Resize(sourceImage, 40, 0, imaging.Lanczos)
// Save to disk cache first
if saveErr := imaging.Save(compressedPreviewImg, imagePreviewFile); saveErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("save clipboard image preview cache failed, err=%s", saveErr.Error()))
// Fallback to base64 if disk save fails
previewImage, err := common.NewWoxImage(compressedPreviewImg)
if err != nil {
previewImage = c.getDefaultTextIcon()
}
iconImage, iconErr := common.NewWoxImage(compressedIconImg)
if iconErr != nil {
iconImage = plugin.PreviewIcon
}
return previewImage, iconImage
}
if saveErr := imaging.Save(compressedIconImg, imageIconFile); saveErr != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("save clipboard image icon cache failed, err=%s", saveErr.Error()))
// Fallback to base64 if disk save fails
previewImage, err := common.NewWoxImage(compressedPreviewImg)
if err != nil {
previewImage = c.getDefaultTextIcon()
}
iconImage, iconErr := common.NewWoxImage(compressedIconImg)
if iconErr != nil {
iconImage = plugin.PreviewIcon
}
return previewImage, iconImage
}
// Use file paths for better performance
previewImage := common.NewWoxImageAbsolutePath(imagePreviewFile)
iconImage := common.NewWoxImageAbsolutePath(imageIconFile)
// Cache the generated images in memory for faster access
c.imageCache[record.ID] = &ImageCacheEntry{
Preview: previewImage,
Icon: iconImage,
}
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("generated image preview and icon cache, id=%s", record.ID))
return previewImage, iconImage
}
// loadImageFromFile loads an image from a file path
func (c *ClipboardPlugin) loadImageFromFile(ctx context.Context, filePath string) image.Image {
if filePath == "" || !util.IsFileExists(filePath) {
return nil
}
file, err := os.Open(filePath)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to open image file: %s", err.Error()))
return nil
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to decode image: %s", err.Error()))
return nil
}
return img
}
// isKeepTextHistory checks if text history should be kept
func (c *ClipboardPlugin) isKeepTextHistory(ctx context.Context) bool {
return c.api.GetSetting(ctx, isKeepTextHistorySettingKey) == "true"
}
// isKeepImageHistory checks if image history should be kept
func (c *ClipboardPlugin) isKeepImageHistory(ctx context.Context) bool {
return c.api.GetSetting(ctx, isKeepImageHistorySettingKey) == "true"
}
// getTextHistoryDays returns the number of days to keep text history
func (c *ClipboardPlugin) getTextHistoryDays(ctx context.Context) int {
textHistoryDaysStr := c.api.GetSetting(ctx, textHistoryDaysSettingKey)
if textHistoryDaysStr == "" {
return 90
}
if textHistoryDaysInt, err := strconv.Atoi(textHistoryDaysStr); err == nil {
return textHistoryDaysInt
}
return 90
}
// getImageHistoryDays returns the number of days to keep image history
func (c *ClipboardPlugin) getImageHistoryDays(ctx context.Context) int {
imageHistoryDaysStr := c.api.GetSetting(ctx, imageHistoryDaysSettingKey)
if imageHistoryDaysStr == "" {
return 3
}
if imageHistoryDaysInt, err := strconv.Atoi(imageHistoryDaysStr); err == nil {
return imageHistoryDaysInt
}
return 3
}
// startCleanupRoutine starts a background routine to periodically clean up expired data
func (c *ClipboardPlugin) startCleanupRoutine(ctx context.Context) {
ticker := time.NewTicker(30 * time.Minute) // Run cleanup every 30 minutes
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.performCleanup(ctx)
}
}
}
// performCleanup removes expired history entries and orphaned cache files
func (c *ClipboardPlugin) performCleanup(ctx context.Context) {
c.api.Log(ctx, plugin.LogLevelInfo, "starting clipboard cleanup routine")
if c.db == nil {
return
}
// Clean up expired database records
textDays := c.getTextHistoryDays(ctx)
imageDays := c.getImageHistoryDays(ctx)
deletedCount, err := c.db.DeleteExpired(ctx, textDays, imageDays)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to delete expired records: %s", err.Error()))
} else if deletedCount > 0 {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("deleted %d expired records", deletedCount))
}
// Clean up orphaned cache files
c.cleanupOrphanedCacheFiles(ctx)
// Clean up memory cache
c.cleanupMemoryCache(ctx)
// Log database statistics
c.logDatabaseStats(ctx)
c.api.Log(ctx, plugin.LogLevelInfo, "clipboard cleanup completed")
}
// cleanupOrphanedCacheFiles removes cache files that no longer have corresponding database records
func (c *ClipboardPlugin) cleanupOrphanedCacheFiles(ctx context.Context) {
cacheDir := util.GetLocation().GetImageCacheDirectory()
if !util.IsFileExists(cacheDir) {
return
}
// Get all current record IDs from database
recent, err := c.db.GetRecent(ctx, 10000, 0) // Get a large number to cover all records
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get records for cleanup: %s", err.Error()))
return
}
validIds := make(map[string]bool)
for _, record := range recent {
validIds[record.ID] = true
}
// Scan cache directory for clipboard files
files, err := os.ReadDir(cacheDir)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to read cache directory: %s", err.Error()))
return
}
removedCount := 0
for _, file := range files {
if strings.HasPrefix(file.Name(), "clipboard_") {
// Extract ID from filename (format: clipboard_{id}_{type}.png or clipboard_{id}.png)
parts := strings.Split(file.Name(), "_")
if len(parts) >= 2 {
id := strings.TrimSuffix(parts[1], ".png")
if len(parts) >= 3 {
id = parts[1] // For files like clipboard_{id}_{type}.png
}
if !validIds[id] {
filePath := path.Join(cacheDir, file.Name())
if removeErr := os.Remove(filePath); removeErr == nil {
removedCount++
}
}
}
}
}
if removedCount > 0 {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("removed %d orphaned cache files", removedCount))
}
}
// cleanupMemoryCache removes cache entries for records that no longer exist
func (c *ClipboardPlugin) cleanupMemoryCache(ctx context.Context) {
if len(c.imageCache) == 0 {
return
}
// Get current record IDs
recent, err := c.db.GetRecent(ctx, 1000, 0)
if err != nil {
return
}
validIds := make(map[string]bool)
for _, record := range recent {
validIds[record.ID] = true
}
// Remove cache entries for non-existent records
removedCount := 0
for id := range c.imageCache {
if !validIds[id] {
delete(c.imageCache, id)
removedCount++
}
}
if removedCount > 0 {
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("cleaned up %d memory cache entries", removedCount))
}
}
// logDatabaseStats logs current database statistics
func (c *ClipboardPlugin) logDatabaseStats(ctx context.Context) {
if c.db == nil {
return
}
stats, err := c.db.GetStats(ctx)
if err != nil {
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get database stats: %s", err.Error()))
return
}
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf(
"clipboard database stats - total: %d, favorites: %d, text: %d, images: %d",
stats["total"], stats["favorites"], stats["text"], stats["images"]))
}
// formatFileSize formats file size in bytes to human readable format
func (c *ClipboardPlugin) formatFileSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

View File

@ -0,0 +1,452 @@
package system
import (
"context"
"database/sql"
"fmt"
"os"
"path"
"strings"
"time"
"wox/util"
"wox/util/clipboard"
"github.com/disintegration/imaging"
_ "github.com/mattn/go-sqlite3"
)
// ClipboardDB handles all database operations for clipboard history
type ClipboardDB struct {
db *sql.DB
}
// ClipboardRecord represents a clipboard history record in the database
type ClipboardRecord struct {
ID string
Type string
Content string // For text content or metadata
FilePath string // For image files
IconData *string // For storing icon data (base64 or file path), nullable
Width *int // For image width, nullable
Height *int // For image height, nullable
FileSize *int64 // For file size in bytes, nullable
Timestamp int64
IsFavorite bool
CreatedAt time.Time
}
// NewClipboardDB creates a new clipboard database instance
func NewClipboardDB(ctx context.Context, pluginId string) (*ClipboardDB, error) {
dbPath := path.Join(util.GetLocation().GetPluginSettingDirectory(), pluginId+"_clipboard.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
clipboardDB := &ClipboardDB{db: db}
if err := clipboardDB.initTables(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to initialize tables: %w", err)
}
return clipboardDB, nil
}
// initTables creates the necessary tables if they don't exist
func (c *ClipboardDB) initTables(ctx context.Context) error {
createTableSQL := `
CREATE TABLE IF NOT EXISTS clipboard_history (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
content TEXT,
file_path TEXT,
icon_data TEXT,
width INTEGER,
height INTEGER,
file_size INTEGER,
timestamp INTEGER NOT NULL,
is_favorite BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON clipboard_history(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_favorite ON clipboard_history(is_favorite);
CREATE INDEX IF NOT EXISTS idx_type ON clipboard_history(type);
CREATE INDEX IF NOT EXISTS idx_content ON clipboard_history(content);
`
_, err := c.db.ExecContext(ctx, createTableSQL)
if err != nil {
return err
}
// Add new columns if they don't exist (for migration from older versions)
alterTableSQLs := []string{
`ALTER TABLE clipboard_history ADD COLUMN icon_data TEXT`,
`ALTER TABLE clipboard_history ADD COLUMN width INTEGER`,
`ALTER TABLE clipboard_history ADD COLUMN height INTEGER`,
`ALTER TABLE clipboard_history ADD COLUMN file_size INTEGER`,
}
for _, alterSQL := range alterTableSQLs {
_, alterErr := c.db.ExecContext(ctx, alterSQL)
// Ignore error if column already exists
if alterErr != nil && !strings.Contains(alterErr.Error(), "duplicate column name") {
// Log the error but don't fail initialization
util.GetLogger().Info(ctx, fmt.Sprintf("Failed to add column (likely already exists): %s", alterErr.Error()))
}
}
return nil
}
// Insert adds a new clipboard record to the database
func (c *ClipboardDB) Insert(ctx context.Context, record ClipboardRecord) error {
insertSQL := `
INSERT INTO clipboard_history (id, type, content, file_path, icon_data, width, height, file_size, timestamp, is_favorite, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := c.db.ExecContext(ctx, insertSQL,
record.ID, record.Type, record.Content, record.FilePath, record.IconData,
record.Width, record.Height, record.FileSize,
record.Timestamp, record.IsFavorite, record.CreatedAt)
return err
}
// Update modifies an existing clipboard record
func (c *ClipboardDB) Update(ctx context.Context, record ClipboardRecord) error {
updateSQL := `
UPDATE clipboard_history
SET type = ?, content = ?, file_path = ?, icon_data = ?, width = ?, height = ?, file_size = ?, timestamp = ?, is_favorite = ?
WHERE id = ?
`
_, err := c.db.ExecContext(ctx, updateSQL,
record.Type, record.Content, record.FilePath, record.IconData,
record.Width, record.Height, record.FileSize,
record.Timestamp, record.IsFavorite, record.ID)
return err
}
// SetFavorite updates the favorite status of a record
func (c *ClipboardDB) SetFavorite(ctx context.Context, id string, isFavorite bool) error {
updateSQL := `UPDATE clipboard_history SET is_favorite = ? WHERE id = ?`
_, err := c.db.ExecContext(ctx, updateSQL, isFavorite, id)
return err
}
// UpdateTimestamp updates the timestamp of a record (for moving to top)
func (c *ClipboardDB) UpdateTimestamp(ctx context.Context, id string, timestamp int64) error {
updateSQL := `UPDATE clipboard_history SET timestamp = ? WHERE id = ?`
_, err := c.db.ExecContext(ctx, updateSQL, timestamp, id)
return err
}
// GetRecent retrieves recent clipboard records with pagination
func (c *ClipboardDB) GetRecent(ctx context.Context, limit, offset int) ([]ClipboardRecord, error) {
querySQL := `
SELECT id, type, content, file_path, icon_data, width, height, file_size, timestamp, is_favorite, created_at
FROM clipboard_history
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
`
rows, err := c.db.QueryContext(ctx, querySQL, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
return c.scanRecords(rows)
}
// GetFavorites retrieves all favorite records
func (c *ClipboardDB) GetFavorites(ctx context.Context) ([]ClipboardRecord, error) {
querySQL := `
SELECT id, type, content, file_path, icon_data, width, height, file_size, timestamp, is_favorite, created_at
FROM clipboard_history
WHERE is_favorite = TRUE
ORDER BY timestamp DESC
`
rows, err := c.db.QueryContext(ctx, querySQL)
if err != nil {
return nil, err
}
defer rows.Close()
return c.scanRecords(rows)
}
// SearchText searches for text content in clipboard history
func (c *ClipboardDB) SearchText(ctx context.Context, searchTerm string, limit int) ([]ClipboardRecord, error) {
querySQL := `
SELECT id, type, content, file_path, icon_data, width, height, file_size, timestamp, is_favorite, created_at
FROM clipboard_history
WHERE type = ? AND content LIKE ?
ORDER BY timestamp DESC
LIMIT ?
`
rows, err := c.db.QueryContext(ctx, querySQL, string(clipboard.ClipboardTypeText), "%"+searchTerm+"%", limit)
if err != nil {
return nil, err
}
defer rows.Close()
return c.scanRecords(rows)
}
// GetByID retrieves a specific record by ID
func (c *ClipboardDB) GetByID(ctx context.Context, id string) (*ClipboardRecord, error) {
querySQL := `
SELECT id, type, content, file_path, timestamp, is_favorite, created_at
FROM clipboard_history
WHERE id = ?
`
row := c.db.QueryRowContext(ctx, querySQL, id)
record := &ClipboardRecord{}
err := row.Scan(&record.ID, &record.Type, &record.Content,
&record.FilePath, &record.Timestamp, &record.IsFavorite, &record.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return record, nil
}
// DeleteExpired removes records older than the specified days
func (c *ClipboardDB) DeleteExpired(ctx context.Context, textDays, imageDays int) (int64, error) {
currentTime := util.GetSystemTimestamp()
textCutoff := currentTime - int64(textDays)*24*60*60*1000
imageCutoff := currentTime - int64(imageDays)*24*60*60*1000
deleteSQL := `
DELETE FROM clipboard_history
WHERE is_favorite = FALSE AND (
(type = ? AND timestamp < ?) OR
(type = ? AND timestamp < ?)
)
`
result, err := c.db.ExecContext(ctx, deleteSQL,
string(clipboard.ClipboardTypeText), textCutoff,
string(clipboard.ClipboardTypeImage), imageCutoff)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// EnforceMaxCount ensures the total number of records doesn't exceed maxCount
func (c *ClipboardDB) EnforceMaxCount(ctx context.Context, maxCount int) (int64, error) {
// First, count total records
countSQL := `SELECT COUNT(*) FROM clipboard_history`
var totalCount int
err := c.db.QueryRowContext(ctx, countSQL).Scan(&totalCount)
if err != nil {
return 0, err
}
if totalCount <= maxCount {
return 0, nil // No need to delete anything
}
// Delete oldest non-favorite records
deleteSQL := `
DELETE FROM clipboard_history
WHERE id IN (
SELECT id FROM clipboard_history
WHERE is_favorite = FALSE
ORDER BY timestamp ASC
LIMIT ?
)
`
deleteCount := totalCount - maxCount
result, err := c.db.ExecContext(ctx, deleteSQL, deleteCount)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// GetStats returns statistics about the clipboard database
func (c *ClipboardDB) GetStats(ctx context.Context) (map[string]int, error) {
stats := make(map[string]int)
// Total count
var total int
err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM clipboard_history`).Scan(&total)
if err != nil {
return nil, err
}
stats["total"] = total
// Favorite count
var favorites int
err = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM clipboard_history WHERE is_favorite = TRUE`).Scan(&favorites)
if err != nil {
return nil, err
}
stats["favorites"] = favorites
// Text count
var textCount int
err = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM clipboard_history WHERE type = ?`, string(clipboard.ClipboardTypeText)).Scan(&textCount)
if err != nil {
return nil, err
}
stats["text"] = textCount
// Image count
var imageCount int
err = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM clipboard_history WHERE type = ?`, string(clipboard.ClipboardTypeImage)).Scan(&imageCount)
if err != nil {
return nil, err
}
stats["images"] = imageCount
return stats, nil
}
// Close closes the database connection
func (c *ClipboardDB) Close() error {
if c.db != nil {
return c.db.Close()
}
return nil
}
// ClipboardHistory represents the old clipboard history structure from plugin settings
type ClipboardHistory struct {
ID string `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Timestamp int64 `json:"timestamp"`
ImagePath string `json:"imagePath,omitempty"`
IsFavorite bool `json:"isFavorite,omitempty"`
}
// migrateLegacyItem migrates a single legacy clipboard item to the database
func (db *ClipboardDB) migrateLegacyItem(ctx context.Context, item ClipboardHistory) error {
// Check if item already exists
exists, err := db.itemExists(ctx, item.ID)
if err != nil {
return fmt.Errorf("failed to check if item exists: %w", err)
}
if exists {
util.GetLogger().Debug(ctx, fmt.Sprintf("Item %s already exists, skipping", item.ID))
return nil
}
// Convert legacy type to new type
var itemType clipboard.Type
switch item.Type {
case "text":
itemType = clipboard.ClipboardTypeText
case "image":
itemType = clipboard.ClipboardTypeImage
default:
itemType = clipboard.ClipboardTypeText
}
// Handle image migration
var imagePath string
if itemType == clipboard.ClipboardTypeImage && item.ImagePath != "" {
// Check if old image file exists
if _, err := os.Stat(item.ImagePath); err == nil {
// Copy image to new location
newImagePath, err := db.copyImageToNewLocation(ctx, item.ImagePath, item.ID)
if err != nil {
util.GetLogger().Warn(ctx, fmt.Sprintf("Failed to copy image for item %s: %v", item.ID, err))
// Continue with text content if image copy fails
itemType = clipboard.ClipboardTypeText
} else {
imagePath = newImagePath
}
} else {
util.GetLogger().Warn(ctx, fmt.Sprintf("Legacy image file not found: %s", item.ImagePath))
// Continue with text content if image file is missing
itemType = clipboard.ClipboardTypeText
}
}
// Insert into database with is_favorite set to true (since we only migrate favorites)
query := `INSERT INTO clipboard_history (id, content, type, file_path, timestamp, is_favorite) VALUES (?, ?, ?, ?, ?, ?)`
_, err = db.db.ExecContext(ctx, query, item.ID, item.Text, string(itemType), imagePath, item.Timestamp, true)
if err != nil {
return fmt.Errorf("failed to insert migrated item: %w", err)
}
util.GetLogger().Debug(ctx, fmt.Sprintf("Successfully migrated item %s", item.ID))
return nil
}
// itemExists checks if an item with the given ID already exists in the database
func (db *ClipboardDB) itemExists(ctx context.Context, id string) (bool, error) {
query := `SELECT COUNT(*) FROM clipboard_history WHERE id = ?`
var count int
err := db.db.QueryRowContext(ctx, query, id).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// copyImageToNewLocation copies an image from the old location to the new temp directory structure
func (db *ClipboardDB) copyImageToNewLocation(ctx context.Context, oldPath, itemID string) (string, error) {
// Create temp directory if it doesn't exist
tempDir := path.Join(os.TempDir(), "wox_clipboard")
if err := os.MkdirAll(tempDir, 0755); err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}
// Generate new filename
newFilename := fmt.Sprintf("%s.png", itemID)
newPath := path.Join(tempDir, newFilename)
// Open source image
srcImg, err := imaging.Open(oldPath)
if err != nil {
return "", fmt.Errorf("failed to open source image: %w", err)
}
// Save to new location
if err := imaging.Save(srcImg, newPath); err != nil {
return "", fmt.Errorf("failed to save image to new location: %w", err)
}
util.GetLogger().Debug(ctx, fmt.Sprintf("Copied image from %s to %s", oldPath, newPath))
return newPath, nil
}
// scanRecords is a helper function to scan multiple records from query results
func (c *ClipboardDB) scanRecords(rows *sql.Rows) ([]ClipboardRecord, error) {
var records []ClipboardRecord
for rows.Next() {
var record ClipboardRecord
err := rows.Scan(&record.ID, &record.Type, &record.Content,
&record.FilePath, &record.IconData, &record.Width, &record.Height, &record.FileSize,
&record.Timestamp, &record.IsFavorite, &record.CreatedAt)
if err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}

View File

@ -53,7 +53,7 @@ func (i *MenusPlugin) Init(ctx context.Context, initParams plugin.InitParams) {
func (i *MenusPlugin) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult {
icon := menusIcon
if iconImage, iconErr := getActiveWindowIcon(ctx); iconErr == nil {
if iconImage, iconErr := GetActiveWindowIcon(ctx); iconErr == nil {
icon = iconImage
}

View File

@ -171,7 +171,7 @@ func createLLMOnRefreshHandler(ctx context.Context,
}
}
func getActiveWindowIcon(ctx context.Context) (common.WoxImage, error) {
func GetActiveWindowIcon(ctx context.Context) (common.WoxImage, error) {
cacheKey := fmt.Sprintf("%s-%d", window.GetActiveWindowName(), window.GetActiveWindowPid())
if icon, ok := windowIconCache.Load(cacheKey); ok {
return icon, nil
@ -194,7 +194,7 @@ func getActiveWindowIcon(ctx context.Context) (common.WoxImage, error) {
return woxIcon, nil
}
func refreshQuery(ctx context.Context, api plugin.API, query plugin.Query) {
func RefreshQuery(ctx context.Context, api plugin.API, query plugin.Query) {
if query.Type == plugin.QueryTypeSelection {
return
}
@ -205,7 +205,7 @@ func refreshQuery(ctx context.Context, api plugin.API, query plugin.Query) {
})
}
func getPasteToActiveWindowAction(ctx context.Context, api plugin.API, actionCallback func()) (plugin.QueryResultAction, error) {
func GetPasteToActiveWindowAction(ctx context.Context, api plugin.API, actionCallback func()) (plugin.QueryResultAction, error) {
windowName := window.GetActiveWindowName()
windowIcon, windowIconErr := window.GetActiveWindowIcon()
if windowIconErr == nil && windowName != "" {

View File

@ -188,6 +188,7 @@
"plugin_clipboard_copy_characters": "Copy characters",
"plugin_clipboard_image_width": "Image width",
"plugin_clipboard_image_height": "Image height",
"plugin_clipboard_image_size": "Image size",
"plugin_clipboard_keep_text_history": "Keep text history for",
"plugin_clipboard_days": "days",
"plugin_clipboard_keep_image_history": "Keep image history for",

View File

@ -186,6 +186,7 @@
"plugin_clipboard_copy_characters": "Copiar caracteres",
"plugin_clipboard_image_width": "Largura da imagem",
"plugin_clipboard_image_height": "Altura da imagem",
"plugin_clipboard_image_size": "Tamanho da imagem",
"plugin_clipboard_keep_text_history": "Manter histórico de texto por",
"plugin_clipboard_days": "dias",
"plugin_clipboard_keep_image_history": "Manter histórico de imagens por",

View File

@ -186,6 +186,7 @@
"plugin_clipboard_copy_characters": "Копировать символы",
"plugin_clipboard_image_width": "Ширина изображения",
"plugin_clipboard_image_height": "Высота изображения",
"plugin_clipboard_image_size": "Размер изображения",
"plugin_clipboard_keep_text_history": "Сохранять историю текста на",
"plugin_clipboard_days": "дней",
"plugin_clipboard_keep_image_history": "Сохранять историю изображений на",

View File

@ -188,6 +188,7 @@
"plugin_clipboard_copy_characters": "复制字符",
"plugin_clipboard_image_width": "图片宽度",
"plugin_clipboard_image_height": "图片高度",
"plugin_clipboard_image_size": "图片大小",
"plugin_clipboard_keep_text_history": "保留文本历史记录",
"plugin_clipboard_days": "天",
"plugin_clipboard_keep_image_history": "保留图片历史记录",