367 lines
9.6 KiB
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
|
|
}
|