Former/storage.go

367 lines
9.6 KiB
Go

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
}