package main import ( "encoding/json" "fmt" "image" "image/png" "log" "os" "path/filepath" "sort" "strings" "time" ) func formerBaseDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, "former") } func formerTempDir() string { return filepath.Join(formerBaseDir(), "temp") } func formerSessionsDir() string { return filepath.Join(formerBaseDir(), "sessions") } func formerProfilesDir() string { return filepath.Join(formerBaseDir(), "profiles") } func formerProjectsDir() string { return filepath.Join(formerBaseDir(), "projects") } func formerRecentPath() string { return filepath.Join(formerBaseDir(), "recent.json") } func ensureFormerDirs() { for _, d := range []string{formerTempDir(), formerProjectsDir()} { os.MkdirAll(d, 0755) } } // ======== .former Project Persistence ======== // CreateProject creates a new .former directory at the given path with an empty project.json. func CreateProject(path string) (*ProjectData, error) { if !strings.HasSuffix(path, ".former") { path += ".former" } if err := os.MkdirAll(path, 0755); err != nil { return nil, fmt.Errorf("create project dir: %v", err) } for _, sub := range []string{"stencil", "enclosure", "vectorwrap", "structural", "scanhelper"} { os.MkdirAll(filepath.Join(path, sub), 0755) } proj := &ProjectData{ ID: randomID(), Name: strings.TrimSuffix(filepath.Base(path), ".former"), CreatedAt: time.Now(), Version: 1, Settings: ProjectSettings{ShowGrid: true}, } if err := SaveProject(path, proj); err != nil { return nil, err } AddRecentProject(path, proj.Name) return proj, nil } // SaveProject atomically writes project.json inside a .former directory. func SaveProject(path string, data *ProjectData) error { raw, err := json.MarshalIndent(data, "", " ") if err != nil { return err } tmpPath := filepath.Join(path, "project.json.tmp") if err := os.WriteFile(tmpPath, raw, 0644); err != nil { return err } return os.Rename(tmpPath, filepath.Join(path, "project.json")) } // LoadProjectData reads project.json from a .former directory. func LoadProjectData(path string) (*ProjectData, error) { raw, err := os.ReadFile(filepath.Join(path, "project.json")) if err != nil { return nil, err } var proj ProjectData if err := json.Unmarshal(raw, &proj); err != nil { return nil, err } return &proj, nil } // ProjectSubdir returns the mode-specific subdirectory within a .former project. func ProjectSubdir(projectPath, mode string) string { return filepath.Join(projectPath, mode) } // ======== Recent Projects Tracking ======== // ListRecentProjects reads ~/former/recent.json and returns entries (newest first). func ListRecentProjects() []RecentEntry { raw, err := os.ReadFile(formerRecentPath()) if err != nil { return nil } var entries []RecentEntry json.Unmarshal(raw, &entries) // Filter out entries whose paths no longer exist var valid []RecentEntry for _, e := range entries { if _, err := os.Stat(filepath.Join(e.Path, "project.json")); err == nil { valid = append(valid, e) } } return valid } // AddRecentProject prepends a project to the recent list, deduplicates, caps at 20. func AddRecentProject(path, name string) { entries := ListRecentProjects() entry := RecentEntry{Path: path, Name: name, LastOpened: time.Now()} var deduped []RecentEntry deduped = append(deduped, entry) for _, e := range entries { if e.Path != path { deduped = append(deduped, e) } } if len(deduped) > 20 { deduped = deduped[:20] } raw, _ := json.MarshalIndent(deduped, "", " ") os.WriteFile(formerRecentPath(), raw, 0644) } // ======== Migration from Old Sessions/Profiles ======== // MigrateOldProjects converts ~/former/sessions/* and ~/former/profiles/* into .former projects. // Non-destructive: renames old dirs to .migrated suffix. func MigrateOldProjects() { for _, pair := range [][2]string{ {formerSessionsDir(), "session"}, {formerProfilesDir(), "profile"}, } { dir := pair[0] if _, err := os.Stat(dir); os.IsNotExist(err) { continue } entries, err := os.ReadDir(dir) if err != nil { continue } migrated := 0 for _, de := range entries { if !de.IsDir() { continue } oldPath := filepath.Join(dir, de.Name()) jsonPath := filepath.Join(oldPath, "former.json") raw, err := os.ReadFile(jsonPath) if err != nil { continue } var inst InstanceData if json.Unmarshal(raw, &inst) != nil { continue } name := inst.ProjectName if inst.Name != "" { name = inst.Name } if name == "" { name = "Untitled" } safeName := sanitizeDirName(name) if safeName == "" { safeName = "untitled" } projPath := filepath.Join(formerProjectsDir(), safeName+".former") // Avoid overwriting if _, err := os.Stat(projPath); err == nil { projPath = filepath.Join(formerProjectsDir(), safeName+"-"+inst.ID[:8]+".former") } proj := &ProjectData{ ID: inst.ID, Name: name, CreatedAt: inst.CreatedAt, Version: 1, Settings: ProjectSettings{ShowGrid: true}, Enclosure: &EnclosureData{ GerberFiles: inst.GerberFiles, DrillPath: inst.DrillPath, NPTHPath: inst.NPTHPath, EdgeCutsFile: inst.EdgeCutsFile, CourtyardFile: inst.CourtyardFile, SoldermaskFile: inst.SoldermaskFile, FabFile: inst.FabFile, Config: inst.Config, Exports: inst.Exports, BoardW: inst.BoardW, BoardH: inst.BoardH, ProjectName: inst.ProjectName, Cutouts: inst.MigrateCutouts(), }, } if err := os.MkdirAll(projPath, 0755); err != nil { continue } encDir := filepath.Join(projPath, "enclosure") os.MkdirAll(encDir, 0755) // Copy gerber files into enclosure subdir newGerberFiles := make(map[string]string) for origName := range inst.GerberFiles { srcPath := filepath.Join(oldPath, origName) dstPath := filepath.Join(encDir, origName) if CopyFile(srcPath, dstPath) == nil { newGerberFiles[origName] = origName } } proj.Enclosure.GerberFiles = newGerberFiles // Copy drill files if inst.DrillPath != "" { src := filepath.Join(oldPath, inst.DrillPath) dst := filepath.Join(encDir, inst.DrillPath) CopyFile(src, dst) } if inst.NPTHPath != "" { src := filepath.Join(oldPath, inst.NPTHPath) dst := filepath.Join(encDir, inst.NPTHPath) CopyFile(src, dst) } // Copy thumbnail thumbSrc := filepath.Join(oldPath, "thumbnail.png") thumbDst := filepath.Join(projPath, "thumbnail.png") CopyFile(thumbSrc, thumbDst) // Create other mode subdirs for _, sub := range []string{"stencil", "vectorwrap", "structural", "scanhelper"} { os.MkdirAll(filepath.Join(projPath, sub), 0755) } if SaveProject(projPath, proj) == nil { AddRecentProject(projPath, name) migrated++ } } if migrated > 0 { migratedDir := dir + ".migrated" if err := os.Rename(dir, migratedDir); err != nil { log.Printf("migration: could not rename %s to %s: %v", dir, migratedDir, err) } else { log.Printf("Migrated %d old %ss from %s", migrated, pair[1], dir) } } } } // ======== Legacy Support (used during migration and by RestoreEnclosureProject) ======== // ProjectEntry represents a saved project on disk (legacy format) type ProjectEntry struct { Path string Type string Data InstanceData ModTime time.Time } // LoadLegacyProject reads former.json from a legacy project directory func LoadLegacyProject(projectDir string) (*InstanceData, error) { raw, err := os.ReadFile(filepath.Join(projectDir, "former.json")) if err != nil { return nil, err } var inst InstanceData if err := json.Unmarshal(raw, &inst); err != nil { return nil, err } return &inst, nil } // RestoreEnclosureFromProject rebuilds an EnclosureSession from a .former project's enclosure data. func RestoreEnclosureFromProject(projectPath string, encData *EnclosureData) (string, *EnclosureSession, error) { encDir := filepath.Join(projectPath, "enclosure") inst := &InstanceData{ ID: encData.ProjectName, GerberFiles: encData.GerberFiles, DrillPath: encData.DrillPath, NPTHPath: encData.NPTHPath, EdgeCutsFile: encData.EdgeCutsFile, CourtyardFile: encData.CourtyardFile, SoldermaskFile: encData.SoldermaskFile, FabFile: encData.FabFile, Config: encData.Config, Exports: encData.Exports, BoardW: encData.BoardW, BoardH: encData.BoardH, ProjectName: encData.ProjectName, } return restoreSessionFromDir(inst, encDir) } // DeleteProject removes a project directory entirely func DeleteProject(projectDir string) error { return os.RemoveAll(projectDir) } // SaveThumbnail saves a preview image to the project directory func SaveThumbnail(projectDir string, img image.Image) error { f, err := os.Create(filepath.Join(projectDir, "thumbnail.png")) if err != nil { return err } defer f.Close() return png.Encode(f, img) } // ListProjectOutputFiles returns files in a mode's subdirectory. func ListProjectOutputFiles(projectPath, mode string) []string { dir := filepath.Join(projectPath, mode) entries, err := os.ReadDir(dir) if err != nil { return nil } var files []string for _, e := range entries { if e.IsDir() { continue } files = append(files, filepath.Join(dir, e.Name())) } sort.Strings(files) return files } func sanitizeDirName(name string) string { name = strings.Map(func(r rune) rune { if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { return '-' } return r }, name) name = strings.TrimSpace(name) if len(name) > 50 { name = name[:50] } return name }