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 }