服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
970 lines
30 KiB
Go
970 lines
30 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"pvfine/internal/pvf"
|
|
scriptengine "pvfine/internal/script"
|
|
pvfversion "pvfine/internal/version"
|
|
)
|
|
|
|
const (
|
|
ScriptRunCompleted = "completed"
|
|
ScriptRunFailed = "failed"
|
|
ScriptRunCancelled = "cancelled"
|
|
|
|
ScriptErrorCompile = "compile"
|
|
ScriptErrorRuntime = "runtime"
|
|
ScriptErrorHost = "host"
|
|
ScriptErrorCancelled = "cancelled"
|
|
ScriptErrorTimeout = "timeout"
|
|
|
|
ScriptFileChanged = "changed"
|
|
ScriptFileAdded = "added"
|
|
ScriptFileDeleted = "deleted"
|
|
ScriptFileSkipped = "skipped"
|
|
ScriptFileError = "error"
|
|
|
|
scriptMaxSourceBytes = 1 << 20
|
|
scriptMaxLogs = 5000
|
|
scriptRunTimeout = 5 * time.Minute
|
|
scriptPageSize = 100
|
|
)
|
|
|
|
var (
|
|
ErrScriptBusy = errors.New("脚本正在运行")
|
|
ErrScriptPlanStale = errors.New("脚本预览已过期,请重新运行")
|
|
|
|
scriptPlanSequence uint64
|
|
scriptRunSequence uint64
|
|
)
|
|
|
|
// ScriptService executes user scripts against an isolated archive stage and
|
|
// exposes the retained preview plan to the frontend.
|
|
type ScriptService struct {
|
|
c *core
|
|
runtime scriptengine.ScriptRuntime
|
|
|
|
// fileSets is the same persistence-backed service the sidebar saves through,
|
|
// so a scripted change lands in file-sets.json instead of a private copy.
|
|
fileSets *FileSetService
|
|
|
|
mu sync.Mutex
|
|
running bool
|
|
cancel context.CancelFunc
|
|
|
|
directory string
|
|
initErr error
|
|
}
|
|
|
|
// NewScriptService creates the script service under the same user config root
|
|
// used by SettingsService and the other user-managed resources.
|
|
func NewScriptService(c *core, fileSets *FileSetService) *ScriptService {
|
|
directory, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return &ScriptService{c: c, runtime: scriptengine.NewGojaRuntime(), fileSets: fileSets, initErr: fmt.Errorf("获取脚本目录失败: %w", err)}
|
|
}
|
|
return newScriptService(c, scriptengine.NewGojaRuntime(), filepath.Join(directory, "pvfine", "scripts"), fileSets)
|
|
}
|
|
|
|
func newScriptService(c *core, runtime scriptengine.ScriptRuntime, directory string, fileSets *FileSetService) *ScriptService {
|
|
if runtime == nil {
|
|
runtime = scriptengine.NewGojaRuntime()
|
|
}
|
|
return &ScriptService{c: c, runtime: runtime, fileSets: fileSets, directory: directory}
|
|
}
|
|
|
|
// invalidateScriptLocked aborts a running VM and releases any retained
|
|
// preview transaction. The caller must hold c.mu.
|
|
func (c *core) invalidateScriptLocked() {
|
|
if c == nil {
|
|
return
|
|
}
|
|
if c.scriptCancel != nil {
|
|
c.scriptCancel()
|
|
c.scriptCancel = nil
|
|
}
|
|
c.invalidateScriptPlanLocked()
|
|
}
|
|
|
|
// invalidateScriptPlanLocked releases a staged preview without cancelling a
|
|
// currently executing run. The caller must hold c.mu.
|
|
func (c *core) invalidateScriptPlanLocked() {
|
|
if c == nil {
|
|
return
|
|
}
|
|
if c.scriptPlan != nil {
|
|
c.scriptPlan.transaction.Rollback()
|
|
c.scriptPlan = nil
|
|
c.mcpScriptPlan = ""
|
|
}
|
|
}
|
|
|
|
type ScriptCompileResult struct {
|
|
Valid bool `json:"valid"`
|
|
Diagnostics []*ScriptDiagnostic `json:"diagnostics"`
|
|
}
|
|
|
|
type ScriptDiagnostic struct {
|
|
Kind string `json:"kind"`
|
|
Message string `json:"message"`
|
|
Stack string `json:"stack,omitempty"`
|
|
Line int `json:"line,omitempty"`
|
|
Column int `json:"column,omitempty"`
|
|
}
|
|
|
|
type ScriptRunRequest struct {
|
|
Name string `json:"name,omitempty"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
type ScriptRunResult struct {
|
|
RunID string `json:"runId"`
|
|
Status string `json:"status"`
|
|
PlanID string `json:"planId,omitempty"`
|
|
ScannedFiles int `json:"scannedFiles"`
|
|
ModifiedFiles int `json:"modifiedFiles"`
|
|
FileSetChanges int `json:"fileSetChanges"`
|
|
DurationMs int64 `json:"durationMs"`
|
|
Logs []*ScriptLog `json:"logs"`
|
|
Error *ScriptError `json:"error,omitempty"`
|
|
}
|
|
|
|
type ScriptError struct {
|
|
Kind string `json:"kind"`
|
|
Message string `json:"message"`
|
|
Stack string `json:"stack,omitempty"`
|
|
Line int `json:"line,omitempty"`
|
|
Column int `json:"column,omitempty"`
|
|
}
|
|
|
|
type ScriptLog struct {
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type ScriptProgress struct {
|
|
RunID string `json:"runId"`
|
|
Done int `json:"done"`
|
|
Total int `json:"total"`
|
|
Message string `json:"message,omitempty"`
|
|
CurrentPath string `json:"currentPath,omitempty"`
|
|
}
|
|
|
|
type ScriptPreviewPage struct {
|
|
PlanID string `json:"planId"`
|
|
NextCursor int `json:"nextCursor"`
|
|
// Filter echoes the path filter this page was built with.
|
|
Filter string `json:"filter,omitempty"`
|
|
// MatchedFiles counts every row matching the filter across all pages, so
|
|
// the UI can report an accurate total instead of only what is loaded.
|
|
MatchedFiles int `json:"matchedFiles"`
|
|
ScannedFiles int `json:"scannedFiles"`
|
|
ModifiedFiles int `json:"modifiedFiles"`
|
|
Rows []*ScriptFilePreview `json:"rows"`
|
|
// FileSetRows lists the staged file set mutations. They are not diffed rows,
|
|
// but the apply step must show and select them alongside archive changes.
|
|
FileSetRows []*ScriptFileSetPreview `json:"fileSetRows,omitempty"`
|
|
// FileSetSelectKey is the change key the frontend selects to apply every
|
|
// file set mutation, mirroring how archive rows use ChangeKey.
|
|
FileSetSelectKey string `json:"fileSetSelectKey,omitempty"`
|
|
}
|
|
|
|
// ScriptFileSetPreview describes one staged file set mutation.
|
|
type ScriptFileSetPreview struct {
|
|
ChangeKey string `json:"changeKey"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
// Added and Removed are the path-level difference against the persisted set,
|
|
// so the UI can show what a setAll actually changed.
|
|
Added []string `json:"added,omitempty"`
|
|
Removed []string `json:"removed,omitempty"`
|
|
// Count is the resulting entry total after the change.
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type ScriptFilePreview struct {
|
|
// ChangeKey is the stable selection key. Structural changes shift entry
|
|
// indexes, so the frontend must select rows by this key, not FileIndex.
|
|
ChangeKey string `json:"changeKey"`
|
|
FileIndex int32 `json:"fileIndex"`
|
|
Path string `json:"path"`
|
|
Status string `json:"status"`
|
|
MatchCount int `json:"matchCount"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
Diff []*BatchDiffLine `json:"diff,omitempty"`
|
|
DiffTruncated bool `json:"diffTruncated,omitempty"`
|
|
}
|
|
|
|
type ScriptApplyResult struct {
|
|
AppliedFiles int32 `json:"appliedFiles"`
|
|
FileIndexes []int32 `json:"fileIndexes"`
|
|
ModifiedCount int `json:"modifiedCount"`
|
|
Revision uint64 `json:"revision"`
|
|
// Structural reports whether the commit changed the entry table, which
|
|
// means every previously previewed file index is now stale.
|
|
Structural bool `json:"structural"`
|
|
// AppliedFileSets counts the file set mutations written to user config.
|
|
AppliedFileSets int `json:"appliedFileSets"`
|
|
}
|
|
|
|
type ScriptFile struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
ModifiedAt string `json:"modifiedAt"`
|
|
}
|
|
|
|
type scriptPlan struct {
|
|
id string
|
|
label string
|
|
archive *pvf.Archive
|
|
revision uint64
|
|
transaction *scriptengine.Transaction
|
|
rows []scriptPlanRow
|
|
scannedFiles int
|
|
modifiedFiles int
|
|
|
|
// fileSets is the run's baseline snapshot, retained so the apply step can
|
|
// tell an edit from a create when merging into the persisted document.
|
|
fileSets *scriptengine.FileSetStage
|
|
|
|
fileSetRows []*ScriptFileSetPreview
|
|
}
|
|
|
|
type scriptPlanRow struct {
|
|
preview ScriptFilePreview
|
|
afterText string
|
|
// change is the staged mutation used to build the commit payload.
|
|
change scriptengine.Change
|
|
}
|
|
|
|
// Compile validates script syntax without reading the current archive.
|
|
func (s *ScriptService) Compile(source string) ScriptCompileResult {
|
|
if len([]byte(source)) > scriptMaxSourceBytes {
|
|
return ScriptCompileResult{
|
|
Valid: false,
|
|
Diagnostics: []*ScriptDiagnostic{{
|
|
Kind: ScriptErrorCompile,
|
|
Message: fmt.Sprintf("脚本长度不能超过 %d 字节", scriptMaxSourceBytes),
|
|
}},
|
|
}
|
|
}
|
|
result := s.runtime.Compile(source)
|
|
return ScriptCompileResult{Valid: result.Valid, Diagnostics: scriptDiagnostics(result.Diagnostics)}
|
|
}
|
|
|
|
// Run executes the script against a transaction and retains a diff plan. It
|
|
// never changes the live archive. The Wails context parameter is hidden from
|
|
// generated TypeScript bindings and is cancelled by the explicit Cancel call
|
|
// or by the caller closing the cancellable promise.
|
|
func (s *ScriptService) Run(ctx context.Context, request ScriptRunRequest) (ScriptRunResult, error) {
|
|
if s.initErr != nil {
|
|
return ScriptRunResult{}, s.initErr
|
|
}
|
|
if len([]byte(request.Source)) > scriptMaxSourceBytes {
|
|
return ScriptRunResult{}, fmt.Errorf("脚本长度不能超过 %d 字节", scriptMaxSourceBytes)
|
|
}
|
|
compile := s.Compile(request.Source)
|
|
if !compile.Valid {
|
|
return ScriptRunResult{
|
|
RunID: nextScriptRunID(),
|
|
Status: ScriptRunFailed,
|
|
Error: firstScriptDiagnostic(compile.Diagnostics),
|
|
}, nil
|
|
}
|
|
|
|
s.mu.Lock()
|
|
if s.running {
|
|
s.mu.Unlock()
|
|
return ScriptRunResult{}, ErrScriptBusy
|
|
}
|
|
s.running = true
|
|
s.mu.Unlock()
|
|
|
|
startedAt := time.Now()
|
|
runID := nextScriptRunID()
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, scriptRunTimeout)
|
|
finishTask := s.c.archiveTasks.begin()
|
|
defer finishTask()
|
|
s.mu.Lock()
|
|
s.cancel = cancel
|
|
s.mu.Unlock()
|
|
defer func() {
|
|
cancel()
|
|
s.mu.Lock()
|
|
s.cancel = nil
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
s.c.mu.Lock()
|
|
if s.c.scriptCancel != nil {
|
|
s.c.scriptCancel = nil
|
|
}
|
|
s.c.mu.Unlock()
|
|
}()
|
|
|
|
s.c.mu.RLock()
|
|
a := s.c.archive
|
|
revision := s.c.batchRevision
|
|
if a != nil {
|
|
tx := scriptengine.NewTransaction(a)
|
|
s.c.mu.RUnlock()
|
|
|
|
// The core uses this cancellation hook when the active archive changes
|
|
// or closes while the script is running. Install it before entering the
|
|
// VM so a long-running script cannot outlive its archive context.
|
|
s.c.mu.Lock()
|
|
if s.c.archive != a || s.c.batchRevision != revision {
|
|
s.c.mu.Unlock()
|
|
tx.Rollback()
|
|
return ScriptRunResult{}, ErrScriptPlanStale
|
|
}
|
|
if s.c.mcpScriptPlan != "" || s.c.mcpImportPlan != "" {
|
|
s.c.mu.Unlock()
|
|
tx.Rollback()
|
|
return ScriptRunResult{}, fmt.Errorf("PLAN_PENDING: 请先处理 MCP 预览")
|
|
}
|
|
s.c.scriptCancel = cancel
|
|
s.c.invalidateScriptPlanLocked()
|
|
s.c.mu.Unlock()
|
|
|
|
logEntries := make([]*ScriptLog, 0)
|
|
logCount := 0
|
|
logBytes := 0
|
|
appendLog := func(entry scriptengine.LogEntry) {
|
|
if len(entry.Message) > 16<<10 {
|
|
entry.Message = entry.Message[:16<<10] + "…[截断]"
|
|
}
|
|
logBytes += len(entry.Message)
|
|
if logCount < scriptMaxLogs && logBytes <= 256<<10 {
|
|
logEntries = append(logEntries, &ScriptLog{Level: entry.Level, Message: entry.Message})
|
|
emitEvent("script:log", map[string]any{
|
|
"runId": runID,
|
|
"level": entry.Level,
|
|
"message": entry.Message,
|
|
})
|
|
logCount++
|
|
return
|
|
}
|
|
if logCount <= scriptMaxLogs {
|
|
logCount = scriptMaxLogs + 1
|
|
logEntries = append(logEntries, &ScriptLog{Level: scriptengine.LogLevelWarn, Message: "日志达到条数或 256 KiB 上限,后续日志已省略"})
|
|
emitEvent("script:log", map[string]any{
|
|
"runId": runID,
|
|
"level": scriptengine.LogLevelWarn,
|
|
"message": "日志达到条数或 256 KiB 上限,后续日志已省略",
|
|
})
|
|
}
|
|
}
|
|
appendProgress := func(progress scriptengine.Progress) {
|
|
emitEvent("script:progress", ScriptProgress{
|
|
RunID: runID, Done: progress.Done, Total: progress.Total,
|
|
Message: progress.Message, CurrentPath: progress.CurrentPath,
|
|
})
|
|
}
|
|
host := scriptengine.NewBatchAPI(runCtx, tx, appendLog, appendProgress)
|
|
// File sets are a user-config resource, not archive content, so the run
|
|
// reads them through the same service the sidebar saves through and
|
|
// stages its edits for the apply step.
|
|
host.SetFileSetStage(scriptengine.NewFileSetStage(s.loadFileSetSnapshot()))
|
|
emitEvent("script:state", map[string]any{"runId": runID, "status": "running"})
|
|
runtimeResult, runErr := s.runtime.Run(runCtx, request.Source, host)
|
|
if runErr != nil {
|
|
tx.Rollback()
|
|
return ScriptRunResult{}, runErr
|
|
}
|
|
result := ScriptRunResult{
|
|
RunID: runID,
|
|
Status: runtimeResult.Status,
|
|
ScannedFiles: runtimeResult.ScannedFiles,
|
|
ModifiedFiles: runtimeResult.ModifiedFiles,
|
|
DurationMs: time.Since(startedAt).Milliseconds(),
|
|
Logs: logEntries,
|
|
Error: scriptError(runtimeResult.Error),
|
|
}
|
|
if runtimeResult.Status != scriptengine.RunStatusCompleted {
|
|
tx.Rollback()
|
|
emitEvent("script:state", map[string]any{"runId": runID, "status": runtimeResult.Status, "error": result.Error})
|
|
return result, nil
|
|
}
|
|
|
|
changes, changesErr := tx.Changes()
|
|
if changesErr != nil {
|
|
tx.Rollback()
|
|
return ScriptRunResult{}, changesErr
|
|
}
|
|
plan := &scriptPlan{
|
|
id: nextScriptPlanID(),
|
|
label: strings.TrimSpace(request.Name),
|
|
archive: a,
|
|
revision: revision,
|
|
transaction: tx,
|
|
scannedFiles: runtimeResult.ScannedFiles,
|
|
modifiedFiles: len(changes),
|
|
rows: make([]scriptPlanRow, 0, len(changes)),
|
|
fileSets: host.FileSetStage(),
|
|
}
|
|
// A run that only touched file sets has no archive change to apply, but
|
|
// the plan is still worth retaining: the user must be able to review and
|
|
// apply it.
|
|
plan.fileSetRows = buildFileSetPreviewRows(plan.fileSets)
|
|
result.FileSetChanges = len(plan.fileSetRows)
|
|
for _, change := range changes {
|
|
status := ScriptFileChanged
|
|
switch change.Kind {
|
|
case pvf.ChangeKindCreated:
|
|
status = ScriptFileAdded
|
|
case pvf.ChangeKindDeleted:
|
|
status = ScriptFileDeleted
|
|
}
|
|
row := scriptPlanRow{change: change, afterText: change.AfterText}
|
|
row.preview = ScriptFilePreview{
|
|
ChangeKey: change.Normalized,
|
|
Path: change.Path,
|
|
Status: status,
|
|
MatchCount: 1,
|
|
}
|
|
if row.preview.Path == "" {
|
|
row.preview.Path = change.Normalized
|
|
}
|
|
// Structural rows have no stable entry index; only an in-place
|
|
// payload edit can reference the entry it came from.
|
|
row.preview.FileIndex = -1
|
|
if status == ScriptFileChanged {
|
|
if index, ok := a.Find(change.Path); ok {
|
|
row.preview.FileIndex = index
|
|
}
|
|
}
|
|
row.preview.Diff, row.preview.DiffTruncated = buildBatchDiff(change.BeforeText, change.AfterText)
|
|
plan.rows = append(plan.rows, row)
|
|
}
|
|
sort.SliceStable(plan.rows, func(left, right int) bool {
|
|
return plan.rows[left].preview.ChangeKey < plan.rows[right].preview.ChangeKey
|
|
})
|
|
|
|
s.c.mu.Lock()
|
|
if s.c.archive != a || s.c.batchRevision != revision {
|
|
s.c.mu.Unlock()
|
|
tx.Rollback()
|
|
return ScriptRunResult{}, ErrScriptPlanStale
|
|
}
|
|
s.c.scriptPlan = plan
|
|
s.c.mu.Unlock()
|
|
result.PlanID = plan.id
|
|
emitEvent("script:state", map[string]any{"runId": runID, "status": ScriptRunCompleted, "planId": plan.id})
|
|
return result, nil
|
|
}
|
|
s.c.mu.RUnlock()
|
|
return ScriptRunResult{}, ErrNoArchive
|
|
}
|
|
|
|
// Cancel requests cancellation of the active script. It is idempotent.
|
|
func (s *ScriptService) Cancel() bool {
|
|
s.mu.Lock()
|
|
cancel := s.cancel
|
|
running := s.running
|
|
s.mu.Unlock()
|
|
if cancel == nil {
|
|
return false
|
|
}
|
|
cancel()
|
|
return running
|
|
}
|
|
|
|
// IsRunning reports whether a script call is currently executing.
|
|
func (s *ScriptService) IsRunning() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.running
|
|
}
|
|
|
|
// PreviewPage returns one page of preview rows, optionally narrowed to rows
|
|
// whose path contains filter (case-insensitive). Filtering happens here rather
|
|
// than in the UI so pagination and the matched total stay consistent: a filter
|
|
// that matches only later pages still reports and pages its matches correctly.
|
|
func (s *ScriptService) PreviewPage(planID, filter string, cursor, limit int) (*ScriptPreviewPage, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
plan, err := s.currentScriptPlanLocked(planID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if limit <= 0 || limit > 500 {
|
|
limit = scriptPageSize
|
|
}
|
|
|
|
matched := matchingScriptRows(plan, filter)
|
|
if cursor > len(matched) {
|
|
cursor = len(matched)
|
|
}
|
|
end := cursor + limit
|
|
if end > len(matched) {
|
|
end = len(matched)
|
|
}
|
|
rows := make([]*ScriptFilePreview, 0, end-cursor)
|
|
for index := cursor; index < end; index++ {
|
|
row := matched[index].preview
|
|
row.Warnings = append([]string(nil), row.Warnings...)
|
|
row.Diff = append([]*BatchDiffLine(nil), row.Diff...)
|
|
rows = append(rows, &row)
|
|
}
|
|
next := -1
|
|
if end < len(matched) {
|
|
next = end
|
|
}
|
|
page := &ScriptPreviewPage{
|
|
PlanID: plan.id, NextCursor: next,
|
|
Filter: strings.TrimSpace(filter),
|
|
MatchedFiles: len(matched),
|
|
ScannedFiles: plan.scannedFiles,
|
|
ModifiedFiles: plan.modifiedFiles,
|
|
Rows: rows,
|
|
FileSetRows: plan.fileSetRows,
|
|
}
|
|
if len(plan.fileSetRows) > 0 {
|
|
page.FileSetSelectKey = fileSetChangeKey
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
// matchingScriptRows returns the plan rows whose path contains filter. An empty
|
|
// filter matches every row. The order matches the plan's stable path order.
|
|
func matchingScriptRows(plan *scriptPlan, filter string) []*scriptPlanRow {
|
|
filter = strings.ToLower(strings.TrimSpace(filter))
|
|
if filter == "" {
|
|
matched := make([]*scriptPlanRow, 0, len(plan.rows))
|
|
for index := range plan.rows {
|
|
matched = append(matched, &plan.rows[index])
|
|
}
|
|
return matched
|
|
}
|
|
matched := make([]*scriptPlanRow, 0)
|
|
for index := range plan.rows {
|
|
row := &plan.rows[index]
|
|
if strings.Contains(strings.ToLower(row.preview.Path), filter) {
|
|
matched = append(matched, row)
|
|
}
|
|
}
|
|
return matched
|
|
}
|
|
|
|
// SelectableChangeKeys returns the change keys of every row matching filter
|
|
// that can be applied. The frontend uses this so "apply selected" covers the
|
|
// whole filtered set even when only the loaded pages were ever previewed. The
|
|
// file set key is filter-independent: a fileset name is not an archive path.
|
|
func (s *ScriptService) SelectableChangeKeys(planID, filter string) ([]string, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
plan, err := s.currentScriptPlanLocked(planID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys := make([]string, 0, len(plan.rows))
|
|
for _, row := range matchingScriptRows(plan, filter) {
|
|
if row.preview.Status == ScriptFileChanged ||
|
|
row.preview.Status == ScriptFileAdded ||
|
|
row.preview.Status == ScriptFileDeleted {
|
|
keys = append(keys, row.preview.ChangeKey)
|
|
}
|
|
}
|
|
if len(plan.fileSetRows) > 0 {
|
|
keys = append(keys, fileSetChangeKey)
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// Apply commits exactly the selected changed rows from one script plan. Rows
|
|
// are addressed by ChangeKey because created and deleted entries shift the
|
|
// entry indexes the other rows were previewed with.
|
|
func (s *ScriptService) Apply(planID string, changeKeys []string) (ScriptApplyResult, error) {
|
|
s.mu.Lock()
|
|
if s.running {
|
|
s.mu.Unlock()
|
|
return ScriptApplyResult{}, ErrScriptBusy
|
|
}
|
|
defer s.mu.Unlock()
|
|
s.c.mu.Lock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, err
|
|
}
|
|
plan, err := s.currentScriptPlanLocked(planID)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, err
|
|
}
|
|
if len(changeKeys) == 0 {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, fmt.Errorf("没有选中的脚本变更文件")
|
|
}
|
|
rowsByKey := make(map[string]*scriptPlanRow, len(plan.rows))
|
|
for index := range plan.rows {
|
|
row := &plan.rows[index]
|
|
rowsByKey[row.preview.ChangeKey] = row
|
|
}
|
|
selected := make(map[string]struct{}, len(changeKeys))
|
|
ordered := make([]string, 0, len(changeKeys))
|
|
paths := make([]string, 0, len(changeKeys))
|
|
// File set changes are applied separately from archive changes: they write
|
|
// user config, not the PVF overlay. The key addresses the whole group, which
|
|
// is what the preview panel's single checkbox selects.
|
|
applyFileSets := false
|
|
for _, key := range changeKeys {
|
|
if _, exists := selected[key]; exists {
|
|
continue
|
|
}
|
|
if key == fileSetChangeKey {
|
|
if len(plan.fileSetRows) == 0 {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, fmt.Errorf("变更 %q 不是可应用的脚本结果", key)
|
|
}
|
|
applyFileSets = true
|
|
continue
|
|
}
|
|
row, exists := rowsByKey[key]
|
|
if !exists {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, fmt.Errorf("变更 %q 不是可应用的脚本结果", key)
|
|
}
|
|
selected[key] = struct{}{}
|
|
ordered = append(ordered, key)
|
|
paths = append(paths, row.change.Path)
|
|
}
|
|
if len(ordered) == 0 && !applyFileSets {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, fmt.Errorf("没有选中的脚本变更文件")
|
|
}
|
|
var fileSetChanges []scriptengine.FileSetChange
|
|
if applyFileSets && plan.fileSets != nil {
|
|
fileSetChanges = plan.fileSets.Changes()
|
|
}
|
|
var before pvfversion.ContentSnapshot
|
|
if len(paths) > 0 && s.c.versionRepo != nil {
|
|
before, err = pvfversion.ContentSnapshotFromArchive(plan.archive, paths)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, err
|
|
}
|
|
}
|
|
mutationCheckpoint := plan.archive.MutationCheckpoint()
|
|
structural, err := plan.transaction.Commit(selected)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, err
|
|
}
|
|
// A structural commit changes the entry table, so every derived index must
|
|
// be rebuilt before the new tree can be served.
|
|
if structural {
|
|
if err := s.c.rebuildArchiveIndexesLocked(plan.archive); err != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, err
|
|
}
|
|
}
|
|
if len(paths) > 0 && s.c.versionRepo != nil {
|
|
after, snapshotErr := pvfversion.ContentSnapshotFromArchive(plan.archive, paths)
|
|
if snapshotErr != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, snapshotErr
|
|
}
|
|
label := "脚本"
|
|
if plan.label != "" {
|
|
label = "脚本 " + plan.label
|
|
}
|
|
if recordErr := s.c.recordVersionMutationLocked(label, before, after); recordErr != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, recordErr
|
|
}
|
|
}
|
|
// Persist file set changes only after the archive commit succeeded, so a
|
|
// rejected commit cannot leave user config ahead of the archive.
|
|
appliedFileSets, fileSetErr := s.applyFileSetChanges(fileSetChanges)
|
|
if fileSetErr != nil {
|
|
s.c.mu.Unlock()
|
|
return ScriptApplyResult{}, fileSetErr
|
|
}
|
|
// After a structural commit every index is rebuilt, so resolve each
|
|
// surviving path against the new table instead of the previewed index.
|
|
appliedIndexes := make([]int32, 0, len(ordered))
|
|
for _, key := range ordered {
|
|
row := rowsByKey[key]
|
|
if row.preview.Status != ScriptFileChanged {
|
|
continue
|
|
}
|
|
index, ok := plan.archive.Find(row.change.Path)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if s.c.editorText == nil {
|
|
s.c.editorText = make(map[int32]string)
|
|
}
|
|
s.c.editorText[index] = row.afterText
|
|
appliedIndexes = append(appliedIndexes, index)
|
|
}
|
|
mutationSummary := plan.archive.MutationsSince(mutationCheckpoint)
|
|
s.c.markAdvancedSearchStaleLocked(plan.archive)
|
|
plan.archive.ClearMutations()
|
|
s.c.batchRevision++
|
|
s.c.batchPlan = nil
|
|
s.c.invalidateScriptPlanLocked()
|
|
s.c.editorAnnotation = editorAnnotationCache{}
|
|
s.c.annotationRelations = make(map[string]map[string]*relationTarget)
|
|
s.c.markAdvancedSearchStaleLocked(s.c.archive)
|
|
info := plan.archive.Info()
|
|
revision := s.c.batchRevision
|
|
versioned := s.c.versionRepo != nil
|
|
s.c.mu.Unlock()
|
|
|
|
emitEvent("archive:advanced-search-stale", map[string]any{"script": true})
|
|
emitEvent("archive:batch-applied", map[string]any{
|
|
"fileIndexes": appliedIndexes, "modifiedCount": info.ModifiedCount,
|
|
"revision": revision, "source": "script", "structural": structural,
|
|
})
|
|
emitEvent("archive:script-applied", map[string]any{
|
|
"fileIndexes": appliedIndexes, "modifiedCount": info.ModifiedCount,
|
|
"revision": revision, "structural": structural,
|
|
})
|
|
// Only a structural commit changes the entry table, so the tree and open
|
|
// tabs must be re-resolved; a payload-only apply keeps every index valid.
|
|
if structural {
|
|
emitEvent("archive:changed", info)
|
|
}
|
|
// File sets live in user config, not the archive, so the sidebar's in-memory
|
|
// copy is stale after an apply and must reload.
|
|
if appliedFileSets > 0 {
|
|
emitEvent("fileset:changed", map[string]any{"count": appliedFileSets, "source": "script"})
|
|
}
|
|
if versioned {
|
|
emitVersionState(s.c, "script-applied")
|
|
}
|
|
if err := s.c.scheduleArchiveMutations(plan.archive, mutationSummary); err != nil {
|
|
return ScriptApplyResult{}, err
|
|
}
|
|
return ScriptApplyResult{
|
|
AppliedFiles: int32(len(ordered)), FileIndexes: appliedIndexes,
|
|
ModifiedCount: info.ModifiedCount, Revision: revision, Structural: structural,
|
|
AppliedFileSets: appliedFileSets,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ScriptService) Discard(planID string) {
|
|
s.c.mu.Lock()
|
|
if s.c.scriptPlan != nil && s.c.scriptPlan.id == planID {
|
|
s.c.scriptPlan.transaction.Rollback()
|
|
s.c.scriptPlan = nil
|
|
s.c.mcpScriptPlan = ""
|
|
}
|
|
s.c.mu.Unlock()
|
|
}
|
|
|
|
func (s *ScriptService) currentScriptPlanLocked(planID string) (*scriptPlan, error) {
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
plan := s.c.scriptPlan
|
|
if plan == nil || plan.id != planID || plan.archive != s.c.archive || plan.revision != s.c.batchRevision {
|
|
return nil, ErrScriptPlanStale
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func nextScriptPlanID() string {
|
|
return fmt.Sprintf("script-%d", atomic.AddUint64(&scriptPlanSequence, 1))
|
|
}
|
|
|
|
func nextScriptRunID() string {
|
|
return fmt.Sprintf("script-run-%d", atomic.AddUint64(&scriptRunSequence, 1))
|
|
}
|
|
|
|
func scriptDiagnostics(values []scriptengine.Diagnostic) []*ScriptDiagnostic {
|
|
result := make([]*ScriptDiagnostic, 0, len(values))
|
|
for _, value := range values {
|
|
result = append(result, &ScriptDiagnostic{
|
|
Kind: value.Kind, Message: value.Message, Stack: value.Stack,
|
|
Line: value.Line, Column: value.Column,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func firstScriptDiagnostic(values []*ScriptDiagnostic) *ScriptError {
|
|
if len(values) == 0 || values[0] == nil {
|
|
return &ScriptError{Kind: ScriptErrorCompile, Message: "脚本编译失败"}
|
|
}
|
|
value := values[0]
|
|
return &ScriptError{Kind: value.Kind, Message: value.Message, Stack: value.Stack, Line: value.Line, Column: value.Column}
|
|
}
|
|
|
|
func scriptError(value *scriptengine.Diagnostic) *ScriptError {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return &ScriptError{Kind: value.Kind, Message: value.Message, Stack: value.Stack, Line: value.Line, Column: value.Column}
|
|
}
|
|
|
|
func (s *ScriptService) ScriptDirectory() (string, error) {
|
|
if s.initErr != nil {
|
|
return "", s.initErr
|
|
}
|
|
if err := os.MkdirAll(s.directory, 0o755); err != nil {
|
|
return "", fmt.Errorf("创建脚本目录失败: %w", err)
|
|
}
|
|
return s.directory, nil
|
|
}
|
|
|
|
func (s *ScriptService) ListScripts() ([]*ScriptFile, error) {
|
|
directory, err := s.ScriptDirectory()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取脚本目录失败: %w", err)
|
|
}
|
|
result := make([]*ScriptFile, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.HasSuffix(strings.ToLower(entry.Name()), ".pvf.js") {
|
|
continue
|
|
}
|
|
info, infoErr := entry.Info()
|
|
if infoErr != nil || !info.Mode().IsRegular() {
|
|
continue
|
|
}
|
|
result = append(result, &ScriptFile{
|
|
Name: entry.Name(), Size: info.Size(),
|
|
ModifiedAt: info.ModTime().Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
sort.SliceStable(result, func(left, right int) bool {
|
|
return strings.ToLower(result[left].Name) < strings.ToLower(result[right].Name)
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
func (s *ScriptService) LoadScript(name string) (string, error) {
|
|
path, err := s.scriptPath(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取脚本失败: %w", err)
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("脚本必须是脚本目录内的普通文件")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取脚本失败: %w", err)
|
|
}
|
|
if len(data) > scriptMaxSourceBytes {
|
|
return "", fmt.Errorf("脚本长度不能超过 %d 字节", scriptMaxSourceBytes)
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return "", fmt.Errorf("脚本必须是 UTF-8 文本")
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func (s *ScriptService) SaveScript(name, source string) error {
|
|
path, err := s.scriptPath(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len([]byte(source)) > scriptMaxSourceBytes {
|
|
return fmt.Errorf("脚本长度不能超过 %d 字节", scriptMaxSourceBytes)
|
|
}
|
|
if !utf8.ValidString(source) {
|
|
return fmt.Errorf("脚本必须是 UTF-8 文本")
|
|
}
|
|
directory := filepath.Dir(path)
|
|
temporary, err := os.CreateTemp(directory, ".pvfine-script-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("创建脚本临时文件失败: %w", err)
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
defer os.Remove(temporaryPath)
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
_ = temporary.Close()
|
|
return err
|
|
}
|
|
if _, err := temporary.WriteString(source); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("写入脚本失败: %w", err)
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("同步脚本失败: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return fmt.Errorf("关闭脚本失败: %w", err)
|
|
}
|
|
if err := os.Rename(temporaryPath, path); err != nil {
|
|
return fmt.Errorf("保存脚本失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ScriptService) OpenScriptDirectory() error {
|
|
directory, err := s.ScriptDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var command string
|
|
var args []string
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
command, args = "open", []string{directory}
|
|
case "windows":
|
|
command, args = "explorer.exe", []string{directory}
|
|
case "linux":
|
|
command, args = "xdg-open", []string{directory}
|
|
default:
|
|
return fmt.Errorf("当前平台不支持打开脚本目录")
|
|
}
|
|
if err := exec.Command(command, args...).Start(); err != nil {
|
|
return fmt.Errorf("打开脚本目录失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ScriptService) scriptPath(name string) (string, error) {
|
|
directory, err := s.ScriptDirectory()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("脚本名称不能为空")
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(name), ".pvf.js") {
|
|
name += ".pvf.js"
|
|
}
|
|
if filepath.Base(name) != name || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) {
|
|
return "", fmt.Errorf("脚本名称必须是目录内的 .pvf.js 文件名")
|
|
}
|
|
path := filepath.Join(directory, name)
|
|
relative, err := filepath.Rel(directory, path)
|
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("脚本路径越界")
|
|
}
|
|
return path, nil
|
|
}
|