package services import ( "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "github.com/wailsapp/wails/v3/pkg/application" "pvfine/internal/pvf" ) // EditorService: 文件内容读取、内存编辑、保存/另存为、导出与整包解包。 type EditorService struct { c *core settings *SettingsService } func NewEditorService(c *core, settings ...*SettingsService) *EditorService { service := &EditorService{c: c} if len(settings) > 0 { service.settings = settings[0] } return service } // editableByteLimit reads the current setting so changes apply on the next open. func (s *EditorService) editableByteLimit() int32 { if s.settings != nil { return editableByteLimit(s.settings) } return s.c.editableByteLimit() } func editableByteLimit(service *SettingsService) int32 { if service != nil { if settings, err := service.GetSettings(); err == nil { return int32(settings.MaxEditableSizeMB) << 20 } } return DefaultMaxEditableSizeMB << 20 } // FileMeta 返回给前端的单个文件视图。 type FileMeta struct { Index int32 `json:"index"` Path string `json:"path"` DataType int32 `json:"dataType"` Size int32 `json:"size"` Tags []TreeTag `json:"tags,omitempty"` Editable bool `json:"editable"` Text string `json:"text"` Modified bool `json:"modified"` Annotations []EditorAnnotation `json:"annotations,omitempty"` Icon *ImageReference `json:"icon,omitempty"` FieldImage *ImageReference `json:"fieldImage,omitempty"` } // GetFile 返回文件的反编译文本(内存编辑视图)。 func (s *EditorService) GetFile(index int32) (*FileMeta, error) { return s.getFile(index) } // GetFileBasic returns the rendered file body without annotations or indexed // metadata so the editor can display useful content before enrichment loads. func (s *EditorService) GetFileBasic(index int32) (*FileMeta, error) { defer debugPhase("GetFileBasic", fmt.Sprintf("file=%d", index))() wait := time.Now() diagnoseCoreLock("GetFileBasic", fmt.Sprintf("file=%d", index), s.c.mu.RLock) debugLockAcquired("GetFileBasic", wait) defer s.c.mu.RUnlock() a := s.c.archive if a == nil { return nil, ErrNoArchive } if err := validateAnnotationIndex(a, index); err != nil { return nil, err } file := a.File(index) meta := &FileMeta{ Index: index, Path: a.Path(index), DataType: file.DataType, Size: file.DataSize, Editable: false, Modified: a.IsModified(index), } switch file.DataType { case pvf.TypeScript, pvf.TypeUnicode: if maxEditableBytes := s.editableByteLimit(); file.DataSize > maxEditableBytes { meta.Text = fmt.Sprintf("; 文件过大(%d 字节),超过文本编辑上限 %d 字节。可在设置 → 编辑器中调整大文件编辑阈值,然后关闭并重新打开此文件", file.DataSize, maxEditableBytes) return meta, nil } text, ok := s.c.editorText[index] debugLog("GetFileBasic file=%d edited-text-cache=%t", index, ok) if !ok { var err error finish := debugPhase("GetFileBasic.text", meta.Path) text, err = a.Text(index) finish() if err != nil { return nil, err } } meta.Editable = a.WriteCapabilities().CanSave meta.Text = text default: if strings.HasSuffix(strings.ToLower(meta.Path), ".ani") { return s.readBinaryANI(a, index, meta) } meta.Text = fmt.Sprintf("; 不支持的类型 %d(v1 仅支持脚本/文本编辑)", file.DataType) } return meta, nil } func (s *EditorService) getFile(index int32) (*FileMeta, error) { defer debugPhase("GetFile", fmt.Sprintf("file=%d", index))() wait := time.Now() diagnoseCoreLock("GetFile", fmt.Sprintf("file=%d", index), s.c.mu.Lock) debugLockAcquired("GetFile", wait) defer s.c.mu.Unlock() a := s.c.archive if a == nil { return nil, ErrNoArchive } if err := validateAnnotationIndex(a, index); err != nil { return nil, err } f := a.File(index) meta := &FileMeta{ Index: index, Path: a.Path(index), DataType: f.DataType, Size: f.DataSize, Editable: false, } finishMetadata := debugPhase("GetFile.metadata", meta.Path) meta.Tags = cloneTreeTags(s.c.treeTagsByFile[index]) if s.c.diskIndex != nil { meta.Tags, _ = s.c.diskIndex.tags(index) } visuals := s.c.fileVisualsLocked(index) meta.Icon = cloneImageReference(visuals.icon) meta.FieldImage = cloneImageReference(visuals.fieldImage) finishMetadata() switch f.DataType { case pvf.TypeScript, pvf.TypeUnicode: if maxEditableBytes := s.editableByteLimit(); f.DataSize > maxEditableBytes { meta.Text = fmt.Sprintf("; 文件过大(%d 字节),超过文本编辑上限 %d 字节。可在设置 → 编辑器中调整大文件编辑阈值,然后关闭并重新打开此文件", f.DataSize, maxEditableBytes) return meta, nil } text, ok := s.c.editorText[index] debugLog("GetFile file=%d edited-text-cache=%t", index, ok) if !ok { var err error finish := debugPhase("GetFile.text", meta.Path) text, err = a.Text(index) finish() if err != nil { return nil, err } } meta.Editable = a.WriteCapabilities().CanSave meta.Text = text if f.DataType == pvf.TypeScript { annotations, err := s.c.editorAnnotationsLocked(index, text) if err != nil { return nil, err } meta.Annotations = annotations } default: if strings.HasSuffix(strings.ToLower(meta.Path), ".ani") { return s.readBinaryANI(a, index, meta) } meta.Text = fmt.Sprintf("; 不支持的类型 %d(v1 仅支持脚本/文本编辑)", f.DataType) } meta.Modified = a.IsModified(index) return meta, nil } // GetAnnotations recomputes annotations against the exact current editor text. func (s *EditorService) GetAnnotations(index int32) ([]EditorAnnotation, error) { defer debugPhase("GetAnnotations", fmt.Sprintf("file=%d", index))() wait := time.Now() diagnoseCoreLock("GetAnnotations", fmt.Sprintf("file=%d", index), s.c.mu.Lock) debugLockAcquired("GetAnnotations", wait) defer s.c.mu.Unlock() if s.c.archive == nil { return nil, ErrNoArchive } if err := validateAnnotationIndex(s.c.archive, index); err != nil { return nil, err } if s.c.archive.File(index).DataType != pvf.TypeScript { return []EditorAnnotation{}, nil } text, ok := s.c.editorText[index] if !ok { var err error text, err = s.c.archive.Text(index) if err != nil { return nil, err } } return s.c.editorAnnotationsLocked(index, text) } // GetAnnotationsForText annotates a frontend draft without committing it to the archive. func (s *EditorService) GetAnnotationsForText(index int32, text string) ([]EditorAnnotation, error) { s.c.mu.Lock() defer s.c.mu.Unlock() if s.c.archive == nil { return nil, ErrNoArchive } if err := validateAnnotationIndex(s.c.archive, index); err != nil { return nil, err } if s.c.archive.File(index).DataType != pvf.TypeScript { return []EditorAnnotation{}, nil } return s.c.editorAnnotationsLocked(index, text) } // SetText 把编辑后的文本写入内存 overlay(不落盘)。 func (s *EditorService) SetText(index int32, text string) error { _, _, err := s.c.setText(index, text) return err } // SetPlaceholderText rewrites the display text behind one `` // placeholder of a script. index is the script being edited; tableIndex and key // come from the placeholder annotation. Only the `.str` payload changes — the // script keeps its placeholder, so no stored script data is altered. func (s *EditorService) SetPlaceholderText(index int32, tableIndex int32, key string, text string) error { return s.c.setPlaceholderText(index, tableIndex, key, text) } // Save 把全部内存修改写回源文件(原子写:临时文件 + rename)。 func (s *EditorService) Save() (ArchiveInfo, error) { progress := archiveSaveProgressEmitter() s.c.mu.Lock() a := s.c.archive if a == nil { s.c.mu.Unlock() return ArchiveInfo{}, ErrNoArchive } if err := s.c.ensureVersionReadyLocked(); err != nil { s.c.mu.Unlock() return ArchiveInfo{}, err } if a.SourcePath() == "" { s.c.mu.Unlock() return ArchiveInfo{}, fmt.Errorf("归档没有源文件,请使用另存为") } if s.shouldBackupSource() { if err := backupSourceFileWithProgress(a.SourcePath(), progress); err != nil { s.c.mu.Unlock() return ArchiveInfo{}, err } } if err := a.SaveWithProgress(progress); err != nil { s.c.mu.Unlock() return ArchiveInfo{}, err } s.c.mcpSourceHash = a.SourceHash() s.c.mcpSaveSequence++ s.c.markAdvancedSearchStaleLocked(a) saveMutations := a.MutationsSince(0) a.ClearMutations() if err := s.c.markVersionArtifactSavedLocked(a.SourcePath()); err != nil { s.c.mu.Unlock() return ArchiveInfo{}, err } info := a.Info() sourcePath := a.SourcePath() s.c.mu.Unlock() if err := s.c.scheduleArchiveMutations(a, saveMutations); err != nil { return ArchiveInfo{}, err } s.c.persistCurrentSQLiteIndexAsync() s.c.persistCurrentSearchIndexCacheAsync() if s.c.autosave != nil { // The workspace is on disk now, so the backup slot has nothing left to // protect. A cache belonging to another archive is kept. s.c.autosave.DropForSource(sourcePath) } emitEvent("archive:saved", info) return info, nil } func (s *EditorService) shouldBackupSource() bool { if s.settings == nil { return true } settings, err := s.settings.GetSettings() if err != nil { // 读取设置失败时使用安全默认值,不要因为配置文件问题阻止保存。 return true } return settings.BackupSourceOnSave } func backupSourceFile(sourcePath string) error { return backupSourceFileWithProgress(sourcePath, nil) } // Progress events are throttled per operation; phase transitions and the final // count always arrive, without flooding the webview for every page or chunk. func archiveSaveProgressEmitter() pvf.SaveProgressFunc { var lastPhase string var lastEmit time.Time return func(progress pvf.SaveProgress) { now := time.Now() complete := progress.Total > 0 && progress.Done >= progress.Total if progress.Phase != lastPhase || complete || now.Sub(lastEmit) >= 100*time.Millisecond { emitEvent("archive:save-progress", progress) lastPhase, lastEmit = progress.Phase, now } } } type backupProgressWriter struct { writer io.Writer done, total int64 progress pvf.SaveProgressFunc } func (w *backupProgressWriter) Write(p []byte) (int, error) { n, err := w.writer.Write(p) w.done += int64(n) w.progress(pvf.SaveProgress{Phase: "backup", Done: w.done, Total: w.total, Unit: "bytes"}) return n, err } func backupSourceFileWithProgress(sourcePath string, progress pvf.SaveProgressFunc) error { source, err := os.Open(sourcePath) if err != nil { return fmt.Errorf("打开源文件以创建备份失败: %w", err) } defer source.Close() info, err := source.Stat() if err != nil { return fmt.Errorf("读取源文件信息失败: %w", err) } backupPath := sourcePath + ".bak" temp, err := os.CreateTemp(filepath.Dir(backupPath), "."+filepath.Base(backupPath)+"-*.tmp") if err != nil { return fmt.Errorf("创建源文件备份临时文件失败: %w", err) } tempPath := temp.Name() defer os.Remove(tempPath) mode := info.Mode().Perm() if mode == 0 { mode = 0o644 } if err := temp.Chmod(mode); err != nil { _ = temp.Close() return fmt.Errorf("设置源文件备份权限失败: %w", err) } var destination io.Writer = temp if progress != nil { progress(pvf.SaveProgress{Phase: "backup", Total: info.Size(), Unit: "bytes"}) destination = &backupProgressWriter{writer: temp, total: info.Size(), progress: progress} } if _, err := io.CopyBuffer(destination, source, make([]byte, 1<<20)); err != nil { _ = temp.Close() return fmt.Errorf("写入源文件备份失败: %w", err) } if progress != nil { progress(pvf.SaveProgress{Phase: "backup-sync"}) } if err := temp.Sync(); err != nil { _ = temp.Close() return fmt.Errorf("同步源文件备份失败: %w", err) } if err := temp.Close(); err != nil { return fmt.Errorf("关闭源文件备份失败: %w", err) } if err := os.Rename(tempPath, backupPath); err != nil { return fmt.Errorf("替换源文件备份失败: %w", err) } return nil } // SaveAsDialog 弹出保存对话框并另存为新 PVF。返回保存路径。 func (s *EditorService) SaveAsDialog() (string, error) { finishTask := s.c.archiveTasks.begin() defer finishTask() s.c.mu.RLock() a := s.c.archive if a == nil { s.c.mu.RUnlock() return "", ErrNoArchive } s.c.mu.RUnlock() defName := "Script_new.pvf" if src := a.SourcePath(); src != "" { defName = filepath.Base(src) } path, err := application.Get().Dialog.SaveFile(). SetFilename(defName). AddFilter("PVF 归档", "*.pvf"). SetMessage("另存为 PVF"). PromptForSingleSelection() if err != nil { return "", err } if path == "" { return "", nil // 用户取消 } s.c.mu.RLock() sameArchive := s.c.archive == a s.c.mu.RUnlock() if !sameArchive { return "", ErrNoArchive } return s.saveCopy(path, true, "") } type exportSelection struct { index int32 path string } // ExportFilesDialog 将选中的文件或目录导出到目标目录,文件内容使用渲染后的 UTF-8 文本。 // 目录会递归展开,并保留归档内的相对路径。 func (s *EditorService) ExportFilesDialog(scopes []string) (string, error) { finishTask := s.c.archiveTasks.begin() defer finishTask() s.c.mu.RLock() a := s.c.archive if a == nil { s.c.mu.RUnlock() return "", ErrNoArchive } var selections []exportSelection if s.c.diskIndex != nil { selections = collectDiskExportSelections(a, s.c.diskIndex, scopes) } else { selections = collectExportSelections(a, s.c.sortedPaths, scopes) } s.c.mu.RUnlock() if len(selections) == 0 { return "", fmt.Errorf("没有可导出的文件") } dir, err := application.Get().Dialog.OpenFile(). CanChooseFiles(false). CanChooseDirectories(true). CanCreateDirectories(true). SetTitle("选择导出目标目录"). PromptForSingleSelection() if err != nil { return "", err } if dir == "" { return "", nil } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } s.c.mu.RLock() defer s.c.mu.RUnlock() if s.c.archive != a { return "", ErrNoArchive } for _, selection := range selections { text, err := a.Text(selection.index) if err != nil { return "", fmt.Errorf("渲染 %q 失败: %w", selection.path, err) } dst, err := safeExportPath(dir, selection.path) if err != nil { return "", err } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return "", err } if err := os.WriteFile(dst, []byte(text), 0o644); err != nil { return "", err } } return dir, nil } func collectDiskExportSelections(a *pvf.Archive, index *sqliteArchiveIndex, scopes []string) []exportSelection { selections := make([]exportSelection, 0, len(scopes)) seen := make(map[string]struct{}, len(scopes)) add := func(fileIndex int32, filePath string) { filePath = normalizeExportPath(filePath) if filePath == "" { return } if _, ok := seen[filePath]; ok { return } seen[filePath] = struct{}{} selections = append(selections, exportSelection{index: fileIndex, path: filePath}) } for _, rawScope := range scopes { scope := normalizeExportPath(rawScope) if scope == "" { continue } if fileIndex, ok := a.Find(scope); ok { add(fileIndex, a.Path(fileIndex)) continue } nodes, err := index.descendants(scope) if err != nil { continue } for _, node := range nodes { add(node.FileIndex, node.Path) } } return selections } func collectExportSelections(a *pvf.Archive, sortedPaths []pathEntry, scopes []string) []exportSelection { selections := make([]exportSelection, 0, len(scopes)) seenPaths := make(map[string]struct{}, len(scopes)) add := func(index int32, path string) { path = normalizeExportPath(path) if path == "" { return } if _, exists := seenPaths[path]; exists { return } seenPaths[path] = struct{}{} selections = append(selections, exportSelection{index: index, path: path}) } for _, rawScope := range scopes { scope := normalizeExportPath(rawScope) if scope == "" { continue } if index, ok := a.Find(scope); ok { add(index, a.Path(index)) continue } prefix := scope + "/" start := sort.Search(len(sortedPaths), func(i int) bool { return sortedPaths[i].path >= prefix }) for _, entry := range sortedPaths[start:] { if !strings.HasPrefix(entry.path, prefix) { break } add(entry.idx, entry.path) } } return selections } func normalizeExportPath(path string) string { path = strings.ReplaceAll(path, "\\", "/") return strings.Trim(path, "/") } func safeExportPath(base, internal string) (string, error) { parts := strings.Split(strings.ReplaceAll(internal, "\\", "/"), "/") cleaned := make([]string, 0, len(parts)) for _, part := range parts { switch part { case "", ".": continue case "..": return "", fmt.Errorf("导出路径越界: %q", internal) } cleaned = append(cleaned, sanitizeExportName(part)) } if len(cleaned) == 0 { return "", fmt.Errorf("导出路径为空: %q", internal) } return filepath.Join(append([]string{base}, cleaned...)...), nil } func sanitizeExportName(name string) string { return strings.Map(func(r rune) rune { switch { case r < 0x20 || r == 0x7f: return '_' case strings.ContainsRune(`<>:"|?*`, r): return '_' default: return r } }, name) } // UnpackDialog 选择目录后,在后台协程把整包解包到该目录。 // 进度通过事件 "unpack:progress" {done,total} 推送,结束发 "unpack:done"。 func (s *EditorService) UnpackDialog() (bool, error) { if !s.c.unpackRunning.CompareAndSwap(false, true) { return false, fmt.Errorf("解包正在进行中") } s.c.mu.RLock() a := s.c.archive s.c.mu.RUnlock() if a == nil { s.c.unpackRunning.Store(false) return false, ErrNoArchive } dir, err := application.Get().Dialog.OpenFile(). CanChooseFiles(false). CanChooseDirectories(true). CanCreateDirectories(true). SetTitle("选择解包目标目录"). PromptForSingleSelection() if err != nil { s.c.unpackRunning.Store(false) return false, err } if dir == "" { s.c.unpackRunning.Store(false) return false, nil // 用户取消 } finishTask := s.c.archiveTasks.begin() s.c.mu.Lock() if s.c.archive != a || s.c.unpackCancel.Load() { s.c.mu.Unlock() finishTask() s.c.unpackRunning.Store(false) return false, nil } s.c.unpackCancel.Store(false) s.c.mu.Unlock() go func() { defer finishTask() defer s.c.unpackRunning.Store(false) emit := emitEvent total := int(a.FileCount()) progress := func(done, tot int) { emit("unpack:progress", map[string]int{"done": done, "total": tot}) } cancel := func() bool { return s.c.unpackCancel.Load() } err := a.ExtractTo(dir, progress, cancel) if err == pvf.ErrCancelled { emit("unpack:done", map[string]any{"ok": false, "message": "解包已取消", "dir": dir}) return } if err != nil { emit("unpack:done", map[string]any{"ok": false, "message": "解包失败: " + err.Error(), "dir": dir}) return } emit("unpack:done", map[string]any{"ok": true, "message": fmt.Sprintf("已解包 %d 个文件到 %s", total, dir), "dir": dir}) }() return true, nil } // CancelUnpack 请求中止进行中的解包。 func (s *EditorService) CancelUnpack() { s.c.unpackCancel.Store(true) } // IsUnpacking 报告解包是否进行中。 func (s *EditorService) IsUnpacking() bool { return s.c.unpackRunning.Load() } // Binary ANI is a read-only decoded view; native payload/type remain intact for // extraction and byte-identical archive saves across all container families. func (s *EditorService) readBinaryANI(a *pvf.Archive, index int32, meta *FileMeta) (*FileMeta, error) { meta.Editable = false meta.Modified = a.IsModified(index) if meta.Size > s.editableByteLimit() { meta.Text = "; 二进制 ANI 过大,超过文本查看上限。可在编辑器设置中调整。" return meta, nil } text, err := a.Text(index) if err != nil { meta.Text = "; 二进制 ANI 解码失败:" + err.Error() return meta, nil } meta.Text = text return meta, nil }