服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1030 lines
31 KiB
Go
1030 lines
31 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"pvfine/internal/pvf"
|
|
pvfversion "pvfine/internal/version"
|
|
)
|
|
|
|
// ArchiveService: 打开/关闭归档、状态查询、资源树懒加载与搜索。
|
|
type ArchiveService struct{ c *core }
|
|
|
|
func NewArchiveService(c *core) *ArchiveService { return &ArchiveService{c: c} }
|
|
|
|
// ArchiveInfo 是前端可观察的归档状态快照。
|
|
type ArchiveInfo = pvf.ArchiveInfoView
|
|
|
|
// FileRegistration describes one indexed id/path entry in an archive list.
|
|
type FileRegistration struct {
|
|
ID string `json:"id"`
|
|
Category string `json:"category"`
|
|
FileIndex int32 `json:"fileIndex"`
|
|
FilePath string `json:"filePath"`
|
|
ListFileIndex int32 `json:"listFileIndex"`
|
|
ListPath string `json:"listPath"`
|
|
EntryPath string `json:"entryPath"`
|
|
}
|
|
|
|
// OpenDialog 弹出文件选择框并加载归档。
|
|
func (s *ArchiveService) OpenDialog() (*ArchiveInfo, error) {
|
|
path, err := application.Get().Dialog.OpenFile().
|
|
CanChooseFiles(true).
|
|
CanChooseDirectories(false).
|
|
AddFilter("PVF 归档", "*.pvf").
|
|
AddFilter("所有文件", "*").
|
|
SetTitle("打开 PVF 归档").
|
|
PromptForSingleSelection()
|
|
if err != nil {
|
|
return nil, err // 用户取消等
|
|
}
|
|
if path == "" {
|
|
return nil, nil
|
|
}
|
|
info, err := s.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// Open 加载指定路径的归档并构建目录索引。
|
|
func (s *ArchiveService) Open(path string) (ArchiveInfo, error) {
|
|
return s.c.openArchive(path)
|
|
}
|
|
|
|
// openArchive installs path as the working archive: parse, derived indexes,
|
|
// background version session and the semantic search index. It is shared by
|
|
// the open dialog and the backup recovery so both produce identical state.
|
|
func (c *core) openArchive(path string) (ArchiveInfo, error) {
|
|
startedAt := time.Now()
|
|
if _, err := os.Stat(path); err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
a, err := c.openArchiveWithRememberedTextEncoding(path, path, "")
|
|
if err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
return c.installOpenedArchive(path, a, startedAt)
|
|
}
|
|
|
|
func (c *core) installOpenedArchive(path string, a *pvf.Archive, startedAt time.Time) (ArchiveInfo, error) {
|
|
if a.FileCount() >= largeArchiveIndexThreshold {
|
|
generation, ctx, err := c.setArchiveWithDeferredDiskIndex(a)
|
|
if err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
c.mu.Lock()
|
|
c.mcpWorkspaceID = mcpID()
|
|
c.mcpEpoch++
|
|
c.mcpSourceHash = a.SourceHash()
|
|
c.mu.Unlock()
|
|
c.recordOpenDuration(time.Since(startedAt))
|
|
info := a.Info()
|
|
emitEvent("archive:opened", info)
|
|
c.startVersionLoad(path, a)
|
|
c.mu.RLock()
|
|
status := c.indexStatus
|
|
c.mu.RUnlock()
|
|
emitEvent("archive:index-progress", status)
|
|
c.buildInitialDiskIndexAsync(a, generation, ctx)
|
|
return info, nil
|
|
}
|
|
if err := c.setArchive(a); err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
c.mu.Lock()
|
|
c.mcpWorkspaceID = mcpID()
|
|
c.mcpEpoch++
|
|
c.mcpSourceHash = a.SourceHash()
|
|
c.mu.Unlock()
|
|
c.recordOpenDuration(time.Since(startedAt))
|
|
info := a.Info()
|
|
// Version repository discovery/recovery is deliberately detached from the
|
|
// normal open path. The raw PVF and its tree are usable immediately; the
|
|
// background task will replace the in-memory archive only when recovery is
|
|
// actually needed.
|
|
c.startVersionLoad(path, a)
|
|
emitEvent("archive:opened", info)
|
|
c.startSearchIndex()
|
|
return info, nil
|
|
}
|
|
|
|
// Close 关闭当前归档,丢弃未保存的内存修改。
|
|
func (s *ArchiveService) Close() {
|
|
s.c.mu.RLock()
|
|
sourcePath := ""
|
|
if s.c.archive != nil {
|
|
sourcePath = s.c.archive.SourcePath()
|
|
}
|
|
s.c.mu.RUnlock()
|
|
s.c.closeArchive()
|
|
if s.c.autosave != nil {
|
|
// The workspace is gone; a backup of its discarded edits would only
|
|
// ask for a restore that no longer has a session behind it.
|
|
s.c.autosave.DropForSource(sourcePath)
|
|
}
|
|
emitEvent("archive:closed")
|
|
}
|
|
|
|
// Info 返回当前归档状态;未打开时 Path 为空。
|
|
func (s *ArchiveService) Info() ArchiveInfo {
|
|
info, _ := s.c.archiveInfo()
|
|
return info
|
|
}
|
|
|
|
// IndexStatus 返回当前归档的语义搜索索引状态。
|
|
func (s *ArchiveService) IndexStatus() IndexStatus {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
status := s.c.indexStatus
|
|
if s.c.archive != nil {
|
|
stats := s.c.archive.StringTableIndexStats()
|
|
status.StringTableIndex = &stats
|
|
}
|
|
return status
|
|
}
|
|
|
|
// RebuildSearchIndex starts an asynchronous forced rebuild. An existing ready
|
|
// snapshot remains available to Search while the replacement is prepared.
|
|
func (s *ArchiveService) RebuildSearchIndex() (IndexStatus, error) {
|
|
developmentLog("[pvfine:index] forced rebuild requested")
|
|
s.c.mu.Lock()
|
|
if s.c.archive == nil {
|
|
s.c.mu.Unlock()
|
|
return IndexStatus{}, ErrNoArchive
|
|
}
|
|
if s.c.diskIndex != nil && s.c.diskIndex.entries == nil {
|
|
if err := s.c.rebuildArchiveIndexesLocked(s.c.archive); err != nil {
|
|
s.c.mu.Unlock()
|
|
return IndexStatus{}, err
|
|
}
|
|
}
|
|
s.c.mu.Unlock()
|
|
s.c.startSearchIndexForced()
|
|
return s.IndexStatus(), nil
|
|
}
|
|
|
|
// ListChildren 懒加载某目录的直接子节点;path 为空表示根。
|
|
func (s *ArchiveService) ListChildren(path string) ([]*TreeNode, error) {
|
|
defer debugPhase("ListChildren", path)()
|
|
wait := time.Now()
|
|
diagnoseCoreLock("ListChildren", fmt.Sprintf("path=%s", path), s.c.mu.RLock)
|
|
debugLockAcquired("ListChildren", wait)
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
if s.c.diskIndex != nil {
|
|
queryStarted := time.Now()
|
|
terminalDebugLog("ListChildren.query started path=%s", path)
|
|
result, err := s.c.diskIndex.children(path)
|
|
terminalDirectoryStage(path, "query", queryStarted)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
finish := debugPhase("ListChildren.metadata", fmt.Sprintf("path=%s children=%d sqlite=true", path, len(result)))
|
|
var annotationsTime, tagsTime, visualsTime time.Duration
|
|
files := 0
|
|
for _, node := range result {
|
|
stageStarted := time.Now()
|
|
node.Annotations = s.c.annotationsForPathLocked(node.Path, node.IsDir)
|
|
elapsed := time.Since(stageStarted)
|
|
annotationsTime += elapsed
|
|
terminalSlowDirectoryNode(node.Path, "annotations", elapsed)
|
|
if !node.IsDir {
|
|
files++
|
|
node.ChangeKind = archiveChangeKind(s.c.archive, node.FileIndex)
|
|
stageStarted = time.Now()
|
|
node.Tags, _ = s.c.diskIndex.tags(node.FileIndex)
|
|
elapsed = time.Since(stageStarted)
|
|
tagsTime += elapsed
|
|
terminalSlowDirectoryNode(node.Path, "tags", elapsed)
|
|
stageStarted = time.Now()
|
|
visuals := s.c.diskIndex.visuals(node.FileIndex)
|
|
node.Icon = cloneImageReference(visuals.icon)
|
|
node.FieldImage = cloneImageReference(visuals.fieldImage)
|
|
elapsed = time.Since(stageStarted)
|
|
visualsTime += elapsed
|
|
terminalSlowDirectoryNode(node.Path, "visuals", elapsed)
|
|
}
|
|
}
|
|
terminalDebugLog("ListChildren.metadata path=%s children=%d files=%d annotations=%s tags=%s visuals=%s",
|
|
path, len(result), files, annotationsTime, tagsTime, visualsTime)
|
|
finish()
|
|
return result, nil
|
|
}
|
|
list := s.c.dirChildren[path]
|
|
if list == nil {
|
|
return []*TreeNode{}, nil
|
|
}
|
|
result := make([]*TreeNode, len(list))
|
|
for i, node := range list {
|
|
copyNode := *node
|
|
copyNode.Annotations = cloneTreeAnnotations(s.c.pathAnnotations[copyNode.Path])
|
|
if !copyNode.IsDir {
|
|
copyNode.ChangeKind = archiveChangeKind(s.c.archive, copyNode.FileIndex)
|
|
copyNode.Tags = cloneTreeTags(s.c.treeTagsByFile[copyNode.FileIndex])
|
|
visuals := s.c.visualsByFile[copyNode.FileIndex]
|
|
copyNode.Icon = cloneImageReference(visuals.icon)
|
|
copyNode.FieldImage = cloneImageReference(visuals.fieldImage)
|
|
}
|
|
result[i] = ©Node
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListDescendantFiles returns all files below a directory path. An empty path
|
|
// returns every file in the archive. Results preserve the archive path order.
|
|
func (s *ArchiveService) ListDescendantFiles(scopePath string) ([]*TreeNode, error) {
|
|
scopePath = strings.Trim(strings.ReplaceAll(scopePath, "\\", "/"), "/")
|
|
|
|
finishTask := s.c.archiveTasks.begin()
|
|
defer finishTask()
|
|
s.c.mu.RLock()
|
|
if s.c.archive == nil {
|
|
s.c.mu.RUnlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
if s.c.diskIndex != nil {
|
|
index := s.c.diskIndex
|
|
archive := s.c.archive
|
|
s.c.mu.RUnlock()
|
|
descendantsStartedAt := time.Now()
|
|
developmentLog("[pvfine:index] descendants scan started: scope=%s", scopePath)
|
|
result, err := index.descendants(scopePath)
|
|
if err != nil {
|
|
log.Printf("[pvfine:index] descendants scan failed: scope=%s elapsed=%s error=%v", scopePath, time.Since(descendantsStartedAt).Round(time.Millisecond), err)
|
|
return nil, err
|
|
}
|
|
const annotationChunkSize = 2048
|
|
for start := 0; start < len(result); start += annotationChunkSize {
|
|
end := start + annotationChunkSize
|
|
if end > len(result) {
|
|
end = len(result)
|
|
}
|
|
s.c.mu.RLock()
|
|
if s.c.archive != archive || s.c.diskIndex != index {
|
|
s.c.mu.RUnlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
for _, node := range result[start:end] {
|
|
node.ChangeKind = archiveChangeKind(archive, node.FileIndex)
|
|
node.Annotations = s.c.annotationsForPathLocked(node.Path)
|
|
node.Tags, _ = index.tags(node.FileIndex)
|
|
visuals := index.visuals(node.FileIndex)
|
|
node.Icon = cloneImageReference(visuals.icon)
|
|
node.FieldImage = cloneImageReference(visuals.fieldImage)
|
|
}
|
|
s.c.mu.RUnlock()
|
|
}
|
|
developmentLog("[pvfine:index] descendants scan finished: scope=%s rows=%d elapsed=%s", scopePath, len(result), time.Since(descendantsStartedAt).Round(time.Millisecond))
|
|
return result, nil
|
|
}
|
|
defer s.c.mu.RUnlock()
|
|
|
|
prefix := scopePath
|
|
start := 0
|
|
if scopePath != "" {
|
|
prefix += "/"
|
|
start = sort.Search(len(s.c.sortedPaths), func(i int) bool {
|
|
return s.c.sortedPaths[i].path >= prefix
|
|
})
|
|
}
|
|
|
|
result := make([]*TreeNode, 0)
|
|
for _, entry := range s.c.sortedPaths[start:] {
|
|
if scopePath != "" && !strings.HasPrefix(entry.path, prefix) {
|
|
break
|
|
}
|
|
visuals := s.c.visualsByFile[entry.idx]
|
|
result = append(result, &TreeNode{
|
|
Name: pathBase(entry.path),
|
|
Path: entry.path,
|
|
Size: entry.size,
|
|
DataType: entry.typ,
|
|
FileIndex: entry.idx,
|
|
ChangeKind: archiveChangeKind(s.c.archive, entry.idx),
|
|
Tags: cloneTreeTags(s.c.treeTagsByFile[entry.idx]),
|
|
Annotations: cloneTreeAnnotations(s.c.pathAnnotations[entry.path]),
|
|
Icon: cloneImageReference(visuals.icon),
|
|
FieldImage: cloneImageReference(visuals.fieldImage),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListModifiedFiles lists the files currently changed in the in-memory PVF
|
|
// overlay. It deliberately does not depend on the optional version sidecar,
|
|
// so the version panel can show pending edits before version control is
|
|
// initialized.
|
|
func (s *ArchiveService) ListModifiedFiles() ([]*TreeNode, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
|
|
result := make([]*TreeNode, 0, s.c.archive.ModifiedCount())
|
|
for index := int32(0); index < s.c.archive.FileCount(); index++ {
|
|
if !s.c.archive.IsModified(index) {
|
|
continue
|
|
}
|
|
file := s.c.archive.File(index)
|
|
path := s.c.archive.Path(index)
|
|
result = append(result, &TreeNode{
|
|
Name: pathBase(path),
|
|
Path: path,
|
|
Size: file.DataSize,
|
|
DataType: file.DataType,
|
|
FileIndex: index,
|
|
ChangeKind: archiveChangeKind(s.c.archive, index),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ResolveFiles resolves archive files by their normalized paths. Missing
|
|
// paths are omitted and duplicate input paths are returned only once.
|
|
func (s *ArchiveService) ResolveFiles(paths []string) ([]*TreeNode, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
if s.c.diskIndex != nil {
|
|
result := make([]*TreeNode, 0, len(paths))
|
|
seen := make(map[string]struct{}, len(paths))
|
|
for _, rawPath := range paths {
|
|
filePath := strings.Trim(strings.ReplaceAll(rawPath, "\\", "/"), "/")
|
|
if filePath == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[filePath]; ok {
|
|
continue
|
|
}
|
|
seen[filePath] = struct{}{}
|
|
node, err := s.c.diskIndex.resolve(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if node == nil {
|
|
continue
|
|
}
|
|
node.ChangeKind = archiveChangeKind(s.c.archive, node.FileIndex)
|
|
node.Annotations = s.c.annotationsForPathLocked(node.Path)
|
|
node.Tags, _ = s.c.diskIndex.tags(node.FileIndex)
|
|
visuals := s.c.diskIndex.visuals(node.FileIndex)
|
|
node.Icon = cloneImageReference(visuals.icon)
|
|
node.FieldImage = cloneImageReference(visuals.fieldImage)
|
|
result = append(result, node)
|
|
}
|
|
return result, nil
|
|
}
|
|
if s.c.indexStatus.Stage == "file-index" {
|
|
result := make([]*TreeNode, 0, len(paths))
|
|
seen := make(map[string]struct{}, len(paths))
|
|
for _, rawPath := range paths {
|
|
filePath := strings.Trim(strings.ReplaceAll(rawPath, "\\", "/"), "/")
|
|
if filePath == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[filePath]; ok {
|
|
continue
|
|
}
|
|
seen[filePath] = struct{}{}
|
|
index, ok := s.c.archive.Find(filePath)
|
|
if !ok {
|
|
continue
|
|
}
|
|
file := s.c.archive.File(index)
|
|
result = append(result, &TreeNode{
|
|
Name: pathBase(filePath), Path: filePath, FileIndex: index,
|
|
Size: file.DataSize, DataType: file.DataType,
|
|
ChangeKind: archiveChangeKind(s.c.archive, index),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
result := make([]*TreeNode, 0, len(paths))
|
|
seen := make(map[string]struct{}, len(paths))
|
|
for _, rawPath := range paths {
|
|
filePath := strings.Trim(strings.ReplaceAll(rawPath, "\\", "/"), "/")
|
|
if filePath == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[filePath]; ok {
|
|
continue
|
|
}
|
|
seen[filePath] = struct{}{}
|
|
|
|
index := sort.Search(len(s.c.sortedPaths), func(i int) bool {
|
|
return s.c.sortedPaths[i].path >= filePath
|
|
})
|
|
if index >= len(s.c.sortedPaths) || s.c.sortedPaths[index].path != filePath {
|
|
continue
|
|
}
|
|
entry := s.c.sortedPaths[index]
|
|
visuals := s.c.visualsByFile[entry.idx]
|
|
result = append(result, &TreeNode{
|
|
Name: pathBase(entry.path),
|
|
Path: entry.path,
|
|
Size: entry.size,
|
|
DataType: entry.typ,
|
|
FileIndex: entry.idx,
|
|
ChangeKind: archiveChangeKind(s.c.archive, entry.idx),
|
|
Tags: cloneTreeTags(s.c.treeTagsByFile[entry.idx]),
|
|
Annotations: cloneTreeAnnotations(s.c.pathAnnotations[entry.path]),
|
|
Icon: cloneImageReference(visuals.icon),
|
|
FieldImage: cloneImageReference(visuals.fieldImage),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// FindFileRegistrations returns configured .lst entries that point to the
|
|
// supplied file indexes. It uses the same relation definitions as the search
|
|
// index, including contextual skill lists.
|
|
func (s *ArchiveService) FindFileRegistrations(fileIndexes []int32) ([]*FileRegistration, error) {
|
|
specs := s.c.searchableListSpecs()
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
if err := validateFileIndexes(s.c.archive, fileIndexes); err != nil {
|
|
return nil, err
|
|
}
|
|
return findFileRegistrationsLocked(s.c.archive, specs, fileIndexes), nil
|
|
}
|
|
|
|
// CreateFile adds an empty editable file to the current archive. dataType is
|
|
// pvf.TypeScript or pvf.TypeUnicode; the caller can fill its content through
|
|
// EditorService.SetText afterwards.
|
|
func (s *ArchiveService) CreateFile(path string, dataType int32) (*TreeNode, error) {
|
|
path, err := normalizeNewFilePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dataType != pvf.TypeScript && dataType != pvf.TypeUnicode {
|
|
return nil, fmt.Errorf("不支持的新文件类型: %d", dataType)
|
|
}
|
|
|
|
s.c.mu.Lock()
|
|
a := s.c.archive
|
|
if a == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if _, exists := a.Find(path); exists {
|
|
s.c.mu.Unlock()
|
|
return nil, fmt.Errorf("文件已存在: %s", path)
|
|
}
|
|
mutationCheckpoint := a.MutationCheckpoint()
|
|
var before pvfversion.ContentSnapshot
|
|
if s.c.versionRepo != nil {
|
|
before = make(pvfversion.ContentSnapshot)
|
|
}
|
|
if s.c.diskIndex != nil && s.c.diskIndex.entries != nil {
|
|
a = a.CloneForBatch()
|
|
mutationCheckpoint = a.MutationCheckpoint()
|
|
}
|
|
index := a.AddFile(path, []byte{}, dataType)
|
|
if err := s.c.rebuildArchiveIndexesLocked(a); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo != nil {
|
|
after, snapshotErr := pvfversion.ContentSnapshotFromArchive(a, []string{path})
|
|
if snapshotErr != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, snapshotErr
|
|
}
|
|
if recordErr := s.c.recordVersionMutationLocked("新建文件", before, after); recordErr != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, recordErr
|
|
}
|
|
}
|
|
mutationSummary := a.MutationsSince(mutationCheckpoint)
|
|
a.ClearMutations()
|
|
node := &TreeNode{
|
|
Name: a.File(index).Name,
|
|
Path: a.Path(index),
|
|
Size: 0,
|
|
DataType: dataType,
|
|
FileIndex: index,
|
|
ChangeKind: ChangeKindAdded,
|
|
Annotations: cloneTreeAnnotations(s.c.pathAnnotations[a.Path(index)]),
|
|
Icon: cloneImageReference(s.c.visualsByFile[index].icon),
|
|
FieldImage: cloneImageReference(s.c.visualsByFile[index].fieldImage),
|
|
}
|
|
info := a.Info()
|
|
versioned := s.c.versionRepo != nil
|
|
s.c.mu.Unlock()
|
|
|
|
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
|
|
return nil, err
|
|
}
|
|
emitEvent("archive:changed", info)
|
|
if versioned {
|
|
emitVersionState(s.c, "file-created")
|
|
}
|
|
return node, nil
|
|
}
|
|
|
|
// DeleteFiles removes one or more file entries from the current archive.
|
|
func (s *ArchiveService) DeleteFiles(fileIndexes []int32) ([]string, error) {
|
|
return s.deleteFiles(fileIndexes, false)
|
|
}
|
|
|
|
// DeleteFilesWithRegistrations removes files and, when requested, the
|
|
// configured .lst entries that register those files.
|
|
func (s *ArchiveService) DeleteFilesWithRegistrations(fileIndexes []int32, syncRegistrations bool) ([]string, error) {
|
|
return s.deleteFiles(fileIndexes, syncRegistrations)
|
|
}
|
|
|
|
func (s *ArchiveService) deleteFiles(fileIndexes []int32, syncRegistrations bool) ([]string, error) {
|
|
var specs []searchableListSpec
|
|
if syncRegistrations {
|
|
specs = s.c.searchableListSpecs()
|
|
}
|
|
|
|
s.c.mu.Lock()
|
|
a := s.c.archive
|
|
if a == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if err := validateFileIndexes(a, fileIndexes); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.diskIndex != nil && s.c.diskIndex.entries != nil {
|
|
a = a.CloneForBatch()
|
|
}
|
|
mutationCheckpoint := a.MutationCheckpoint()
|
|
mutationPaths := make([]string, 0, len(fileIndexes))
|
|
for _, index := range fileIndexes {
|
|
mutationPaths = append(mutationPaths, a.Path(index))
|
|
}
|
|
var registrations []*FileRegistration
|
|
if syncRegistrations {
|
|
registrations = findFileRegistrationsLocked(a, specs, fileIndexes)
|
|
for _, registration := range registrations {
|
|
mutationPaths = append(mutationPaths, registration.ListPath)
|
|
}
|
|
}
|
|
var before pvfversion.ContentSnapshot
|
|
if s.c.versionRepo != nil {
|
|
var snapshotErr error
|
|
before, snapshotErr = pvfversion.ContentSnapshotFromArchive(a, mutationPaths)
|
|
if snapshotErr != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, snapshotErr
|
|
}
|
|
}
|
|
if syncRegistrations {
|
|
byList := make(map[int32][]pvf.ListPair)
|
|
for _, registration := range registrations {
|
|
byList[registration.ListFileIndex] = append(
|
|
byList[registration.ListFileIndex],
|
|
pvf.ListPair{ID: registration.ID, Path: registration.EntryPath},
|
|
)
|
|
}
|
|
for listIndex, entries := range byList {
|
|
if _, err := a.RemoveListPairs(listIndex, entries); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
paths, err := a.RemoveFiles(fileIndexes)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if len(paths) == 0 {
|
|
s.c.mu.Unlock()
|
|
return []string{}, nil
|
|
}
|
|
if err := s.c.rebuildArchiveIndexesLocked(a); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo != nil {
|
|
after, snapshotErr := pvfversion.ContentSnapshotFromArchive(a, mutationPaths)
|
|
if snapshotErr != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, snapshotErr
|
|
}
|
|
if recordErr := s.c.recordVersionMutationLocked("删除文件", before, after); recordErr != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, recordErr
|
|
}
|
|
}
|
|
mutationSummary := a.MutationsSince(mutationCheckpoint)
|
|
a.ClearMutations()
|
|
info := a.Info()
|
|
versioned := s.c.versionRepo != nil
|
|
s.c.mu.Unlock()
|
|
|
|
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
|
|
return nil, err
|
|
}
|
|
emitEvent("archive:changed", info)
|
|
if versioned {
|
|
emitVersionState(s.c, "files-deleted")
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func validateFileIndexes(a *pvf.Archive, indexes []int32) error {
|
|
for _, index := range indexes {
|
|
if index < 0 || index >= a.FileCount() {
|
|
return fmt.Errorf("文件索引越界: %d", index)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findFileRegistrationsLocked(a *pvf.Archive, specs []searchableListSpec, fileIndexes []int32) []*FileRegistration {
|
|
targets := make(map[int32]string, len(fileIndexes))
|
|
for _, index := range fileIndexes {
|
|
if index < 0 || index >= a.FileCount() {
|
|
continue
|
|
}
|
|
targets[index] = a.Path(index)
|
|
}
|
|
if len(targets) == 0 {
|
|
return []*FileRegistration{}
|
|
}
|
|
|
|
registrations := make([]*FileRegistration, 0)
|
|
for _, spec := range specs {
|
|
listIndex, ok := a.FindList(spec.listPath)
|
|
if !ok {
|
|
continue
|
|
}
|
|
listPath := a.Path(listIndex)
|
|
pairs, err := a.ScriptListPairs(listIndex)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, pair := range pairs {
|
|
_, targetIndex, ok := findListTargetInArchive(a, listPath, pair.Path)
|
|
if !ok {
|
|
continue
|
|
}
|
|
filePath, selected := targets[targetIndex]
|
|
if !selected {
|
|
continue
|
|
}
|
|
registrations = append(registrations, &FileRegistration{
|
|
ID: pair.ID,
|
|
Category: spec.category,
|
|
FileIndex: targetIndex,
|
|
FilePath: filePath,
|
|
ListFileIndex: listIndex,
|
|
ListPath: listPath,
|
|
EntryPath: pair.Path,
|
|
})
|
|
}
|
|
}
|
|
sort.SliceStable(registrations, func(i, j int) bool {
|
|
left, right := registrations[i], registrations[j]
|
|
if left.FilePath != right.FilePath {
|
|
return left.FilePath < right.FilePath
|
|
}
|
|
if left.ListPath != right.ListPath {
|
|
return left.ListPath < right.ListPath
|
|
}
|
|
return left.ID < right.ID
|
|
})
|
|
return registrations
|
|
}
|
|
|
|
// SuggestDirectories returns directory paths with a case-insensitive prefix
|
|
// match. An empty prefix returns no suggestions to avoid flooding the UI.
|
|
func (s *ArchiveService) SuggestDirectories(prefix string, limit int) ([]string, error) {
|
|
prefix = normalizeAdvancedScope(prefix)
|
|
if prefix == "" {
|
|
return []string{}, nil
|
|
}
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
if s.c.diskIndex != nil {
|
|
return s.c.diskIndex.suggest(prefix, limit)
|
|
}
|
|
result := make([]string, 0, limit)
|
|
for _, path := range s.c.directories {
|
|
if !strings.HasPrefix(strings.ToLower(path), prefix) {
|
|
continue
|
|
}
|
|
result = append(result, path)
|
|
if len(result) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func normalizeNewFilePath(raw string) (string, error) {
|
|
path := strings.Trim(strings.ReplaceAll(strings.TrimSpace(raw), "\\", "/"), "/")
|
|
if path == "" {
|
|
return "", errors.New("文件名不能为空")
|
|
}
|
|
for _, part := range strings.Split(path, "/") {
|
|
if part == "" || part == "." || part == ".." {
|
|
return "", fmt.Errorf("文件路径无效: %q", raw)
|
|
}
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// SearchResult 是一页搜索命中;NextCursor < 0 表示已扫完。
|
|
type SearchResult struct {
|
|
Hits []*SearchHit `json:"hits"`
|
|
NextCursor int `json:"nextCursor"`
|
|
Scanned int `json:"scanned"`
|
|
}
|
|
|
|
// Search 在路径、语义名称和 id 中做不区分大小写的子串匹配。
|
|
// 查询包含 * 或 ? 时,改用通配符匹配:* 匹配任意长度字符,? 匹配一个字符。
|
|
// cursor 传上次返回的 NextCursor(首次传 0),limit 为本页上限(1..1000)。
|
|
func (s *ArchiveService) Search(query string, cursor int, limit int) (*SearchResult, error) {
|
|
return s.search(query, cursor, limit, false)
|
|
}
|
|
|
|
// SearchExact 在路径、语义名称和 id 中做不区分大小写的全量匹配。
|
|
// 查询包含 * 或 ? 时,通配符仍按完整字段匹配。
|
|
// cursor 传上次返回的 NextCursor(首次传 0),limit 为本页上限(1..1000)。
|
|
func (s *ArchiveService) SearchExact(query string, cursor int, limit int) (*SearchResult, error) {
|
|
return s.search(query, cursor, limit, true)
|
|
}
|
|
|
|
func (s *ArchiveService) search(query string, cursor int, limit int, exact bool) (*SearchResult, error) {
|
|
return s.searchScoped(query, cursor, limit, exact, false, "", "")
|
|
}
|
|
|
|
// SearchItems searches only registered equipment and stackable items. Filtering
|
|
// happens before pagination, so unrelated records cannot hide valid choices.
|
|
func (s *ArchiveService) SearchItems(query string, cursor int, limit int) (*SearchResult, error) {
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 30
|
|
}
|
|
var exact *SearchHit
|
|
var exactArchive *pvf.Archive
|
|
firstPage := cursor <= 0
|
|
rawCursor := 0
|
|
if cursor > 0 {
|
|
rawCursor = cursor - 1
|
|
}
|
|
query = strings.TrimSpace(query)
|
|
if _, err := strconv.ParseInt(query, 10, 32); err == nil {
|
|
s.c.mu.Lock()
|
|
if s.c.archive != nil && s.c.annotationEngine != nil {
|
|
if ref, ok := s.c.resolveAnnotationReferenceLocked("物品", query); ok {
|
|
if meta, err := s.c.archive.ScriptMetadata(ref.FileIndex); err == nil {
|
|
file := s.c.archive.File(ref.FileIndex)
|
|
category := SearchCategoryStackable
|
|
if strings.HasSuffix(strings.ToLower(s.c.archive.Path(ref.FileIndex)), ".equ") {
|
|
category = SearchCategoryEquipment
|
|
}
|
|
exact = &SearchHit{ID: query, Name: markedName(meta), Path: s.c.archive.Path(ref.FileIndex), FileIndex: ref.FileIndex, Rarity: meta.RarityValue(), Icon: imageReferenceFromPVF(meta.Icon), Category: category, Size: file.DataSize, DataType: file.DataType}
|
|
exactArchive = s.c.archive
|
|
}
|
|
}
|
|
}
|
|
s.c.mu.Unlock()
|
|
}
|
|
if exact != nil && firstPage && limit == 1 {
|
|
return &SearchResult{Hits: []*SearchHit{exact}, NextCursor: 1}, nil
|
|
}
|
|
excludeID := ""
|
|
if exact != nil {
|
|
excludeID = exact.ID
|
|
}
|
|
if exact != nil && firstPage {
|
|
limit--
|
|
}
|
|
result, err := s.searchScoped(query, rawCursor, limit, false, true, excludeID, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exact != nil {
|
|
s.c.mu.RLock()
|
|
current := s.c.archive == exactArchive
|
|
s.c.mu.RUnlock()
|
|
if !current {
|
|
return nil, fmt.Errorf("归档已变化,请重新搜索")
|
|
}
|
|
if firstPage {
|
|
result.Hits = append([]*SearchHit{exact}, result.Hits...)
|
|
}
|
|
}
|
|
if result.NextCursor >= 0 {
|
|
result.NextCursor++
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *ArchiveService) searchScoped(query string, cursor int, limit int, exact, itemsOnly bool, excludeID, scope string) (*SearchResult, error) {
|
|
return s.searchScopedRecords(query, cursor, limit, exact, itemsOnly, excludeID, scope, false)
|
|
}
|
|
|
|
// SearchQuestTargets searches registered dungeon, monster, or NPC IDs before pagination.
|
|
func (s *ArchiveService) SearchQuestTargets(kind, query string, cursor, limit int) (*SearchResult, error) {
|
|
var scope string
|
|
switch kind {
|
|
case "dungeon", "monster", "npc":
|
|
scope = kind
|
|
default:
|
|
return nil, fmt.Errorf("不支持的任务查询类型: %s", kind)
|
|
}
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 30
|
|
}
|
|
return s.searchScopedRecords(query, cursor, limit, false, false, "", scope, true)
|
|
}
|
|
|
|
func (s *ArchiveService) searchScopedRecords(query string, cursor int, limit int, exact, itemsOnly bool, excludeID, scope string, registeredOnly bool) (*SearchResult, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
res := &SearchResult{Hits: []*SearchHit{}, NextCursor: -1}
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
if s.c.diskIndex != nil {
|
|
if s.c.indexStatus.State == IndexStateBuilding && (registeredOnly || itemsOnly || exact) {
|
|
return nil, ErrSearchIndexing
|
|
}
|
|
if s.c.indexStatus.State == IndexStateError {
|
|
return nil, errors.New(s.c.indexStatus.Error)
|
|
}
|
|
// Bind pagination to both the semantic generation and the query source
|
|
// (path fallback versus complete semantic records).
|
|
epoch := s.c.indexGen * 2
|
|
if s.c.diskIndex.dirty {
|
|
epoch++
|
|
}
|
|
if epoch >= 1<<21 {
|
|
return nil, errors.New("搜索会话编号已耗尽,请重新打开归档")
|
|
}
|
|
if cursor > 0 {
|
|
if uint64(cursor)>>32 != epoch {
|
|
return nil, errors.New("搜索游标已失效,请重新搜索")
|
|
}
|
|
cursor = int(uint64(cursor) & ((1 << 32) - 1))
|
|
}
|
|
result, err := s.c.diskIndex.searchScopedRecords(query, cursor, limit, exact, itemsOnly, excludeID, scope, registeredOnly)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result.NextCursor >= 0 {
|
|
result.NextCursor = int(epoch<<32 | uint64(result.NextCursor))
|
|
}
|
|
for _, hit := range result.Hits {
|
|
hit.ChangeKind = archiveChangeKind(s.c.archive, hit.FileIndex)
|
|
hit.Annotations = s.c.annotationsForPathLocked(hit.Path)
|
|
hit.PathAnnotations = s.c.annotationChainLocked(hit.Path)
|
|
visuals := s.c.diskIndex.visuals(hit.FileIndex)
|
|
hit.Icon = cloneImageReference(visuals.icon)
|
|
hit.FieldImage = cloneImageReference(visuals.fieldImage)
|
|
hit.Rarity = visuals.rarity
|
|
}
|
|
return result, nil
|
|
}
|
|
q := strings.ToLower(strings.TrimSpace(query))
|
|
if q == "" {
|
|
return res, nil
|
|
}
|
|
if s.c.indexStatus.State == IndexStateBuilding || s.c.indexStatus.State == IndexStateIdle {
|
|
return nil, ErrSearchIndexing
|
|
}
|
|
if s.c.indexStatus.State == IndexStateError {
|
|
return nil, errors.New(s.c.indexStatus.Error)
|
|
}
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 200
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
matcher := newSearchMatcher(q, exact)
|
|
records := s.c.searchRecords
|
|
i := cursor
|
|
for ; i < len(records) && len(res.Hits) < limit; i++ {
|
|
record := &records[i]
|
|
if !advancedPathInScope(record.hit.Path, scope) {
|
|
continue
|
|
}
|
|
if registeredOnly && record.hit.ID == "" {
|
|
continue
|
|
}
|
|
if excludeID != "" && record.hit.ID == excludeID {
|
|
continue
|
|
}
|
|
if itemsOnly && record.hit.Category != SearchCategoryEquipment && record.hit.Category != SearchCategoryStackable {
|
|
continue
|
|
}
|
|
matched := matcher.match(record.lowerPath) ||
|
|
matcher.match(record.lowerName) ||
|
|
matcher.match(record.lowerID)
|
|
if matched {
|
|
hit := record.hit
|
|
hit.ChangeKind = archiveChangeKind(s.c.archive, hit.FileIndex)
|
|
hit.Annotations = cloneTreeAnnotations(s.c.pathAnnotations[hit.Path])
|
|
hit.PathAnnotations = clonePathAnnotationChain(s.c.pathAnnotations, hit.Path)
|
|
res.Hits = append(res.Hits, &hit)
|
|
}
|
|
}
|
|
if i < len(records) {
|
|
res.NextCursor = i
|
|
}
|
|
res.Scanned = i
|
|
return res, nil
|
|
}
|
|
|
|
func clonePathAnnotationChain(values map[string][]TreeAnnotation, filePath string) map[string][]TreeAnnotation {
|
|
result := make(map[string][]TreeAnnotation)
|
|
current := filePath
|
|
for current != "" {
|
|
if annotations := cloneTreeAnnotations(values[current]); len(annotations) > 0 {
|
|
result[current] = annotations
|
|
}
|
|
current, _ = splitParent(current)
|
|
}
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetTextEncoding reinterprets native text without rewriting the archive.
|
|
func (s *ArchiveService) SetTextEncoding(encoding string) (ArchiveInfo, error) {
|
|
s.c.mu.Lock()
|
|
if s.c.archive == nil {
|
|
s.c.mu.Unlock()
|
|
return ArchiveInfo{}, ErrNoArchive
|
|
}
|
|
if s.c.versionLoading || s.c.unpackRunning.Load() {
|
|
s.c.mu.Unlock()
|
|
return ArchiveInfo{}, errors.New("请等待版本控制加载或解包完成后再切换编码")
|
|
}
|
|
current := s.c.archive
|
|
if current.TextEncoding() == encoding {
|
|
info := current.Info()
|
|
s.c.mu.Unlock()
|
|
return info, nil
|
|
}
|
|
next, err := current.WithTextEncoding(encoding)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return ArchiveInfo{}, err
|
|
}
|
|
prepared, err := prepareArchiveDerivedIndexes(next)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return ArchiveInfo{}, err
|
|
}
|
|
if err := s.c.settings.rememberArchiveTextEncoding(current.SourcePath(), next.TextEncoding()); err != nil {
|
|
closePreparedArchiveIndexes(prepared)
|
|
s.c.mu.Unlock()
|
|
return ArchiveInfo{}, err
|
|
}
|
|
err = s.c.installPreparedArchiveIndexesLocked(next, prepared, false)
|
|
info := next.Info()
|
|
s.c.mu.Unlock()
|
|
if err != nil {
|
|
return ArchiveInfo{}, err
|
|
}
|
|
emitEvent("archive:reloaded", info)
|
|
s.c.startSearchIndexForced()
|
|
return info, nil
|
|
}
|