DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,969 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user