package main import ( "encoding/json" "fmt" "image" "image/png" "os" "path/filepath" "sort" "strings" "time" ) func formerBaseDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, "former") } func formerSessionsDir() string { return filepath.Join(formerBaseDir(), "sessions") } func formerProfilesDir() string { return filepath.Join(formerBaseDir(), "profiles") } func ensureFormerDirs() { os.MkdirAll(formerSessionsDir(), 0755) os.MkdirAll(formerProfilesDir(), 0755) } // ProjectEntry represents a saved project on disk type ProjectEntry struct { Path string Type string // "session" or "profile" Data InstanceData ModTime time.Time } // SaveSession persists an enclosure session to ~/former/sessions/ func SaveSession(inst InstanceData, sourceDir string, thumbnail image.Image) (string, error) { ensureFormerDirs() name := sanitizeDirName(inst.ProjectName) if name == "" { name = "untitled" } id := inst.ID if len(id) > 8 { id = id[:8] } projectDir := filepath.Join(formerSessionsDir(), fmt.Sprintf("%s-%s", name, id)) if err := saveProject(projectDir, inst, sourceDir); err != nil { return "", err } if thumbnail != nil { SaveThumbnail(projectDir, thumbnail) } return projectDir, nil } // SaveProfile persists an enclosure session as a named profile to ~/former/profiles/ func SaveProfile(inst InstanceData, name string, sourceDir string, thumbnail image.Image) (string, error) { ensureFormerDirs() dirLabel := sanitizeDirName(name) if dirLabel == "" { dirLabel = "untitled" } id := inst.ID if len(id) > 8 { id = id[:8] } projectDir := filepath.Join(formerProfilesDir(), fmt.Sprintf("%s-%s", dirLabel, id)) inst.Name = name if err := saveProject(projectDir, inst, sourceDir); err != nil { return "", err } if thumbnail != nil { SaveThumbnail(projectDir, thumbnail) } return projectDir, nil } func saveProject(projectDir string, inst InstanceData, sourceDir string) error { os.MkdirAll(projectDir, 0755) // Copy gerber files using original filenames newGerberFiles := make(map[string]string) for origName, savedBasename := range inst.GerberFiles { srcPath := filepath.Join(sourceDir, savedBasename) dstPath := filepath.Join(projectDir, origName) if err := CopyFile(srcPath, dstPath); err != nil { // Fallback: try using origName directly srcPath = filepath.Join(sourceDir, origName) if err2 := CopyFile(srcPath, dstPath); err2 != nil { return fmt.Errorf("copy %s: %v", origName, err) } } newGerberFiles[origName] = origName } inst.GerberFiles = newGerberFiles // Copy drill files if inst.DrillPath != "" { srcPath := filepath.Join(sourceDir, inst.DrillPath) ext := filepath.Ext(inst.DrillPath) if ext == "" { ext = ".drl" } dstName := "drill" + ext dstPath := filepath.Join(projectDir, dstName) if CopyFile(srcPath, dstPath) == nil { inst.DrillPath = dstName } } if inst.NPTHPath != "" { srcPath := filepath.Join(sourceDir, inst.NPTHPath) ext := filepath.Ext(inst.NPTHPath) if ext == "" { ext = ".drl" } dstName := "npth" + ext dstPath := filepath.Join(projectDir, dstName) if CopyFile(srcPath, dstPath) == nil { inst.NPTHPath = dstName } } // Write former.json data, err := json.MarshalIndent(inst, "", " ") if err != nil { return err } return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644) } // ListProjects returns all saved projects sorted by modification time (newest first). // Pass limit=0 for no limit. func ListProjects(limit int) ([]ProjectEntry, error) { ensureFormerDirs() var entries []ProjectEntry sessEntries, _ := listProjectsInDir(formerSessionsDir(), "session") entries = append(entries, sessEntries...) profEntries, _ := listProjectsInDir(formerProfilesDir(), "profile") entries = append(entries, profEntries...) sort.Slice(entries, func(i, j int) bool { return entries[i].ModTime.After(entries[j].ModTime) }) if limit > 0 && len(entries) > limit { entries = entries[:limit] } return entries, nil } func listProjectsInDir(dir, projType string) ([]ProjectEntry, error) { dirEntries, err := os.ReadDir(dir) if err != nil { return nil, err } var entries []ProjectEntry for _, de := range dirEntries { if !de.IsDir() { continue } jsonPath := filepath.Join(dir, de.Name(), "former.json") info, err := os.Stat(jsonPath) if err != nil { continue } raw, err := os.ReadFile(jsonPath) if err != nil { continue } var inst InstanceData if err := json.Unmarshal(raw, &inst); err != nil { continue } entries = append(entries, ProjectEntry{ Path: filepath.Join(dir, de.Name()), Type: projType, Data: inst, ModTime: info.ModTime(), }) } return entries, nil } // LoadProject reads former.json from a project directory func LoadProject(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 } // TouchProject updates the mtime of a project's former.json func TouchProject(projectDir string) { jsonPath := filepath.Join(projectDir, "former.json") now := time.Now() os.Chtimes(jsonPath, now, now) } // DeleteProject removes a project directory entirely func DeleteProject(projectDir string) error { return os.RemoveAll(projectDir) } // RestoreProject loads and rebuilds a session from a project directory func RestoreProject(projectDir string) (string, *EnclosureSession, *InstanceData, error) { inst, err := LoadProject(projectDir) if err != nil { return "", nil, nil, err } sid, session, err := restoreSessionFromDir(inst, projectDir) if err != nil { return "", nil, nil, err } TouchProject(projectDir) return sid, session, inst, nil } // 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) } 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 }