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,791 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
pvfversion "pvfine/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
BatchModeText = "text"
|
||||
BatchModeStructured = "structured"
|
||||
|
||||
BatchFileChanged = "changed"
|
||||
BatchFileSkipped = "skipped"
|
||||
BatchFileError = "error"
|
||||
|
||||
batchPreviewPageSize = 100
|
||||
batchMaxDiffLines = 2000
|
||||
batchDiffContext = 2
|
||||
)
|
||||
|
||||
var batchPlanSequence uint64
|
||||
|
||||
// BatchService previews and applies multi-file edits against the current PVF.
|
||||
// Preview plans live only in memory and are invalidated by every archive
|
||||
// content mutation.
|
||||
type BatchService struct{ c *core }
|
||||
|
||||
func NewBatchService(c *core) *BatchService { return &BatchService{c: c} }
|
||||
|
||||
type BatchRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Paths []string `json:"paths"`
|
||||
Text *TextReplaceSpec `json:"text,omitempty"`
|
||||
Operations []StructuredOperation `json:"operations,omitempty"`
|
||||
}
|
||||
|
||||
type TextReplaceSpec struct {
|
||||
Find string `json:"find"`
|
||||
Replacement string `json:"replacement"`
|
||||
Regex bool `json:"regex"`
|
||||
}
|
||||
|
||||
type StructuredOperation struct {
|
||||
Kind string `json:"kind"`
|
||||
Section string `json:"section"`
|
||||
TokenIndex int `json:"tokenIndex"`
|
||||
Value string `json:"value"`
|
||||
CreateIfMissing bool `json:"createIfMissing"`
|
||||
Operator string `json:"operator"`
|
||||
Operand string `json:"operand"`
|
||||
OperandEnd string `json:"operandEnd"`
|
||||
AnchorSection string `json:"anchorSection"`
|
||||
HasEndTag bool `json:"hasEndTag"`
|
||||
}
|
||||
|
||||
type BatchPreviewPage struct {
|
||||
PlanID string `json:"planId"`
|
||||
NextCursor int `json:"nextCursor"`
|
||||
RequestedFiles int `json:"requestedFiles"`
|
||||
MatchedFiles int `json:"matchedFiles"`
|
||||
MatchedOccurrences int `json:"matchedOccurrences"`
|
||||
ChangedFiles int `json:"changedFiles"`
|
||||
Rows []*BatchFilePreview `json:"rows"`
|
||||
}
|
||||
|
||||
type BatchFilePreview struct {
|
||||
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 BatchDiffLine struct {
|
||||
Kind string `json:"kind"`
|
||||
Text string `json:"text"`
|
||||
OldLine int `json:"oldLine"`
|
||||
NewLine int `json:"newLine"`
|
||||
}
|
||||
|
||||
type BatchApplyResult struct {
|
||||
AppliedFiles int32 `json:"appliedFiles"`
|
||||
FileIndexes []int32 `json:"fileIndexes"`
|
||||
ModifiedCount int `json:"modifiedCount"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
type batchPlan struct {
|
||||
id string
|
||||
archive *pvf.Archive
|
||||
revision uint64
|
||||
staged *pvf.Archive
|
||||
rows []batchPlanRow
|
||||
requestedFiles int
|
||||
matchedFiles int
|
||||
matchedOccurrences int
|
||||
changedFiles int
|
||||
}
|
||||
|
||||
type batchPlanRow struct {
|
||||
preview BatchFilePreview
|
||||
afterText string
|
||||
}
|
||||
|
||||
type batchFileTarget struct {
|
||||
index int32
|
||||
path string
|
||||
}
|
||||
|
||||
// Preview builds an in-memory plan and returns its first page. It does not
|
||||
// modify the live archive or any editor overlay.
|
||||
func (s *BatchService) Preview(request BatchRequest) (*BatchPreviewPage, error) {
|
||||
mode := strings.ToLower(strings.TrimSpace(request.Mode))
|
||||
if mode != BatchModeText && mode != BatchModeStructured {
|
||||
return nil, fmt.Errorf("未知批处理模式: %s", request.Mode)
|
||||
}
|
||||
if len(request.Paths) == 0 {
|
||||
return nil, fmt.Errorf("没有可处理的文件")
|
||||
}
|
||||
if err := validateBatchRequest(mode, request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
finishTask := s.c.archiveTasks.begin()
|
||||
defer finishTask()
|
||||
s.c.mu.RLock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
s.c.mu.RUnlock()
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
revision := s.c.batchRevision
|
||||
targets := resolveBatchTargets(a, request.Paths)
|
||||
stage := a.CloneForBatch()
|
||||
s.c.mu.RUnlock()
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("没有可处理的文件")
|
||||
}
|
||||
|
||||
plan := &batchPlan{
|
||||
id: nextBatchPlanID(),
|
||||
archive: a,
|
||||
revision: revision,
|
||||
staged: stage,
|
||||
requestedFiles: len(request.Paths),
|
||||
}
|
||||
if err := buildBatchPlan(plan, stage, targets, mode, request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.c.mu.Lock()
|
||||
defer s.c.mu.Unlock()
|
||||
if s.c.archive != a || s.c.batchRevision != revision {
|
||||
return nil, ErrBatchPlanStale
|
||||
}
|
||||
s.c.batchPlan = plan
|
||||
return batchPreviewPageFromPlan(plan, 0, batchPreviewPageSize), nil
|
||||
}
|
||||
|
||||
// PreviewPage returns another page from the currently retained preview plan.
|
||||
func (s *BatchService) PreviewPage(planID string, cursor, limit int) (*BatchPreviewPage, error) {
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
plan, err := s.currentBatchPlanLocked(planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = batchPreviewPageSize
|
||||
}
|
||||
return batchPreviewPageFromPlan(plan, cursor, limit), nil
|
||||
}
|
||||
|
||||
// Apply commits exactly the selected changed rows from a preview plan. An
|
||||
// empty selection is rejected so the UI cannot accidentally report success for
|
||||
// a no-op.
|
||||
func (s *BatchService) Apply(planID string, fileIndexes []int32) (BatchApplyResult, error) {
|
||||
s.c.mu.Lock()
|
||||
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, err
|
||||
}
|
||||
plan, err := s.currentBatchPlanLocked(planID)
|
||||
if err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, err
|
||||
}
|
||||
if len(fileIndexes) == 0 {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, fmt.Errorf("没有选中的变更文件")
|
||||
}
|
||||
|
||||
rowsByIndex := make(map[int32]*batchPlanRow, len(plan.rows))
|
||||
for index := range plan.rows {
|
||||
row := &plan.rows[index]
|
||||
if row.preview.FileIndex >= 0 {
|
||||
rowsByIndex[row.preview.FileIndex] = row
|
||||
}
|
||||
}
|
||||
selected := make(map[int32]struct{}, len(fileIndexes))
|
||||
ordered := make([]int32, 0, len(fileIndexes))
|
||||
for _, fileIndex := range fileIndexes {
|
||||
if _, exists := selected[fileIndex]; exists {
|
||||
continue
|
||||
}
|
||||
row, exists := rowsByIndex[fileIndex]
|
||||
if !exists || row.preview.Status != BatchFileChanged {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, fmt.Errorf("文件 %d 不是可应用的批处理结果", fileIndex)
|
||||
}
|
||||
selected[fileIndex] = struct{}{}
|
||||
ordered = append(ordered, fileIndex)
|
||||
}
|
||||
var before pvfversion.ContentSnapshot
|
||||
if s.c.versionRepo != nil {
|
||||
paths := make([]string, 0, len(ordered))
|
||||
for _, fileIndex := range ordered {
|
||||
paths = append(paths, plan.archive.Path(fileIndex))
|
||||
}
|
||||
before, err = pvfversion.ContentSnapshotFromArchive(plan.archive, paths)
|
||||
if err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, err
|
||||
}
|
||||
}
|
||||
mutationCheckpoint := plan.archive.MutationCheckpoint()
|
||||
if err := plan.archive.CommitBatch(plan.staged, selected); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, err
|
||||
}
|
||||
if s.c.versionRepo != nil {
|
||||
paths := make([]string, 0, len(ordered))
|
||||
for _, fileIndex := range ordered {
|
||||
paths = append(paths, plan.archive.Path(fileIndex))
|
||||
}
|
||||
after, snapshotErr := pvfversion.ContentSnapshotFromArchive(plan.archive, paths)
|
||||
if snapshotErr != nil {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, snapshotErr
|
||||
}
|
||||
if recordErr := s.c.recordVersionMutationLocked("批量操作", before, after); recordErr != nil {
|
||||
s.c.mu.Unlock()
|
||||
return BatchApplyResult{}, recordErr
|
||||
}
|
||||
}
|
||||
|
||||
if s.c.editorText == nil {
|
||||
s.c.editorText = make(map[int32]string)
|
||||
}
|
||||
for _, fileIndex := range ordered {
|
||||
row := rowsByIndex[fileIndex]
|
||||
s.c.editorText[fileIndex] = row.afterText
|
||||
}
|
||||
mutationSummary := plan.archive.MutationsSince(mutationCheckpoint)
|
||||
s.c.markAdvancedSearchStaleLocked(plan.archive)
|
||||
plan.archive.ClearMutations()
|
||||
s.c.batchRevision++
|
||||
s.c.batchPlan = nil
|
||||
s.c.invalidateScriptLocked()
|
||||
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{"batch": true})
|
||||
emitEvent("archive:batch-applied", map[string]any{
|
||||
"fileIndexes": ordered,
|
||||
"modifiedCount": info.ModifiedCount,
|
||||
"revision": revision,
|
||||
})
|
||||
if versioned {
|
||||
emitVersionState(s.c, "batch-applied")
|
||||
}
|
||||
// One background update classifies all affected files together and avoids
|
||||
// emitting one semantic-index invalidation per file.
|
||||
if err := s.c.scheduleArchiveMutations(plan.archive, mutationSummary); err != nil {
|
||||
return BatchApplyResult{}, err
|
||||
}
|
||||
return BatchApplyResult{
|
||||
AppliedFiles: int32(len(ordered)),
|
||||
FileIndexes: ordered,
|
||||
ModifiedCount: info.ModifiedCount,
|
||||
Revision: revision,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *BatchService) currentBatchPlanLocked(planID string) (*batchPlan, error) {
|
||||
if s.c.archive == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
plan := s.c.batchPlan
|
||||
if plan == nil || plan.id != planID || plan.archive != s.c.archive || plan.revision != s.c.batchRevision {
|
||||
return nil, ErrBatchPlanStale
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func nextBatchPlanID() string {
|
||||
return fmt.Sprintf("batch-%d", atomic.AddUint64(&batchPlanSequence, 1))
|
||||
}
|
||||
|
||||
func validateBatchRequest(mode string, request BatchRequest) error {
|
||||
switch mode {
|
||||
case BatchModeText:
|
||||
if request.Text == nil {
|
||||
return fmt.Errorf("文本批处理参数为空")
|
||||
}
|
||||
if request.Text.Find == "" {
|
||||
return fmt.Errorf("查找内容不能为空")
|
||||
}
|
||||
if request.Text.Regex {
|
||||
re, err := regexp.Compile(request.Text.Find)
|
||||
if err != nil {
|
||||
return fmt.Errorf("正则表达式无效: %w", err)
|
||||
}
|
||||
if err := validateBatchReplacement(re, request.Text.Replacement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case BatchModeStructured:
|
||||
if len(request.Operations) == 0 {
|
||||
return fmt.Errorf("至少需要一条结构化操作")
|
||||
}
|
||||
for index, operation := range request.Operations {
|
||||
kind := strings.ToLower(strings.TrimSpace(operation.Kind))
|
||||
if kind == "" {
|
||||
return fmt.Errorf("第 %d 条操作类型为空", index+1)
|
||||
}
|
||||
if kind != "insert" && normalizeBatchSectionName(operation.Section) == "" {
|
||||
return fmt.Errorf("第 %d 条操作 section 为空", index+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBatchReplacement(re *regexp.Regexp, replacement string) error {
|
||||
for index := 0; index < len(replacement); index++ {
|
||||
if replacement[index] != '$' {
|
||||
continue
|
||||
}
|
||||
if index+1 >= len(replacement) {
|
||||
return fmt.Errorf("替换文本末尾的 $ 无效,请使用 $$ 表示字面量")
|
||||
}
|
||||
index++
|
||||
if replacement[index] == '$' {
|
||||
continue
|
||||
}
|
||||
if replacement[index] == '{' {
|
||||
end := strings.IndexByte(replacement[index+1:], '}')
|
||||
if end < 0 {
|
||||
return fmt.Errorf("替换捕获组缺少 }")
|
||||
}
|
||||
name := replacement[index+1 : index+1+end]
|
||||
if re.SubexpIndex(name) < 0 {
|
||||
return fmt.Errorf("替换捕获组不存在: %s", name)
|
||||
}
|
||||
index += end + 1
|
||||
continue
|
||||
}
|
||||
start := index
|
||||
if replacement[index] >= '0' && replacement[index] <= '9' {
|
||||
for index+1 < len(replacement) && replacement[index+1] >= '0' && replacement[index+1] <= '9' {
|
||||
index++
|
||||
}
|
||||
group, _ := strconv.Atoi(replacement[start : index+1])
|
||||
if group > re.NumSubexp() {
|
||||
return fmt.Errorf("替换捕获组不存在: $%d", group)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isBatchNameStart(replacement[index]) {
|
||||
for index+1 < len(replacement) && isBatchNamePart(replacement[index+1]) {
|
||||
index++
|
||||
}
|
||||
name := replacement[start : index+1]
|
||||
if re.SubexpIndex(name) < 0 {
|
||||
return fmt.Errorf("替换捕获组不存在: $%s", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("替换文本中的 $ 用法无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBatchNameStart(value byte) bool {
|
||||
return value == '_' || value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z'
|
||||
}
|
||||
|
||||
func isBatchNamePart(value byte) bool {
|
||||
return isBatchNameStart(value) || value >= '0' && value <= '9'
|
||||
}
|
||||
|
||||
func resolveBatchTargets(a *pvf.Archive, paths []string) []batchFileTarget {
|
||||
seen := make(map[int32]struct{}, len(paths))
|
||||
seenMissing := make(map[string]struct{}, len(paths))
|
||||
result := make([]batchFileTarget, 0, len(paths))
|
||||
for _, rawPath := range paths {
|
||||
path := normalizeBatchPath(rawPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
index, ok := a.Find(path)
|
||||
if !ok {
|
||||
if _, exists := seenMissing[path]; exists {
|
||||
continue
|
||||
}
|
||||
seenMissing[path] = struct{}{}
|
||||
result = append(result, batchFileTarget{index: -1, path: path})
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[index]; exists {
|
||||
continue
|
||||
}
|
||||
seen[index] = struct{}{}
|
||||
result = append(result, batchFileTarget{index: index, path: a.Path(index)})
|
||||
}
|
||||
sort.SliceStable(result, func(left, right int) bool {
|
||||
return strings.ToLower(result[left].path) < strings.ToLower(result[right].path)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeBatchPath(path string) string {
|
||||
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "/")
|
||||
}
|
||||
|
||||
func normalizeBatchSectionName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if strings.HasPrefix(name, "[") && strings.HasSuffix(name, "]") && len(name) > 2 {
|
||||
name = name[1 : len(name)-1]
|
||||
}
|
||||
return strings.TrimPrefix(name, "/")
|
||||
}
|
||||
|
||||
func buildBatchPlan(plan *batchPlan, stage *pvf.Archive, targets []batchFileTarget, mode string, request BatchRequest) error {
|
||||
operations := make([]pvf.StructuredBatchOperation, 0, len(request.Operations))
|
||||
for _, operation := range request.Operations {
|
||||
if operation.TokenIndex < 0 && strings.ToLower(strings.TrimSpace(operation.Kind)) != "set" {
|
||||
operation.TokenIndex = 0
|
||||
}
|
||||
operations = append(operations, pvf.StructuredBatchOperation{
|
||||
Kind: operation.Kind,
|
||||
Section: operation.Section,
|
||||
TokenIndex: operation.TokenIndex,
|
||||
Value: operation.Value,
|
||||
CreateIfMissing: operation.CreateIfMissing,
|
||||
Operator: operation.Operator,
|
||||
Operand: operation.Operand,
|
||||
OperandEnd: operation.OperandEnd,
|
||||
AnchorSection: operation.AnchorSection,
|
||||
HasEndTag: operation.HasEndTag,
|
||||
})
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
row := batchPlanRow{preview: BatchFilePreview{
|
||||
FileIndex: target.index,
|
||||
Path: target.path,
|
||||
}}
|
||||
if target.index < 0 {
|
||||
row.preview.Status = BatchFileSkipped
|
||||
row.preview.Reason = "当前归档不存在"
|
||||
plan.rows = append(plan.rows, row)
|
||||
continue
|
||||
}
|
||||
|
||||
file := stage.File(target.index)
|
||||
var matchCount int
|
||||
var warning []string
|
||||
var beforeText string
|
||||
var afterText string
|
||||
var changed bool
|
||||
var err error
|
||||
switch mode {
|
||||
case BatchModeText:
|
||||
if file.DataType != pvf.TypeScript && file.DataType != pvf.TypeUnicode {
|
||||
row.preview.Status = BatchFileSkipped
|
||||
row.preview.Reason = fmt.Sprintf("不支持文本编辑的数据类型 %d", file.DataType)
|
||||
plan.rows = append(plan.rows, row)
|
||||
continue
|
||||
}
|
||||
before, readErr := stage.Text(target.index)
|
||||
if readErr != nil {
|
||||
err = readErr
|
||||
break
|
||||
}
|
||||
beforeText = before
|
||||
var after string
|
||||
after, matchCount, err = replaceBatchText(before, *request.Text)
|
||||
if err == nil && matchCount > 0 && after != before {
|
||||
if err = stage.SetText(target.index, after); err == nil {
|
||||
afterText = after
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if err == nil && matchCount > 0 && !changed {
|
||||
row.preview.Status = BatchFileSkipped
|
||||
row.preview.Reason = "匹配成功,但替换后内容未变化"
|
||||
}
|
||||
case BatchModeStructured:
|
||||
if file.DataType != pvf.TypeScript {
|
||||
row.preview.Status = BatchFileSkipped
|
||||
row.preview.Reason = "结构化操作只支持 TypeScript"
|
||||
plan.rows = append(plan.rows, row)
|
||||
continue
|
||||
}
|
||||
raw, readErr := stage.RawBytes(target.index)
|
||||
if readErr != nil {
|
||||
err = readErr
|
||||
break
|
||||
}
|
||||
beforeText, err = stage.Text(target.index)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var transformed pvf.BatchTransformResult
|
||||
transformed, err = stage.TransformStructuredBatch(raw, operations)
|
||||
if err == nil {
|
||||
matchCount = transformed.MatchCount()
|
||||
warning = transformed.Warnings()
|
||||
changed = transformed.Changed()
|
||||
if changed {
|
||||
if err = stage.SetRawBytes(target.index, transformed.Raw()); err == nil {
|
||||
afterText, err = stage.Text(target.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && !changed {
|
||||
row.preview.Status = BatchFileSkipped
|
||||
if len(warning) > 0 {
|
||||
row.preview.Reason = strings.Join(warning, ";")
|
||||
} else {
|
||||
row.preview.Reason = "操作未产生变化"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row.preview.MatchCount = matchCount
|
||||
row.preview.Warnings = append([]string(nil), warning...)
|
||||
if err != nil {
|
||||
row.preview.Status = BatchFileError
|
||||
row.preview.Reason = err.Error()
|
||||
plan.rows = append(plan.rows, row)
|
||||
continue
|
||||
}
|
||||
if matchCount > 0 {
|
||||
plan.matchedFiles++
|
||||
plan.matchedOccurrences += matchCount
|
||||
}
|
||||
if changed {
|
||||
row.preview.Status = BatchFileChanged
|
||||
row.afterText = afterText
|
||||
row.preview.Diff, row.preview.DiffTruncated = buildBatchDiff(beforeText, afterText)
|
||||
plan.changedFiles++
|
||||
}
|
||||
if row.preview.Status != "" {
|
||||
plan.rows = append(plan.rows, row)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceBatchText(text string, spec TextReplaceSpec) (string, int, error) {
|
||||
if spec.Regex {
|
||||
re, err := regexp.Compile(spec.Find)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("正则表达式无效: %w", err)
|
||||
}
|
||||
matches := re.FindAllStringIndex(text, -1)
|
||||
return re.ReplaceAllString(text, spec.Replacement), len(matches), nil
|
||||
}
|
||||
return strings.ReplaceAll(text, spec.Find, spec.Replacement), strings.Count(text, spec.Find), nil
|
||||
}
|
||||
|
||||
func batchPreviewPageFromPlan(plan *batchPlan, cursor, limit int) *BatchPreviewPage {
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
if cursor > len(plan.rows) {
|
||||
cursor = len(plan.rows)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = batchPreviewPageSize
|
||||
}
|
||||
end := cursor + limit
|
||||
if end > len(plan.rows) {
|
||||
end = len(plan.rows)
|
||||
}
|
||||
rows := make([]*BatchFilePreview, 0, end-cursor)
|
||||
for index := cursor; index < end; index++ {
|
||||
preview := plan.rows[index].preview
|
||||
preview.Warnings = append([]string(nil), preview.Warnings...)
|
||||
preview.Diff = append([]*BatchDiffLine(nil), preview.Diff...)
|
||||
rows = append(rows, &preview)
|
||||
}
|
||||
next := -1
|
||||
if end < len(plan.rows) {
|
||||
next = end
|
||||
}
|
||||
return &BatchPreviewPage{
|
||||
PlanID: plan.id,
|
||||
NextCursor: next,
|
||||
RequestedFiles: plan.requestedFiles,
|
||||
MatchedFiles: plan.matchedFiles,
|
||||
MatchedOccurrences: plan.matchedOccurrences,
|
||||
ChangedFiles: plan.changedFiles,
|
||||
Rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
type batchDiffOp struct {
|
||||
kind byte
|
||||
text string
|
||||
}
|
||||
|
||||
func buildBatchDiff(before, after string) ([]*BatchDiffLine, bool) {
|
||||
oldLines := splitBatchLines(before)
|
||||
newLines := splitBatchLines(after)
|
||||
ops := batchDiffOps(oldLines, newLines)
|
||||
if len(ops) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
changed := make([]bool, len(ops))
|
||||
for index, op := range ops {
|
||||
changed[index] = op.kind != '='
|
||||
}
|
||||
for index, op := range ops {
|
||||
if op.kind != '=' {
|
||||
start := index - batchDiffContext
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := index + batchDiffContext + 1
|
||||
if end > len(ops) {
|
||||
end = len(ops)
|
||||
}
|
||||
for context := start; context < end; context++ {
|
||||
changed[context] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]*BatchDiffLine, 0)
|
||||
oldLine, newLine := 1, 1
|
||||
for index, op := range ops {
|
||||
line := &BatchDiffLine{Text: op.text, OldLine: oldLine, NewLine: newLine}
|
||||
switch op.kind {
|
||||
case '=':
|
||||
line.Kind = "context"
|
||||
oldLine++
|
||||
newLine++
|
||||
case '-':
|
||||
line.Kind = "remove"
|
||||
oldLine++
|
||||
case '+':
|
||||
line.Kind = "add"
|
||||
newLine++
|
||||
}
|
||||
if changed[index] {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
if len(result) <= batchMaxDiffLines {
|
||||
return result, false
|
||||
}
|
||||
return result[:batchMaxDiffLines], true
|
||||
}
|
||||
|
||||
func splitBatchLines(text string) []string {
|
||||
text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(text, "\n")
|
||||
}
|
||||
|
||||
// batchDiffOps diffs two line slices. It first strips the common prefix and
|
||||
// suffix so the quadratic middle-diff only ever sees the region that actually
|
||||
// changed; a small edit inside a large file therefore stays a small diff
|
||||
// instead of tripping the size fallback below.
|
||||
func batchDiffOps(oldLines, newLines []string) []batchDiffOp {
|
||||
prefix := 0
|
||||
for prefix < len(oldLines) && prefix < len(newLines) && oldLines[prefix] == newLines[prefix] {
|
||||
prefix++
|
||||
}
|
||||
suffix := 0
|
||||
for suffix < len(oldLines)-prefix && suffix < len(newLines)-prefix &&
|
||||
oldLines[len(oldLines)-1-suffix] == newLines[len(newLines)-1-suffix] {
|
||||
suffix++
|
||||
}
|
||||
|
||||
middle := diffBatchMiddle(
|
||||
oldLines[prefix:len(oldLines)-suffix],
|
||||
newLines[prefix:len(newLines)-suffix],
|
||||
)
|
||||
result := make([]batchDiffOp, 0, len(oldLines)+len(newLines))
|
||||
for _, line := range oldLines[:prefix] {
|
||||
result = append(result, batchDiffOp{kind: '=', text: line})
|
||||
}
|
||||
result = append(result, middle...)
|
||||
for _, line := range oldLines[len(oldLines)-suffix:] {
|
||||
result = append(result, batchDiffOp{kind: '=', text: line})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func diffBatchMiddle(oldLines, newLines []string) []batchDiffOp {
|
||||
// The bounded dynamic-programming path gives compact diffs for normal
|
||||
// scripts. Very large changed regions fall back to a safe remove/add block
|
||||
// rather than allocating a quadratic matrix.
|
||||
if len(oldLines) == 0 {
|
||||
result := make([]batchDiffOp, 0, len(newLines))
|
||||
for _, line := range newLines {
|
||||
result = append(result, batchDiffOp{kind: '+', text: line})
|
||||
}
|
||||
return result
|
||||
}
|
||||
if len(newLines) == 0 {
|
||||
result := make([]batchDiffOp, 0, len(oldLines))
|
||||
for _, line := range oldLines {
|
||||
result = append(result, batchDiffOp{kind: '-', text: line})
|
||||
}
|
||||
return result
|
||||
}
|
||||
if len(oldLines) > 1200 || len(newLines) > 1200 || len(oldLines)*len(newLines) > 900000 {
|
||||
result := make([]batchDiffOp, 0, len(oldLines)+len(newLines))
|
||||
for _, line := range oldLines {
|
||||
result = append(result, batchDiffOp{kind: '-', text: line})
|
||||
}
|
||||
for _, line := range newLines {
|
||||
result = append(result, batchDiffOp{kind: '+', text: line})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
rows := make([][]int, len(oldLines)+1)
|
||||
for row := range rows {
|
||||
rows[row] = make([]int, len(newLines)+1)
|
||||
}
|
||||
for row := len(oldLines) - 1; row >= 0; row-- {
|
||||
for column := len(newLines) - 1; column >= 0; column-- {
|
||||
if oldLines[row] == newLines[column] {
|
||||
rows[row][column] = rows[row+1][column+1] + 1
|
||||
} else if rows[row+1][column] >= rows[row][column+1] {
|
||||
rows[row][column] = rows[row+1][column]
|
||||
} else {
|
||||
rows[row][column] = rows[row][column+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]batchDiffOp, 0, len(oldLines)+len(newLines))
|
||||
row, column := 0, 0
|
||||
for row < len(oldLines) && column < len(newLines) {
|
||||
if oldLines[row] == newLines[column] {
|
||||
result = append(result, batchDiffOp{kind: '=', text: oldLines[row]})
|
||||
row++
|
||||
column++
|
||||
} else if rows[row+1][column] >= rows[row][column+1] {
|
||||
result = append(result, batchDiffOp{kind: '-', text: oldLines[row]})
|
||||
row++
|
||||
} else {
|
||||
result = append(result, batchDiffOp{kind: '+', text: newLines[column]})
|
||||
column++
|
||||
}
|
||||
}
|
||||
for row < len(oldLines) {
|
||||
result = append(result, batchDiffOp{kind: '-', text: oldLines[row]})
|
||||
row++
|
||||
}
|
||||
for column < len(newLines) {
|
||||
result = append(result, batchDiffOp{kind: '+', text: newLines[column]})
|
||||
column++
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user