mirror of https://github.com/Wox-launcher/Wox
feat(database): enhance SQLite configuration for improved concurrency
- Configure SQLite with Write-Ahead Logging (WAL) mode for better concurrency. - Set connection pool settings to optimize database access. - Execute additional PRAGMA statements for optimal performance. - Update logging to reflect new database initialization details. feat(clipboard): migrate clipboard data handling to new settings storage - Implement migration of clipboard data from legacy JSON to new database-backed storage. - Introduce methods for managing favorite clipboard items in settings. - Update clipboard plugin to utilize new favorite management functions. refactor(setting): improve error logging for settings retrieval - Replace direct logger calls with `util.GetLogger()` for consistency across settings management.
This commit is contained in:
parent
734d0f0830
commit
5be879167f
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"wox/util"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
|
@ -38,14 +39,49 @@ type Oplog struct {
|
|||
func Init(ctx context.Context) error {
|
||||
dbPath := filepath.Join(util.GetLocation().GetUserDataDirectory(), "wox.db")
|
||||
|
||||
// Configure SQLite with proper concurrency settings
|
||||
dsn := dbPath + "?" +
|
||||
"_journal_mode=WAL&" + // Enable WAL mode for better concurrency
|
||||
"_synchronous=NORMAL&" + // Balance between safety and performance
|
||||
"_cache_size=1000&" + // Set cache size
|
||||
"_foreign_keys=true&" + // Enable foreign key constraints
|
||||
"_busy_timeout=5000" // Set busy timeout to 5 seconds
|
||||
|
||||
var err error
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
db, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Configure connection pool for better concurrency handling
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings
|
||||
sqlDB.SetMaxOpenConns(10) // Maximum number of open connections
|
||||
sqlDB.SetMaxIdleConns(5) // Maximum number of idle connections
|
||||
sqlDB.SetConnMaxLifetime(time.Hour) // Maximum lifetime of a connection
|
||||
|
||||
// Execute additional PRAGMA statements for optimal concurrency
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL", // Ensure WAL mode is enabled
|
||||
"PRAGMA synchronous=NORMAL", // Balance safety and performance
|
||||
"PRAGMA cache_size=1000", // Set cache size
|
||||
"PRAGMA foreign_keys=ON", // Enable foreign key constraints
|
||||
"PRAGMA temp_store=memory", // Store temporary tables in memory
|
||||
"PRAGMA mmap_size=268435456", // Set memory-mapped I/O size (256MB)
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if _, err := sqlDB.Exec(pragma); err != nil {
|
||||
util.GetLogger().Warn(ctx, fmt.Sprintf("failed to execute pragma %s: %v", pragma, err))
|
||||
}
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(
|
||||
&WoxSetting{},
|
||||
&PluginSetting{},
|
||||
|
@ -55,7 +91,7 @@ func Init(ctx context.Context) error {
|
|||
return fmt.Errorf("failed to migrate database schema: %w", err)
|
||||
}
|
||||
|
||||
util.GetLogger().Info(ctx, fmt.Sprintf("database initialized at %s", dbPath))
|
||||
util.GetLogger().Info(ctx, fmt.Sprintf("database initialized at %s with WAL mode enabled", dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -2,17 +2,22 @@ package migration
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
"wox/common"
|
||||
"wox/database"
|
||||
"wox/i18n"
|
||||
"wox/setting"
|
||||
"wox/util"
|
||||
"wox/util/locale"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This file contains the logic for a one-time migration from the old JSON-based settings
|
||||
|
@ -332,6 +337,235 @@ func Run(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Migrate clipboard data
|
||||
if err := migrateClipboardData(ctx, tx); err != nil {
|
||||
util.GetLogger().Warn(ctx, fmt.Sprintf("failed to migrate clipboard data: %v", err))
|
||||
}
|
||||
|
||||
util.GetLogger().Info(ctx, "Successfully migrated old configuration to the new database.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clipboard migration structures
|
||||
type oldClipboardHistory 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"`
|
||||
}
|
||||
|
||||
type favoriteClipboardItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
FilePath string `json:"filePath,omitempty"`
|
||||
IconData *string `json:"iconData,omitempty"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
FileSize *int64 `json:"fileSize,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type clipboardRecord struct {
|
||||
ID string
|
||||
Type string
|
||||
Content string
|
||||
FilePath string
|
||||
IconData *string
|
||||
Width *int
|
||||
Height *int
|
||||
FileSize *int64
|
||||
Timestamp int64
|
||||
IsFavorite bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// migrateClipboardData migrates clipboard data from old JSON settings and database to new settings storage
|
||||
func migrateClipboardData(ctx context.Context, tx *gorm.DB) error {
|
||||
clipboardPluginId := "5f815d98-27f5-488d-a756-c317ea39935b"
|
||||
pluginSettingStore := setting.NewPluginSettingStore(tx, clipboardPluginId)
|
||||
|
||||
var allFavoritesToMigrate []favoriteClipboardItem
|
||||
|
||||
// 1. Migrate from legacy JSON settings
|
||||
var historyJson string
|
||||
err := pluginSettingStore.Get("history", &historyJson)
|
||||
if err == nil && historyJson != "" {
|
||||
var history []oldClipboardHistory
|
||||
unmarshalErr := json.Unmarshal([]byte(historyJson), &history)
|
||||
if unmarshalErr != nil {
|
||||
// Log warning if logger is available
|
||||
if logger := util.GetLogger(); logger != nil {
|
||||
logger.Warn(ctx, fmt.Sprintf("failed to unmarshal legacy clipboard history: %v", unmarshalErr))
|
||||
}
|
||||
} else {
|
||||
// Migrate legacy data from JSON settings
|
||||
for _, item := range history {
|
||||
if item.IsFavorite {
|
||||
// Convert to new favorite format
|
||||
favoriteItem := favoriteClipboardItem{
|
||||
ID: item.ID,
|
||||
Type: item.Type,
|
||||
Content: item.Text,
|
||||
FilePath: item.ImagePath,
|
||||
Timestamp: item.Timestamp,
|
||||
CreatedAt: item.Timestamp / 1000, // Convert to seconds
|
||||
}
|
||||
allFavoritesToMigrate = append(allFavoritesToMigrate, favoriteItem)
|
||||
}
|
||||
}
|
||||
if logger := util.GetLogger(); logger != nil {
|
||||
logger.Info(ctx, fmt.Sprintf("found %d favorite items in legacy JSON settings", len(allFavoritesToMigrate)))
|
||||
}
|
||||
}
|
||||
// Clear the old history setting
|
||||
pluginSettingStore.Set("history", "")
|
||||
}
|
||||
|
||||
// 2. Migrate existing favorite items from database
|
||||
dbFavorites, err := getFavoritesFromDatabase(ctx, clipboardPluginId)
|
||||
if err != nil {
|
||||
if logger := util.GetLogger(); logger != nil {
|
||||
logger.Warn(ctx, fmt.Sprintf("failed to get database favorites for migration: %v", err))
|
||||
}
|
||||
} else {
|
||||
for _, record := range dbFavorites {
|
||||
// Convert database record to favorite format
|
||||
favoriteItem := favoriteClipboardItem{
|
||||
ID: record.ID,
|
||||
Type: record.Type,
|
||||
Content: record.Content,
|
||||
FilePath: record.FilePath,
|
||||
IconData: record.IconData,
|
||||
Width: record.Width,
|
||||
Height: record.Height,
|
||||
FileSize: record.FileSize,
|
||||
Timestamp: record.Timestamp,
|
||||
CreatedAt: record.CreatedAt.Unix(),
|
||||
}
|
||||
allFavoritesToMigrate = append(allFavoritesToMigrate, favoriteItem)
|
||||
}
|
||||
|
||||
if logger := util.GetLogger(); logger != nil {
|
||||
logger.Info(ctx, fmt.Sprintf("found %d favorite items in database", len(dbFavorites)))
|
||||
}
|
||||
|
||||
// Remove favorite items from database after migration
|
||||
if len(dbFavorites) > 0 {
|
||||
deletedCount, deleteErr := deleteFavoritesFromDatabase(ctx, clipboardPluginId)
|
||||
if deleteErr != nil {
|
||||
if logger := util.GetLogger(); logger != nil {
|
||||
logger.Warn(ctx, fmt.Sprintf("failed to delete favorites from database: %v", deleteErr))
|
||||
}
|
||||
} else {
|
||||
if logger := util.GetLogger(); logger != nil {
|
||||
logger.Info(ctx, fmt.Sprintf("deleted %d favorite items from database after migration", deletedCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save all favorites to new settings storage
|
||||
if len(allFavoritesToMigrate) > 0 {
|
||||
favoritesJson, err := json.Marshal(allFavoritesToMigrate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal favorites: %w", err)
|
||||
}
|
||||
|
||||
if err := pluginSettingStore.Set("favorites", string(favoritesJson)); err != nil {
|
||||
return fmt.Errorf("failed to save migrated favorites: %w", err)
|
||||
}
|
||||
|
||||
if logger := util.GetLogger(); logger != nil {
|
||||
logger.Info(ctx, fmt.Sprintf("migrated %d favorite clipboard items to new storage", len(allFavoritesToMigrate)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFavoritesFromDatabase retrieves favorite clipboard items from the clipboard database
|
||||
func getFavoritesFromDatabase(ctx context.Context, pluginId string) ([]clipboardRecord, error) {
|
||||
dbPath := path.Join(util.GetLocation().GetPluginSettingDirectory(), pluginId+"_clipboard.db")
|
||||
|
||||
// Check if database file exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []clipboardRecord{}, nil // No database file, no favorites to migrate
|
||||
}
|
||||
|
||||
// Configure SQLite connection
|
||||
dsn := dbPath + "?" +
|
||||
"_journal_mode=WAL&" +
|
||||
"_synchronous=NORMAL&" +
|
||||
"_cache_size=1000&" +
|
||||
"_foreign_keys=true&" +
|
||||
"_busy_timeout=5000"
|
||||
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open clipboard database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
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 := db.QueryContext(ctx, querySQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query favorites: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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, fmt.Errorf("failed to scan record: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
// deleteFavoritesFromDatabase removes all favorite items from the clipboard database
|
||||
func deleteFavoritesFromDatabase(ctx context.Context, pluginId string) (int64, error) {
|
||||
dbPath := path.Join(util.GetLocation().GetPluginSettingDirectory(), pluginId+"_clipboard.db")
|
||||
|
||||
// Check if database file exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return 0, nil // No database file, nothing to delete
|
||||
}
|
||||
|
||||
// Configure SQLite connection
|
||||
dsn := dbPath + "?" +
|
||||
"_journal_mode=WAL&" +
|
||||
"_synchronous=NORMAL&" +
|
||||
"_cache_size=1000&" +
|
||||
"_foreign_keys=true&" +
|
||||
"_busy_timeout=5000"
|
||||
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to open clipboard database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
deleteSQL := `DELETE FROM clipboard_history WHERE is_favorite = TRUE`
|
||||
result, err := db.ExecContext(ctx, deleteSQL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete favorites: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ var imageHistoryDaysSettingKey = "image_history_days"
|
|||
var primaryActionSettingKey = "primary_action"
|
||||
var primaryActionValueCopy = "copy"
|
||||
var primaryActionValuePaste = "paste"
|
||||
var favoritesSettingKey = "favorites"
|
||||
|
||||
func init() {
|
||||
plugin.AllSystemPlugin = append(plugin.AllSystemPlugin, &ClipboardPlugin{
|
||||
|
@ -44,9 +45,38 @@ type ImageCacheEntry struct {
|
|||
Icon common.WoxImage
|
||||
}
|
||||
|
||||
// FavoriteClipboardItem represents a favorite clipboard item stored in settings
|
||||
type FavoriteClipboardItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
FilePath string `json:"filePath,omitempty"`
|
||||
IconData *string `json:"iconData,omitempty"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
FileSize *int64 `json:"fileSize,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ClipboardDBInterface defines the interface for clipboard database operations
|
||||
type ClipboardDBInterface interface {
|
||||
Insert(ctx context.Context, record ClipboardRecord) error
|
||||
Update(ctx context.Context, record ClipboardRecord) error
|
||||
UpdateTimestamp(ctx context.Context, id string, timestamp int64) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
GetRecent(ctx context.Context, limit, offset int) ([]ClipboardRecord, error)
|
||||
SearchText(ctx context.Context, searchTerm string, limit int) ([]ClipboardRecord, error)
|
||||
GetByID(ctx context.Context, id string) (*ClipboardRecord, error)
|
||||
DeleteExpired(ctx context.Context, textDays, imageDays int) (int64, error)
|
||||
EnforceMaxCount(ctx context.Context, maxCount int) (int64, error)
|
||||
GetStats(ctx context.Context) (map[string]int, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type ClipboardPlugin struct {
|
||||
api plugin.API
|
||||
db *ClipboardDB
|
||||
db ClipboardDBInterface
|
||||
maxHistoryCount int
|
||||
// Cache for generated preview and icon images to avoid regeneration
|
||||
imageCache map[string]*ImageCacheEntry
|
||||
|
@ -161,25 +191,8 @@ func (c *ClipboardPlugin) Init(ctx context.Context, initParams plugin.InitParams
|
|||
}
|
||||
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)
|
||||
}
|
||||
// Migration is now handled by the central migrator during app startup
|
||||
// No need for plugin-specific migration code here
|
||||
|
||||
// Register unload callback to close database connection
|
||||
c.api.OnUnload(ctx, func() {
|
||||
|
@ -225,7 +238,7 @@ func (c *ClipboardPlugin) Init(ctx context.Context, initParams plugin.InitParams
|
|||
return
|
||||
}
|
||||
|
||||
// Create new record
|
||||
// Create new record (always non-favorite initially)
|
||||
record := ClipboardRecord{
|
||||
ID: uuid.NewString(),
|
||||
Type: string(data.GetType()),
|
||||
|
@ -272,7 +285,7 @@ func (c *ClipboardPlugin) Init(ctx context.Context, initParams plugin.InitParams
|
|||
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("saved clipboard image to disk: %s", imageFilePath))
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
// Insert into database (non-favorite items only)
|
||||
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
|
||||
|
@ -298,53 +311,70 @@ func (c *ClipboardPlugin) Query(ctx context.Context, query plugin.Query) []plugi
|
|||
}
|
||||
|
||||
if query.Command == "fav" {
|
||||
// Get favorite records from database
|
||||
favorites, err := c.db.GetFavorites(ctx)
|
||||
// Get favorite records from settings
|
||||
favorites, err := c.getFavoriteItems(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 {
|
||||
for _, favoriteItem := range favorites {
|
||||
record := c.convertFavoriteToRecord(favoriteItem)
|
||||
results = append(results, c.convertRecordToResult(ctx, record, query))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
if query.Search == "" {
|
||||
// Get favorites first
|
||||
favorites, err := c.db.GetFavorites(ctx)
|
||||
// Get favorites first from settings
|
||||
favorites, err := c.getFavoriteItems(ctx)
|
||||
if err != nil {
|
||||
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get favorites: %s", err.Error()))
|
||||
} else {
|
||||
for _, record := range favorites {
|
||||
for _, favoriteItem := range favorites {
|
||||
record := c.convertFavoriteToRecord(favoriteItem)
|
||||
results = append(results, c.convertRecordToResult(ctx, record, query))
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent non-favorite records
|
||||
// Get recent non-favorite records from database
|
||||
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 {
|
||||
// All records in database are non-favorite now
|
||||
results = append(results, c.convertRecordToResult(ctx, record, query))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Search in text content
|
||||
var allResults []ClipboardRecord
|
||||
|
||||
// Search in favorites from settings
|
||||
favorites, err := c.getFavoriteItems(ctx)
|
||||
if err == nil {
|
||||
for _, favoriteItem := range favorites {
|
||||
if favoriteItem.Type == string(clipboard.ClipboardTypeText) &&
|
||||
strings.Contains(strings.ToLower(favoriteItem.Content), strings.ToLower(query.Search)) {
|
||||
record := c.convertFavoriteToRecord(favoriteItem)
|
||||
allResults = append(allResults, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in database records
|
||||
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
|
||||
} else {
|
||||
allResults = append(allResults, searchResults...)
|
||||
}
|
||||
|
||||
for _, record := range searchResults {
|
||||
for _, record := range allResults {
|
||||
results = append(results, c.convertRecordToResult(ctx, record, query))
|
||||
}
|
||||
|
||||
|
@ -353,22 +383,52 @@ func (c *ClipboardPlugin) Query(ctx context.Context, query plugin.Query) []plugi
|
|||
|
||||
// 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 {
|
||||
// Check most recent record from database
|
||||
recent, err := c.db.GetRecent(ctx, 1, 0)
|
||||
if err != nil || len(recent) == 0 {
|
||||
var lastRecord *ClipboardRecord
|
||||
if err == nil && len(recent) > 0 {
|
||||
lastRecord = &recent[0]
|
||||
}
|
||||
|
||||
// Check most recent favorite from settings
|
||||
favorites, err := c.getFavoriteItems(ctx)
|
||||
var lastFavorite *FavoriteClipboardItem
|
||||
if err == nil && len(favorites) > 0 {
|
||||
// Find the most recent favorite by timestamp
|
||||
for i := range favorites {
|
||||
if lastFavorite == nil || favorites[i].Timestamp > lastFavorite.Timestamp {
|
||||
lastFavorite = &favorites[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which is more recent
|
||||
var mostRecentRecord *ClipboardRecord
|
||||
if lastRecord != nil && lastFavorite != nil {
|
||||
if lastRecord.Timestamp > lastFavorite.Timestamp {
|
||||
mostRecentRecord = lastRecord
|
||||
} else {
|
||||
favoriteRecord := c.convertFavoriteToRecord(*lastFavorite)
|
||||
mostRecentRecord = &favoriteRecord
|
||||
}
|
||||
} else if lastRecord != nil {
|
||||
mostRecentRecord = lastRecord
|
||||
} else if lastFavorite != nil {
|
||||
favoriteRecord := c.convertFavoriteToRecord(*lastFavorite)
|
||||
mostRecentRecord = &favoriteRecord
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastRecord := recent[0]
|
||||
|
||||
if lastRecord.Type != string(data.GetType()) {
|
||||
if mostRecentRecord.Type != string(data.GetType()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if data.GetType() == clipboard.ClipboardTypeText {
|
||||
textData := data.(*clipboard.TextData)
|
||||
if lastRecord.Content == textData.Text {
|
||||
if mostRecentRecord.Content == textData.Text {
|
||||
// Update timestamp of existing record
|
||||
c.db.UpdateTimestamp(ctx, lastRecord.ID, util.GetSystemTimestamp())
|
||||
c.updateRecordTimestamp(ctx, mostRecentRecord, util.GetSystemTimestamp())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -376,9 +436,9 @@ func (c *ClipboardPlugin) isDuplicateContent(ctx context.Context, data clipboard
|
|||
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 {
|
||||
if mostRecentRecord.Content == currentSize {
|
||||
// Update timestamp of existing record
|
||||
c.db.UpdateTimestamp(ctx, lastRecord.ID, util.GetSystemTimestamp())
|
||||
c.updateRecordTimestamp(ctx, mostRecentRecord, util.GetSystemTimestamp())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -430,7 +490,7 @@ func (c *ClipboardPlugin) convertTextRecord(ctx context.Context, record Clipboar
|
|||
Icon: plugin.AddToFavIcon,
|
||||
PreventHideAfterAction: true,
|
||||
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
|
||||
if err := c.db.SetFavorite(ctx, record.ID, true); err != nil {
|
||||
if err := c.markAsFavorite(ctx, record); 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))
|
||||
|
@ -444,7 +504,7 @@ func (c *ClipboardPlugin) convertTextRecord(ctx context.Context, record Clipboar
|
|||
Icon: plugin.RemoveFromFavIcon,
|
||||
PreventHideAfterAction: true,
|
||||
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
|
||||
if err := c.db.SetFavorite(ctx, record.ID, false); err != nil {
|
||||
if err := c.cancelFavorite(ctx, record.ID); 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))
|
||||
|
@ -852,3 +912,174 @@ func (c *ClipboardPlugin) formatFileSize(bytes int64) string {
|
|||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// getFavoriteItems retrieves favorite items from settings
|
||||
func (c *ClipboardPlugin) getFavoriteItems(ctx context.Context) ([]FavoriteClipboardItem, error) {
|
||||
favoritesJson := c.api.GetSetting(ctx, favoritesSettingKey)
|
||||
if favoritesJson == "" {
|
||||
return []FavoriteClipboardItem{}, nil
|
||||
}
|
||||
|
||||
var favorites []FavoriteClipboardItem
|
||||
if err := json.Unmarshal([]byte(favoritesJson), &favorites); err != nil {
|
||||
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to unmarshal favorites: %s", err.Error()))
|
||||
return []FavoriteClipboardItem{}, nil
|
||||
}
|
||||
|
||||
return favorites, nil
|
||||
}
|
||||
|
||||
// saveFavoriteItems saves favorite items to settings
|
||||
func (c *ClipboardPlugin) saveFavoriteItems(ctx context.Context, favorites []FavoriteClipboardItem) error {
|
||||
favoritesJson, err := json.Marshal(favorites)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal favorites: %w", err)
|
||||
}
|
||||
|
||||
c.api.SaveSetting(ctx, favoritesSettingKey, string(favoritesJson), false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addToFavorites adds an item to favorites settings
|
||||
func (c *ClipboardPlugin) addToFavorites(ctx context.Context, record ClipboardRecord) error {
|
||||
favorites, err := c.getFavoriteItems(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
for _, fav := range favorites {
|
||||
if fav.ID == record.ID {
|
||||
return nil // Already exists
|
||||
}
|
||||
}
|
||||
|
||||
// Convert ClipboardRecord to FavoriteClipboardItem
|
||||
favoriteItem := FavoriteClipboardItem{
|
||||
ID: record.ID,
|
||||
Type: record.Type,
|
||||
Content: record.Content,
|
||||
FilePath: record.FilePath,
|
||||
IconData: record.IconData,
|
||||
Width: record.Width,
|
||||
Height: record.Height,
|
||||
FileSize: record.FileSize,
|
||||
Timestamp: record.Timestamp,
|
||||
CreatedAt: record.CreatedAt.Unix(),
|
||||
}
|
||||
|
||||
favorites = append(favorites, favoriteItem)
|
||||
return c.saveFavoriteItems(ctx, favorites)
|
||||
}
|
||||
|
||||
// removeFromFavorites removes an item from favorites settings
|
||||
func (c *ClipboardPlugin) removeFromFavorites(ctx context.Context, id string) error {
|
||||
favorites, err := c.getFavoriteItems(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find and remove the item
|
||||
for i, fav := range favorites {
|
||||
if fav.ID == id {
|
||||
favorites = append(favorites[:i], favorites[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c.saveFavoriteItems(ctx, favorites)
|
||||
}
|
||||
|
||||
// convertFavoriteToRecord converts FavoriteClipboardItem to ClipboardRecord
|
||||
func (c *ClipboardPlugin) convertFavoriteToRecord(item FavoriteClipboardItem) ClipboardRecord {
|
||||
return ClipboardRecord{
|
||||
ID: item.ID,
|
||||
Type: item.Type,
|
||||
Content: item.Content,
|
||||
FilePath: item.FilePath,
|
||||
IconData: item.IconData,
|
||||
Width: item.Width,
|
||||
Height: item.Height,
|
||||
FileSize: item.FileSize,
|
||||
Timestamp: item.Timestamp,
|
||||
IsFavorite: true,
|
||||
CreatedAt: time.Unix(item.CreatedAt, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// markAsFavorite moves an item from database to favorites settings
|
||||
func (c *ClipboardPlugin) markAsFavorite(ctx context.Context, record ClipboardRecord) error {
|
||||
// Add to favorites settings
|
||||
if err := c.addToFavorites(ctx, record); err != nil {
|
||||
return fmt.Errorf("failed to add to favorites: %w", err)
|
||||
}
|
||||
|
||||
// Remove from database if it exists there
|
||||
if err := c.db.Delete(ctx, record.ID); err != nil {
|
||||
c.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("failed to remove from database (may not exist): %s", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelFavorite moves an item from favorites settings to database
|
||||
func (c *ClipboardPlugin) cancelFavorite(ctx context.Context, id string) error {
|
||||
// Get the favorite item first
|
||||
favorites, err := c.getFavoriteItems(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get favorites: %w", err)
|
||||
}
|
||||
|
||||
var favoriteItem *FavoriteClipboardItem
|
||||
for _, fav := range favorites {
|
||||
if fav.ID == id {
|
||||
favoriteItem = &fav
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if favoriteItem == nil {
|
||||
return fmt.Errorf("favorite item not found: %s", id)
|
||||
}
|
||||
|
||||
// Convert to ClipboardRecord and add to database
|
||||
record := c.convertFavoriteToRecord(*favoriteItem)
|
||||
record.IsFavorite = false // Mark as non-favorite
|
||||
if err := c.db.Insert(ctx, record); err != nil {
|
||||
return fmt.Errorf("failed to insert to database: %w", err)
|
||||
}
|
||||
|
||||
// Remove from favorites settings
|
||||
if err := c.removeFromFavorites(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to remove from favorites: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRecordTimestamp updates the timestamp of a record in the appropriate storage
|
||||
func (c *ClipboardPlugin) updateRecordTimestamp(ctx context.Context, record *ClipboardRecord, timestamp int64) {
|
||||
if record.IsFavorite {
|
||||
// Update in favorites settings
|
||||
favorites, err := c.getFavoriteItems(ctx)
|
||||
if err != nil {
|
||||
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get favorites for timestamp update: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
for i := range favorites {
|
||||
if favorites[i].ID == record.ID {
|
||||
favorites[i].Timestamp = timestamp
|
||||
if err := c.saveFavoriteItems(ctx, favorites); err != nil {
|
||||
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to save favorites after timestamp update: %s", err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Update in database
|
||||
if err := c.db.UpdateTimestamp(ctx, record.ID, timestamp); err != nil {
|
||||
c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to update timestamp in database: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,14 +4,12 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
"wox/util"
|
||||
"wox/util/clipboard"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
|
@ -38,17 +36,48 @@ type ClipboardRecord struct {
|
|||
// 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)
|
||||
|
||||
// Configure SQLite with proper concurrency settings
|
||||
dsn := dbPath + "?" +
|
||||
"_journal_mode=WAL&" + // Enable WAL mode for better concurrency
|
||||
"_synchronous=NORMAL&" + // Balance between safety and performance
|
||||
"_cache_size=1000&" + // Set cache size
|
||||
"_foreign_keys=true&" + // Enable foreign key constraints
|
||||
"_busy_timeout=5000" // Set busy timeout to 5 seconds
|
||||
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings for better concurrency
|
||||
db.SetMaxOpenConns(10) // Maximum number of open connections
|
||||
db.SetMaxIdleConns(5) // Maximum number of idle connections
|
||||
db.SetConnMaxLifetime(time.Hour) // Maximum lifetime of a connection
|
||||
|
||||
// Execute additional PRAGMA statements for optimal concurrency
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL", // Ensure WAL mode is enabled
|
||||
"PRAGMA synchronous=NORMAL", // Balance safety and performance
|
||||
"PRAGMA cache_size=1000", // Set cache size
|
||||
"PRAGMA foreign_keys=ON", // Enable foreign key constraints
|
||||
"PRAGMA temp_store=memory", // Store temporary tables in memory
|
||||
"PRAGMA mmap_size=268435456", // Set memory-mapped I/O size (256MB)
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
util.GetLogger().Warn(ctx, fmt.Sprintf("failed to execute pragma %s: %v", pragma, err))
|
||||
}
|
||||
}
|
||||
|
||||
clipboardDB := &ClipboardDB{db: db}
|
||||
if err := clipboardDB.initTables(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize tables: %w", err)
|
||||
}
|
||||
|
||||
util.GetLogger().Info(ctx, fmt.Sprintf("clipboard database initialized at %s with WAL mode enabled", dbPath))
|
||||
return clipboardDB, nil
|
||||
}
|
||||
|
||||
|
@ -131,13 +160,6 @@ func (c *ClipboardDB) Update(ctx context.Context, record ClipboardRecord) error
|
|||
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 = ?`
|
||||
|
@ -145,6 +167,13 @@ func (c *ClipboardDB) UpdateTimestamp(ctx context.Context, id string, timestamp
|
|||
return err
|
||||
}
|
||||
|
||||
// Delete removes a record by ID
|
||||
func (c *ClipboardDB) Delete(ctx context.Context, id string) error {
|
||||
deleteSQL := `DELETE FROM clipboard_history WHERE id = ?`
|
||||
_, err := c.db.ExecContext(ctx, deleteSQL, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRecent retrieves recent clipboard records with pagination
|
||||
func (c *ClipboardDB) GetRecent(ctx context.Context, limit, offset int) ([]ClipboardRecord, error) {
|
||||
querySQL := `
|
||||
|
@ -163,24 +192,6 @@ func (c *ClipboardDB) GetRecent(ctx context.Context, limit, offset int) ([]Clipb
|
|||
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 := `
|
||||
|
@ -340,99 +351,6 @@ type ClipboardHistory struct {
|
|||
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
|
||||
|
|
|
@ -37,7 +37,7 @@ func NewWoxSettingStore(db *gorm.DB) *WoxSettingStore {
|
|||
func (s *WoxSettingStore) Get(key string, target interface{}) error {
|
||||
var setting database.WoxSetting
|
||||
if err := s.db.Where("key = ?", key).First(&setting).Error; err != nil {
|
||||
logger.Error(context.Background(), fmt.Sprintf("Failed to read wox setting %s: %v", key, err))
|
||||
util.GetLogger().Error(context.Background(), fmt.Sprintf("Failed to read wox setting %s: %v", key, err))
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -90,7 +90,7 @@ func NewPluginSettingStore(db *gorm.DB, pluginId string) *PluginSettingStore {
|
|||
func (s *PluginSettingStore) Get(key string, target interface{}) error {
|
||||
var setting database.PluginSetting
|
||||
if err := s.db.Where("plugin_id = ? AND key = ?", s.pluginId, key).First(&setting).Error; err != nil {
|
||||
logger.Error(context.Background(), fmt.Sprintf("Failed to read plugin setting %s.%s: %v", s.pluginId, key, err))
|
||||
util.GetLogger().Error(context.Background(), fmt.Sprintf("Failed to read plugin setting %s.%s: %v", s.pluginId, key, err))
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue