mirror of https://github.com/Wox-launcher/Wox
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:
parent
f219a2efce
commit
d2da6beea6
|
@ -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
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -34,6 +34,8 @@ import (
|
|||
_ "wox/plugin/system/converter"
|
||||
|
||||
_ "wox/plugin/system/file"
|
||||
|
||||
_ "wox/plugin/system/clipboard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Сохранять историю изображений на",
|
||||
|
|
|
@ -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": "保留图片历史记录",
|
||||
|
|
Loading…
Reference in New Issue