服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
685 lines
20 KiB
Go
685 lines
20 KiB
Go
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 `<table::key>`
|
|
// 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
|
|
}
|