Files
90CNServer/pvf/services/archive.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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] = &copyNode
}
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
}