Use SQLite instead of JSON storage

This commit is contained in:
2025-06-29 23:41:34 +08:00
parent 6f8775472d
commit 70d88dabba
25 changed files with 807 additions and 636 deletions

View File

@@ -2,270 +2,337 @@ package services
import (
"context"
"os"
"database/sql"
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
_ "modernc.org/sqlite" // SQLite driver
)
// DocumentService 提供文档管理功能
// SQL constants for database operations
const (
dbName = "voidraft.db"
// Database schema (simplified single table with auto-increment ID)
sqlCreateDocumentsTable = `
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT DEFAULT '∞∞∞text-a',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`
// Performance optimization indexes
sqlCreateIndexUpdatedAt = `CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC)`
sqlCreateIndexTitle = `CREATE INDEX IF NOT EXISTS idx_documents_title ON documents(title)`
// SQLite performance optimization settings
sqlOptimizationSettings = `
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -64000;
PRAGMA temp_store = MEMORY;
PRAGMA foreign_keys = ON;`
// Document operations
sqlGetDocumentByID = `
SELECT id, title, content, created_at, updated_at
FROM documents
WHERE id = ?`
sqlInsertDocument = `
INSERT INTO documents (title, content, created_at, updated_at)
VALUES (?, ?, ?, ?)`
sqlUpdateDocument = `
UPDATE documents
SET title = ?, content = ?, updated_at = ?
WHERE id = ?`
sqlUpdateDocumentContent = `
UPDATE documents
SET content = ?, updated_at = ?
WHERE id = ?`
sqlUpdateDocumentTitle = `
UPDATE documents
SET title = ?, updated_at = ?
WHERE id = ?`
sqlDeleteDocument = `
DELETE FROM documents WHERE id = ?`
sqlListAllDocumentsMeta = `
SELECT id, title, created_at, updated_at
FROM documents
ORDER BY updated_at DESC`
sqlGetFirstDocumentID = `
SELECT id FROM documents ORDER BY id LIMIT 1`
)
// DocumentService provides document management functionality
type DocumentService struct {
configService *ConfigService
logger *log.LoggerService
docStore *Store[models.Document]
// 文档状态管理
mu sync.RWMutex
document *models.Document
// 自动保存管理
db *sql.DB
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
isDirty atomic.Bool
lastSaveTime atomic.Int64 // unix timestamp
saveScheduler chan struct{}
// 初始化控制
initOnce sync.Once
}
// NewDocumentService 创建文档服务
// NewDocumentService creates a new document service
func NewDocumentService(configService *ConfigService, logger *log.LoggerService) *DocumentService {
if logger == nil {
logger = log.New()
}
ctx, cancel := context.WithCancel(context.Background())
return &DocumentService{
configService: configService,
logger: logger,
ctx: ctx,
cancel: cancel,
saveScheduler: make(chan struct{}, 1),
}
}
// Initialize 初始化服务
func (ds *DocumentService) Initialize() error {
var initErr error
ds.initOnce.Do(func() {
initErr = ds.doInitialize()
})
return initErr
// OnStartup initializes the service when the application starts
func (ds *DocumentService) OnStartup(ctx context.Context, _ application.ServiceOptions) error {
ds.ctx = ctx
return ds.initDatabase()
}
// doInitialize 执行初始化
func (ds *DocumentService) doInitialize() error {
if err := ds.initStore(); err != nil {
return err
}
ds.loadDocument()
go ds.autoSaveWorker()
return nil
}
// initStore 初始化存储
func (ds *DocumentService) initStore() error {
docPath, err := ds.getDocumentPath()
// initDatabase initializes the SQLite database
func (ds *DocumentService) initDatabase() error {
dbPath, err := ds.getDatabasePath()
if err != nil {
return err
return fmt.Errorf("failed to get database path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(docPath), 0755); err != nil {
return err
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
ds.docStore = NewStore[models.Document](StoreOption{
FilePath: docPath,
AutoSave: false,
Logger: ds.logger,
})
ds.db = db
// Apply optimization settings
if _, err := db.Exec(sqlOptimizationSettings); err != nil {
return fmt.Errorf("failed to apply optimization settings: %w", err)
}
// Create table
if _, err := db.Exec(sqlCreateDocumentsTable); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
// Create indexes
if err := ds.createIndexes(); err != nil {
return fmt.Errorf("failed to create indexes: %w", err)
}
// Ensure default document exists
if err := ds.ensureDefaultDocument(); err != nil {
return fmt.Errorf("failed to ensure default document: %w", err)
}
return nil
}
// getDocumentPath 获取文档路径
func (ds *DocumentService) getDocumentPath() (string, error) {
// getDatabasePath gets the database file path
func (ds *DocumentService) getDatabasePath() (string, error) {
config, err := ds.configService.GetConfig()
if err != nil {
return "", err
}
return filepath.Join(config.General.DataPath, "docs", "default.json"), nil
return filepath.Join(config.General.DataPath, dbName), nil
}
// loadDocument 加载文档
func (ds *DocumentService) loadDocument() {
ds.mu.Lock()
defer ds.mu.Unlock()
doc := ds.docStore.Get()
if doc.Meta.ID == "" {
ds.document = models.NewDefaultDocument()
ds.docStore.Set(*ds.document)
} else {
ds.document = &doc
// createIndexes creates database indexes
func (ds *DocumentService) createIndexes() error {
indexes := []string{
sqlCreateIndexUpdatedAt,
sqlCreateIndexTitle,
}
ds.lastSaveTime.Store(time.Now().Unix())
}
// GetActiveDocument 获取活动文档
func (ds *DocumentService) GetActiveDocument() (*models.Document, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
if ds.document == nil {
return nil, nil
}
docCopy := *ds.document
return &docCopy, nil
}
// UpdateActiveDocumentContent 更新文档内容
func (ds *DocumentService) UpdateActiveDocumentContent(content string) error {
ds.mu.Lock()
defer ds.mu.Unlock()
if ds.document != nil && ds.document.Content != content {
ds.document.Content = content
ds.markDirty()
}
return nil
}
// markDirty 标记为脏数据并触发自动保存
func (ds *DocumentService) markDirty() {
if ds.isDirty.CompareAndSwap(false, true) {
select {
case ds.saveScheduler <- struct{}{}:
default: // 已有保存任务在队列中
}
}
}
// ForceSave 强制保存
func (ds *DocumentService) ForceSave() error {
return ds.saveDocument()
}
// saveDocument 保存文档
func (ds *DocumentService) saveDocument() error {
ds.mu.Lock()
defer ds.mu.Unlock()
if ds.document == nil {
return nil
}
now := time.Now()
ds.document.Meta.LastUpdated = now
if err := ds.docStore.Set(*ds.document); err != nil {
return err
}
if err := ds.docStore.Save(); err != nil {
return err
}
ds.isDirty.Store(false)
ds.lastSaveTime.Store(now.Unix())
return nil
}
// autoSaveWorker 自动保存工作协程
func (ds *DocumentService) autoSaveWorker() {
ticker := time.NewTicker(ds.getAutoSaveInterval())
defer ticker.Stop()
for {
select {
case <-ds.ctx.Done():
return
case <-ds.saveScheduler:
ds.performAutoSave()
case <-ticker.C:
if ds.isDirty.Load() {
ds.performAutoSave()
}
// 动态调整保存间隔
ticker.Reset(ds.getAutoSaveInterval())
}
}
}
// getAutoSaveInterval 获取自动保存间隔
func (ds *DocumentService) getAutoSaveInterval() time.Duration {
config, err := ds.configService.GetConfig()
if err != nil {
return 5 * time.Second
}
return time.Duration(config.Editing.AutoSaveDelay) * time.Millisecond
}
// performAutoSave 执行自动保存
func (ds *DocumentService) performAutoSave() {
if !ds.isDirty.Load() {
return
}
// 防抖:避免过于频繁的保存
lastSave := time.Unix(ds.lastSaveTime.Load(), 0)
if time.Since(lastSave) < time.Second {
// 延迟重试
time.AfterFunc(time.Second, func() {
select {
case ds.saveScheduler <- struct{}{}:
default:
}
})
return
}
if err := ds.saveDocument(); err != nil {
ds.logger.Error("auto save failed", "error", err)
}
}
// ReloadDocument 重新加载文档
func (ds *DocumentService) ReloadDocument() error {
// 先保存当前文档
if ds.isDirty.Load() {
if err := ds.saveDocument(); err != nil {
for _, index := range indexes {
if _, err := ds.db.Exec(index); err != nil {
return err
}
}
return nil
}
// 重新初始化存储
if err := ds.initStore(); err != nil {
// ensureDefaultDocument ensures a default document exists
func (ds *DocumentService) ensureDefaultDocument() error {
// Check if any document exists
var count int
err := ds.db.QueryRow("SELECT COUNT(*) FROM documents").Scan(&count)
if err != nil {
return err
}
// 重新加载
ds.loadDocument()
// If no documents exist, create default document
if count == 0 {
defaultDoc := models.NewDefaultDocument()
_, err := ds.CreateDocument(defaultDoc.Title)
return err
}
return nil
}
// ServiceShutdown 关闭服务
func (ds *DocumentService) ServiceShutdown() error {
ds.cancel() // 停止自动保存工作协程
// GetDocumentByID gets a document by ID
func (ds *DocumentService) GetDocumentByID(id int64) (*models.Document, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
// 最后保存
if ds.isDirty.Load() {
return ds.saveDocument()
var doc models.Document
row := ds.db.QueryRow(sqlGetDocumentByID, id)
err := row.Scan(&doc.ID, &doc.Title, &doc.Content, &doc.CreatedAt, &doc.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get document by ID: %w", err)
}
return &doc, nil
}
// CreateDocument creates a new document and returns the created document with ID
func (ds *DocumentService) CreateDocument(title string) (*models.Document, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
// Create document with default content
now := time.Now()
doc := &models.Document{
Title: title,
Content: "∞∞∞text-a\n",
CreatedAt: now,
UpdatedAt: now,
}
result, err := ds.db.Exec(sqlInsertDocument, doc.Title, doc.Content, doc.CreatedAt, doc.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to create document: %w", err)
}
// Get the auto-generated ID
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("failed to get last insert ID: %w", err)
}
// Return the created document with ID
doc.ID = id
return doc, nil
}
// UpdateDocumentContent updates the content of a document
func (ds *DocumentService) UpdateDocumentContent(id int64, content string) error {
ds.mu.Lock()
defer ds.mu.Unlock()
_, err := ds.db.Exec(sqlUpdateDocumentContent, content, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to update document content: %w", err)
}
return nil
}
// OnDataPathChanged 处理数据路径变更
func (ds *DocumentService) OnDataPathChanged(oldPath, newPath string) error {
return ds.ReloadDocument()
// UpdateDocumentTitle updates the title of a document
func (ds *DocumentService) UpdateDocumentTitle(id int64, title string) error {
ds.mu.Lock()
defer ds.mu.Unlock()
_, err := ds.db.Exec(sqlUpdateDocumentTitle, title, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to update document title: %w", err)
}
return nil
}
// DeleteDocument deletes a document (not allowed if it's the only document)
func (ds *DocumentService) DeleteDocument(id int64) error {
ds.mu.Lock()
defer ds.mu.Unlock()
// Check if this is the only document
var count int
err := ds.db.QueryRow("SELECT COUNT(*) FROM documents").Scan(&count)
if err != nil {
return fmt.Errorf("failed to count documents: %w", err)
}
// Don't allow deletion if this is the only document
if count <= 1 {
return fmt.Errorf("cannot delete the last document")
}
_, err = ds.db.Exec(sqlDeleteDocument, id)
if err != nil {
return fmt.Errorf("failed to delete document: %w", err)
}
return nil
}
// ListAllDocumentsMeta lists all document metadata
func (ds *DocumentService) ListAllDocumentsMeta() ([]*models.Document, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
rows, err := ds.db.Query(sqlListAllDocumentsMeta)
if err != nil {
return nil, fmt.Errorf("failed to list document meta: %w", err)
}
defer rows.Close()
var documents []*models.Document
for rows.Next() {
var doc models.Document
err := rows.Scan(&doc.ID, &doc.Title, &doc.CreatedAt, &doc.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan document meta: %w", err)
}
documents = append(documents, &doc)
}
return documents, nil
}
// GetFirstDocumentID gets the first document's ID for frontend initialization
func (ds *DocumentService) GetFirstDocumentID() (int64, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
var id int64
err := ds.db.QueryRow(sqlGetFirstDocumentID).Scan(&id)
if err != nil {
if err == sql.ErrNoRows {
return 0, nil // No documents exist
}
return 0, fmt.Errorf("failed to get first document ID: %w", err)
}
return id, nil
}
// OnShutdown shuts down the service when the application closes
func (ds *DocumentService) OnShutdown() error {
if ds.db != nil {
return ds.db.Close()
}
return nil
}
// OnDataPathChanged handles data path changes
func (ds *DocumentService) OnDataPathChanged() error {
// Close existing database
if ds.db != nil {
ds.db.Close()
}
// Reinitialize with new path
return ds.initDatabase()
}