0.3.2: Save As filename prompt per print (Windows + macOS)

- promptForFilename setting (default true; env VERAE_PROMPT_FOR_FILENAME),
  'verae-print config --prompt-for-filename', last Save As folder in
  ~/.verae/state.json
- Windows helper: common file dialog (PDF filter, overwrite prompt, topmost
  owner) before stamping; macOS printer path: choose file name (NSSavePanel)
  via 'stamp -prompt-name -title'; cancel discards the job (no stamp/credit)
- job.Options.OutPath: original saved at the chosen path, certificate and
  .verae.json next to it with the chosen base name (never overwritten)
- held jobs keep the chosen path (pendingMeta.OutPath)
- installer: 'Ask for a file name for each print' checkbox; removed the
  'Open the user guide' finish-page checkbox; mac-key-setup asks too
- auth test no longer touches the real macOS keychain (VERAE_SKIP_KEYCHAIN)
- version 0.3.2
This commit is contained in:
Grok Bot (for George) 2026-10-03 20:00:56 -04:00
commit 13ebc351f5
6 changed files with 561 additions and 48 deletions

View file

@ -148,7 +148,7 @@ func SaveXVeraeToken(token string) error {
return err
}
// Keychain (best-effort): delete old, add new
if runtime.GOOS != "darwin" {
if runtime.GOOS != "darwin" || os.Getenv("VERAE_SKIP_KEYCHAIN") == "1" {
return nil
}
account := os.Getenv("USER")

View file

@ -10,8 +10,9 @@ import (
func TestSaveXVeraeToken(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) // os.UserHomeDir on Windows
t.Setenv("XVERAE_TOKEN", "") // force file path
t.Setenv("USERPROFILE", home) // os.UserHomeDir on Windows
t.Setenv("XVERAE_TOKEN", "") // force file path
t.Setenv("VERAE_SKIP_KEYCHAIN", "1") // never touch the real login keychain
if err := SaveXVeraeToken("xVerae-testtoken123"); err != nil {
t.Fatal(err)
}

View file

@ -15,7 +15,7 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
)
const HelperVersion = "0.3.1"
const HelperVersion = "0.3.2"
// Result paths written for one print job.
type Result struct {
@ -32,8 +32,13 @@ type Options struct {
SourcePDF string // temp PDF from macOS PDF Services
OutDir string
DocumentName string // optional override for stem; default from source basename
Channel auth.Channel
Client *veraeapi.Client
// OutPath, when set, is the exact path chosen by the user in a Save As
// dialog (which already asked before replacing an existing file). The
// original is written there; certificate and sidecar go next to it using
// the chosen base name, and those are still never overwritten.
OutPath string
Channel auth.Channel
Client *veraeapi.Client
}
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
@ -88,6 +93,9 @@ func Run(opt Options) (Result, error) {
return Result{}, fmt.Errorf("client required")
}
outDir := opt.OutDir
if opt.OutPath != "" {
outDir = filepath.Dir(opt.OutPath)
}
if outDir == "" {
d, err := DefaultOutDir()
if err != nil {
@ -117,9 +125,21 @@ func Run(opt Options) (Result, error) {
}
// Reserve a name that collides with nothing already there: the original,
// its .verae.json sidecar. Never overwrite; add -2, -3, ... instead.
origPath, stem, err := reserveOriginal(outDir, origBase, stem, srcBytes)
if err != nil {
return Result{}, err
var origPath string
if opt.OutPath != "" {
origPath = opt.OutPath
if !strings.EqualFold(filepath.Ext(origPath), ".pdf") {
origPath += ".pdf"
}
stem = userStem(filepath.Base(origPath))
if err := os.WriteFile(origPath, srcBytes, 0o600); err != nil { // replace confirmed by the user
return Result{}, err
}
} else {
origPath, stem, err = reserveOriginal(outDir, origBase, stem, srcBytes)
if err != nil {
return Result{}, err
}
}
origBase = filepath.Base(origPath)
@ -264,3 +284,20 @@ func reserveOriginal(dir, origBase, stem string, data []byte) (string, string, e
}
return "", "", fmt.Errorf("no free file name for %s in %s", origBase, dir)
}
// userStem keeps a user-chosen name as typed (spaces etc.), only removing the
// extension and characters that are invalid in file names on any platform.
func userStem(base string) string {
st := strings.TrimSuffix(base, filepath.Ext(base))
st = strings.Map(func(r rune) rune {
if r < 32 || strings.ContainsRune(`<>:"/\\|?*`, r) {
return '-'
}
return r
}, st)
st = strings.TrimRight(strings.TrimSpace(st), ".")
if st == "" {
st = "document"
}
return st
}

View file

@ -146,3 +146,36 @@ func TestRunNeverOverwritesSameName(t *testing.T) {
t.Fatal(p, err)
}
}
func TestRunWithChosenPath(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "spool.pdf")
_ = os.WriteFile(src, []byte("%PDF-1.4 new"), 0o600)
pick := filepath.Join(dir, "My Contract v2.pdf")
_ = os.WriteFile(pick, []byte("old"), 0o600) // user confirmed "Replace"
client := &veraeapi.Client{Channel: auth.ChannelZapier, ZapierBase: "https://zap.test", XVeraeToken: "xVerae-x",
HTTPDo: func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"receipt_id":"bafyR1"}`)), Header: make(http.Header)}, nil
}}
for i := 0; i < 2; i++ {
res, err := Run(Options{SourcePDF: src, OutPath: pick, Channel: auth.ChannelZapier, Client: client})
if err != nil {
t.Fatal(err)
}
if res.OriginalPDF != pick {
t.Fatal(res.OriginalPDF)
}
wantCert := "My Contract v2-verae-timestamp-bafyR1.pdf"
wantJSON := "My Contract v2.verae.json"
if i == 1 {
wantCert = "My Contract v2-verae-timestamp-bafyR1-2.pdf"
wantJSON = "My Contract v2-2.verae.json"
}
if filepath.Base(res.CertificatePDF) != wantCert || filepath.Base(res.ReceiptJSON) != wantJSON {
t.Fatalf("run %d: %s %s", i, filepath.Base(res.CertificatePDF), filepath.Base(res.ReceiptJSON))
}
}
if b, _ := os.ReadFile(pick); string(b) != "%PDF-1.4 new" {
t.Fatal("original not written to chosen path")
}
}

View file

@ -29,6 +29,21 @@ type Settings struct {
OpenOnComplete string `json:"openOnComplete,omitempty"`
// LogLevel "debug" enables verbose helper logs (same as VERAE_LOG_LEVEL=debug).
LogLevel string `json:"logLevel,omitempty"`
// PromptForFilename shows a Save As dialog for every print (default true).
PromptForFilename *bool `json:"promptForFilename,omitempty"`
}
// Prompt reports the effective promptForFilename value.
func (s Settings) Prompt() bool { return s.PromptForFilename == nil || *s.PromptForFilename }
func parseBool(v string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off":
return false, true
}
return false, false
}
// Default settings.
@ -95,27 +110,32 @@ func Load() (Settings, string) {
}
}
for _, p := range []string{MachinePath(), UserPath("")} {
if s, ok := readFile(p); ok && s.LogLevel != "" {
eff.LogLevel = strings.ToLower(strings.TrimSpace(s.LogLevel))
if s, ok := readFile(p); ok {
if s.LogLevel != "" {
eff.LogLevel = strings.ToLower(strings.TrimSpace(s.LogLevel))
}
if s.PromptForFilename != nil {
v := *s.PromptForFilename
eff.PromptForFilename = &v
}
}
}
if b, ok := parseBool(os.Getenv("VERAE_PROMPT_FOR_FILENAME")); ok {
eff.PromptForFilename = &b
}
if v := NormalizeOpen(os.Getenv("VERAE_OPEN_ON_COMPLETE")); v != "" {
eff.OpenOnComplete, src = v, "env VERAE_OPEN_ON_COMPLETE"
}
return eff, src
}
// SetOpenOnComplete updates (merges) the JSON file at path, preserving unknown keys.
func SetOpenOnComplete(path, value string) error {
v := NormalizeOpen(value)
if v == "" {
return fmt.Errorf("openOnComplete must be folder, pdf or none (got %q)", value)
}
// SetKey merges one key into the JSON file at path (unknown keys preserved).
func SetKey(path, key string, value any) error {
m := map[string]any{}
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal([]byte(strings.TrimPrefix(string(b), "\uFEFF")), &m)
}
m["openOnComplete"] = v
m[key] = value
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
@ -126,3 +146,45 @@ func SetOpenOnComplete(path, value string) error {
}
return os.Rename(tmp, path)
}
// SetPromptForFilename writes promptForFilename (true/false, yes/no, 1/0).
func SetPromptForFilename(path, value string) error {
b, ok := parseBool(value)
if !ok {
return fmt.Errorf("promptForFilename must be true or false (got %q)", value)
}
return SetKey(path, "promptForFilename", b)
}
// statePath holds non-preference state (last Save As folder).
func statePath() string { return filepath.Join(filepath.Dir(UserPath("")), "state.json") }
// LastSaveDir returns the folder used last in the Save As dialog ("" if none/gone).
func LastSaveDir() string {
b, err := os.ReadFile(statePath())
if err != nil {
return ""
}
var st struct {
LastSaveDir string `json:"lastSaveDir"`
}
if json.Unmarshal(b, &st) != nil || st.LastSaveDir == "" {
return ""
}
if fi, err := os.Stat(st.LastSaveDir); err != nil || !fi.IsDir() {
return ""
}
return st.LastSaveDir
}
// SetLastSaveDir remembers the folder chosen in the Save As dialog.
func SetLastSaveDir(dir string) error { return SetKey(statePath(), "lastSaveDir", dir) }
// SetOpenOnComplete updates (merges) the JSON file at path, preserving unknown keys.
func SetOpenOnComplete(path, value string) error {
v := NormalizeOpen(value)
if v == "" {
return fmt.Errorf("openOnComplete must be folder, pdf or none (got %q)", value)
}
return SetKey(path, "openOnComplete", v)
}

View file

@ -46,3 +46,30 @@ func indexOf(s, sub string) int {
}
return -1
}
func TestPromptAndLastDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("VERAE_PROMPT_FOR_FILENAME", "")
if s, _ := Load(); !s.Prompt() {
t.Fatal("default must be true")
}
if err := SetPromptForFilename(UserPath(""), "false"); err != nil {
t.Fatal(err)
}
if s, _ := Load(); s.Prompt() {
t.Fatal("want false")
}
t.Setenv("VERAE_PROMPT_FOR_FILENAME", "1")
if s, _ := Load(); !s.Prompt() {
t.Fatal("env override")
}
if LastSaveDir() != "" {
t.Fatal("no state yet")
}
_ = SetLastSaveDir(home)
if LastSaveDir() != home {
t.Fatal(LastSaveDir())
}
}