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
23 changed files with 561 additions and 48 deletions

View file

@ -138,6 +138,7 @@ func runMacKeySetup(args []string) int {
if *askOpen {
mode := askOpenOnComplete()
fmt.Println("openOnComplete:", mode)
fmt.Println("promptForFilename:", askPromptForFilename())
}
return 0
}
@ -368,3 +369,23 @@ func askOpenOnComplete() string {
}
return mode
}
// askPromptForFilename: Save As dialog for every print (default on)?
func askPromptForFilename() bool {
cur, _ := settings.Load()
def := "Ask each time"
if !cur.Prompt() {
def = "Save automatically"
}
btn, _, err := ask("When you print to Verae Timestamping:\n\n• Ask each time - choose the file name and folder in a Save dialog (Cancel discards the print, nothing is stamped)\n• Save automatically - use the document title in Documents/Verae Timestamps", "", false, false, []string{"Save automatically", "Ask each time"}, def)
on := cur.Prompt()
if err == nil {
on = btn == "Ask each time"
}
if err := settings.SetPromptForFilename(settings.UserPath(""), map[bool]string{true: "true", false: "false"}[on]); err != nil {
vlog.Errorf("mac-key-setup: write config: %v", err)
} else {
vlog.Printf("mac-key-setup: promptForFilename=%v saved to %s", on, settings.UserPath(""))
}
return on
}

View file

@ -11,6 +11,7 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
@ -120,6 +121,8 @@ func runStamp(args []string) int {
openFolder := fs.Bool("open", false, "on success, apply the OpenOnComplete setting (folder|pdf|none; see 'verae-print config')")
skipSetup := fs.Bool("skip-setup", false, "do not open first-run account dialog")
jobFlag := fs.String("job-id", "", "debug job id (for log correlation)")
titleFlag := fs.String("title", "", "job title used to prefill the Save As name (with -prompt-name)")
promptName := fs.Bool("prompt-name", false, "printer jobs: show a Save As dialog first when promptForFilename is on (cancel discards the job)")
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "stamp: PDF path required")
@ -140,6 +143,23 @@ func runStamp(args []string) int {
return 2
}
outPath := ""
if *promptName {
if cfg, _ := settings.Load(); cfg.Prompt() {
t := *titleFlag
if t == "" && *name != "document" {
t = *name
}
p, ok := askSaveTarget(lj, t, *outDir)
if !ok {
lj.Printf("stamp: Save As cancelled by user — job discarded (not stamped, no credit used)")
fmt.Println("Cancelled: the print was discarded (not timestamped).")
return 0
}
outPath = p
}
}
// First-run: customer account + xVerae- signing key (preferred path)
if ch == auth.ChannelZapier && !auth.HasXVeraeToken() {
if *skipSetup {
@ -174,6 +194,7 @@ func runStamp(args []string) int {
res, err := job.Run(job.Options{
SourcePDF: pdfPath,
OutDir: *outDir,
OutPath: outPath,
DocumentName: *name,
Channel: ch,
Client: client,
@ -185,7 +206,7 @@ func runStamp(args []string) int {
if title == "" {
title = filepath.Base(pdfPath)
}
m, herr := holdJob(pdfPath, title, "cli", *outDir, err, false)
m, herr := holdJob(pdfPath, stampTarget{Title: title, OutDir: *outDir, OutPath: outPath}, "cli", err, false)
if herr != nil {
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)

View file

@ -34,6 +34,7 @@ func runConfig(args []string) int {
fs := flag.NewFlagSet("config", flag.ContinueOnError)
open := fs.String("open-on-complete", "", "folder | pdf | none")
file := fs.String("file", "", "config file to update (default ~/.verae/config.json)")
prompt := fs.String("prompt-for-filename", "", "true | false: show a Save As dialog for each print")
machine := fs.Bool("machine", false, "update the all-users default file instead")
if err := fs.Parse(args); err != nil {
return 2
@ -53,7 +54,15 @@ func runConfig(args []string) int {
vlog.Init("cli", false)
vlog.Printf("config: openOnComplete=%s written to %s", settings.NormalizeOpen(*open), path)
}
if *prompt != "" {
if err := settings.SetPromptForFilename(path, *prompt); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
vlog.Init("cli", false)
vlog.Printf("config: promptForFilename=%s written to %s", *prompt, path)
}
s, src := settings.Load()
fmt.Printf("openOnComplete=%s (from %s)\nuser file: %s\nmachine file: %s\n", s.OpenOnComplete, src, settings.UserPath(""), settings.MachinePath())
fmt.Printf("openOnComplete=%s (from %s)\npromptForFilename=%v\nuser file: %s\nmachine file: %s\n", s.OpenOnComplete, src, s.Prompt(), settings.UserPath(""), settings.MachinePath())
return 0
}

View file

@ -40,6 +40,7 @@ type pendingMeta struct {
Title string `json:"title"`
Source string `json:"source"`
OutDir string `json:"outDir,omitempty"`
OutPath string `json:"outPath,omitempty"` // file chosen in the Save As dialog
Reason string `json:"reason"`
Created string `json:"created"`
}
@ -88,13 +89,13 @@ func moveOrCopy(src, dst string, move bool) error {
}
// holdJob stores the PDF + metadata in the pending folder.
func holdJob(pdfPath, title, source, outDir string, reason error, move bool) (pendingMeta, error) {
func holdJob(pdfPath string, t stampTarget, source string, reason error, move bool) (pendingMeta, error) {
if err := os.MkdirAll(pendingDir(), 0o700); err != nil {
return pendingMeta{}, err
}
rb := make([]byte, 3)
_, _ = rand.Read(rb)
m := pendingMeta{ID: time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(rb), Title: title, Source: source, OutDir: outDir,
m := pendingMeta{ID: time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(rb), Title: t.Title, Source: source, OutDir: t.OutDir, OutPath: t.OutPath,
Reason: reason.Error(), Created: time.Now().Format(time.RFC3339)}
if err := moveOrCopy(pdfPath, m.pdf(), move); err != nil {
return pendingMeta{}, err
@ -136,7 +137,10 @@ func removePending(m pendingMeta) {
}
// stampFn stamps one PDF with the CURRENT stored key.
type stampFn func(pdf, title, outDir string, lj vlog.Job) (job.Result, error)
type stampFn func(pdf string, t stampTarget, lj vlog.Job) (job.Result, error)
// stampTarget says where results go: OutPath (Save As choice) or OutDir+Title.
type stampTarget struct{ Title, OutDir, OutPath string }
// acquireKeyHook replaces the interactive prompt in tests.
var acquireKeyHook func(reason string) bool
@ -150,7 +154,7 @@ func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
for _, m := range listPending() {
lj := vlog.Job(vlog.NewJobID("pending"))
lj.Printf("pending: retrying held job %s title=%q (held because: %s)", m.ID, m.Title, m.Reason)
res, err := stamp(m.pdf(), m.Title, m.OutDir, lj)
res, err := stamp(m.pdf(), stampTarget{Title: m.Title, OutDir: m.OutDir, OutPath: m.OutPath}, lj)
if err != nil {
if isAuthFailure(err) {
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)

View file

@ -27,7 +27,7 @@ func TestHoldAndRetryAfterSignIn(t *testing.T) {
if !isAuthFailure(authErr) || !isAuthFailure(errNoKey) || isAuthFailure(errors.New("timeout")) {
t.Fatal("isAuthFailure")
}
m, err := holdJob(src, "Printed 2026-10-03 192901", "watch", "", authErr, true)
m, err := holdJob(src, stampTarget{Title: "Printed 2026-10-03 192901", OutPath: filepath.Join(d, "chosen.pdf")}, "watch", authErr, true)
if err != nil {
t.Fatal(err)
}
@ -38,7 +38,10 @@ func TestHoldAndRetryAfterSignIn(t *testing.T) {
t.Fatalf("list %v", l)
}
keyOK := false
stamp := func(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
stamp := func(pdf string, tg stampTarget, lj vlog.Job) (job.Result, error) {
if tg.OutPath != filepath.Join(d, "chosen.pdf") {
t.Errorf("chosen path lost on retry: %q", tg.OutPath)
}
if !keyOK {
return job.Result{}, authErr
}
@ -72,12 +75,12 @@ func TestRetryKeepsNonAuthFailures(t *testing.T) {
for _, n := range []string{"a.pdf", "b.pdf"} {
p := filepath.Join(d, n)
_ = os.WriteFile(p, []byte("%PDF"), 0o600)
if _, err := holdJob(p, n, "ipp", "", errNoKey, false); err != nil {
if _, err := holdJob(p, stampTarget{Title: n}, "ipp", errNoKey, false); err != nil {
t.Fatal(err)
}
}
calls := 0
done, remaining, authFail := retryPending(func(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
done, remaining, authFail := retryPending(func(pdf string, tg stampTarget, lj vlog.Job) (job.Result, error) {
calls++
if calls == 1 {
return job.Result{}, errors.New("network down")

102
cmd/verae-print/saveas.go Normal file
View file

@ -0,0 +1,102 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// saveNameFromTitle turns a print-job title into a file name for the Save As
// dialog: path and extension dropped, app prefixes ("Microsoft Word - ")
// removed, characters invalid in file names replaced; empty titles fall back
// to "Printed YYYY-MM-DD HHMMSS".
func saveNameFromTitle(title string, now time.Time) string {
t := strings.TrimSpace(title)
for _, p := range []string{"Microsoft Word - ", "Microsoft Excel - ", "Microsoft PowerPoint - "} {
t = strings.TrimPrefix(t, p)
}
t = filepath.Base(strings.ReplaceAll(t, `\`, "/"))
if ext := filepath.Ext(t); ext != "" && len(ext) <= 6 && !strings.ContainsAny(ext, " ") {
t = strings.TrimSuffix(t, ext)
}
t = strings.Map(func(r rune) rune {
if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) {
return '-'
}
return r
}, t)
if rs := []rune(t); len(rs) > 120 {
t = string(rs[:120])
}
t = strings.TrimRight(strings.TrimSpace(t), ".")
if t == "" || t == "." || t == "-" {
t = "Printed " + now.Format("2006-01-02 150405")
}
return t + ".pdf"
}
// saveStartDir: last folder used in the dialog, else the configured output
// folder, else Documents/Verae Timestamps (created so the dialog opens there).
func saveStartDir(outDir string) string {
for _, d := range []string{settings.LastSaveDir(), outDir} {
if d == "" {
continue
}
if st, err := os.Stat(d); err == nil && st.IsDir() {
return d
}
}
d, err := job.DefaultOutDir()
if err != nil {
h, _ := os.UserHomeDir()
return h
}
_ = os.MkdirAll(d, 0o755)
return d
}
// askSaveTarget shows the native Save As dialog for a print job. It returns
// (path, true) when the user picked a file, ("", false) when they cancelled,
// and ("", true) when no dialog could be shown (the job is then stamped into
// the default folder as before, so a print is never lost to a dialog error).
func askSaveTarget(lj vlog.Job, title, outDir string) (string, bool) {
name := saveNameFromTitle(title, time.Now())
dir := saveStartDir(outDir)
lj.Printf("saveas: prompting (default name %q, folder %q)", name, dir)
p, ok, err := nativeSaveDialog(name, dir)
if err != nil {
lj.Errorf("saveas: dialog unavailable (%v); saving to default folder", err)
return "", true
}
if !ok {
return "", false
}
p = ensurePDFExt(p)
_ = settings.SetLastSaveDir(filepath.Dir(p))
lj.Printf("saveas: user chose %q", p)
return p, true
}
// ensurePDFExt adds ".pdf" when the user typed a name without it. The dialog
// only confirmed replacing the name as typed, so if "<name>.pdf" exists we
// pick "<name>-2.pdf" etc. instead of overwriting it.
func ensurePDFExt(p string) string {
if strings.EqualFold(filepath.Ext(p), ".pdf") {
return p
}
base := p
p = base + ".pdf"
for i := 2; i < 1000; i++ {
if _, err := os.Stat(p); os.IsNotExist(err) {
break
}
p = fmt.Sprintf("%s-%d.pdf", base, i)
}
return p
}

View file

@ -0,0 +1,37 @@
//go:build darwin
package main
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// "choose file name" is the standard NSSavePanel; it asks before replacing an
// existing file. Text is passed via argv (no AppleScript string quoting).
const saveDialogAS = `on run argv
set nm to item 1 of argv
set dir to item 2 of argv
activate
set f to choose file name with prompt "Save timestamped print as" default name nm default location (POSIX file dir)
return POSIX path of f
end run`
func nativeSaveDialog(name, dir string) (string, bool, error) {
cmd := exec.Command("/usr/bin/osascript", "-e", saveDialogAS, name, dir)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
if err := cmd.Run(); err != nil {
if strings.Contains(errb.String(), "-128") { // User canceled.
return "", false, nil
}
return "", false, fmt.Errorf("%v: %s", err, strings.TrimSpace(errb.String()))
}
p := strings.TrimSpace(out.String())
if p == "" {
return "", false, fmt.Errorf("empty path from dialog")
}
return p, true, nil
}

View file

@ -0,0 +1,9 @@
//go:build !windows && !darwin
package main
import "errors"
func nativeSaveDialog(name, dir string) (string, bool, error) {
return "", false, errors.New("no native Save As dialog on this platform")
}

View file

@ -0,0 +1,39 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestSaveNameFromTitle(t *testing.T) {
now := time.Date(2026, 10, 3, 19, 5, 7, 0, time.Local)
cases := map[string]string{
"Microsoft Word - Contract v2.docx": "Contract v2.pdf",
`C:\Users\x\Désirée notes.txt`: "Désirée notes.pdf",
"": "Printed 2026-10-03 190507.pdf",
" ": "Printed 2026-10-03 190507.pdf",
"a:b?c": "a-b-c.pdf",
"Report 1.5 final": "Report 1.5 final.pdf",
}
for in, want := range cases {
if got := saveNameFromTitle(in, now); got != want {
t.Errorf("%q -> %q want %q", in, got, want)
}
}
}
func TestEnsurePDFExt(t *testing.T) {
d := t.TempDir()
if got := ensurePDFExt(filepath.Join(d, "a.PDF")); got != filepath.Join(d, "a.PDF") {
t.Fatal(got)
}
if got := ensurePDFExt(filepath.Join(d, "b")); got != filepath.Join(d, "b.pdf") {
t.Fatal(got)
}
_ = os.WriteFile(filepath.Join(d, "b.pdf"), nil, 0o600)
if got := ensurePDFExt(filepath.Join(d, "b")); got != filepath.Join(d, "b-2.pdf") {
t.Fatal(got)
}
}

View file

@ -0,0 +1,66 @@
//go:build windows
package main
import (
"bytes"
"encoding/base64"
"fmt"
"os"
"os/exec"
"strings"
)
// Common file dialog (WinForms SaveFileDialog) owned by an invisible topmost
// form so it appears above the printing app. OverwritePrompt asks before
// replacing an existing file.
const saveDialogPS = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.Application]::EnableVisualStyles()
function D($v) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($v)) }
$d = New-Object Windows.Forms.SaveFileDialog
$d.Title = 'Save timestamped print as'
$d.Filter = 'PDF files (*.pdf)|*.pdf'
$d.DefaultExt = 'pdf'
$d.AddExtension = $true
$d.OverwritePrompt = $true
$d.CheckPathExists = $true
$d.InitialDirectory = D $env:VERAE_SAVE_DIR
$d.FileName = D $env:VERAE_SAVE_NAME
$o = New-Object Windows.Forms.Form
$o.TopMost = $true
$o.ShowInTaskbar = $false
$o.FormBorderStyle = 'None'
$o.StartPosition = 'CenterScreen'
$o.Size = New-Object Drawing.Size(1, 1)
$o.Opacity = 0
$o.Show()
$o.Activate()
$r = $d.ShowDialog($o)
$o.Close()
if ($r -eq [Windows.Forms.DialogResult]::OK) { 'ok|' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($d.FileName)) } else { 'cancel' }
`
func nativeSaveDialog(name, dir string) (string, bool, error) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", saveDialogPS)
cmd.Env = append(os.Environ(), "VERAE_SAVE_NAME="+b64(name), "VERAE_SAVE_DIR="+b64(dir))
hideWindow(cmd)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
if err := cmd.Run(); err != nil {
return "", false, fmt.Errorf("%v: %s", err, strings.TrimSpace(errb.String()))
}
s := strings.TrimSpace(out.String())
if s == "cancel" {
return "", false, nil
}
if !strings.HasPrefix(s, "ok|") {
return "", false, fmt.Errorf("unexpected dialog output")
}
p, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, "ok|"))
if err != nil || len(p) == 0 {
return "", false, fmt.Errorf("bad dialog output")
}
return string(p), true, nil
}

View file

@ -115,10 +115,20 @@ func makeProcessor(outDir string) winprint.Processor {
if !strings.Contains(j.Format, "pdf") && !sniffPDF(j.Path) {
return fmt.Errorf("received %s (not PDF); raster→PDF conversion is not implemented — file kept at %s. Set VERAE_IPP_FORMATS=application/pdf", j.Format, j.Path)
}
res, err := stampFile(j.Path, j.Title, outDir, lj)
target := stampTarget{Title: j.Title, OutDir: outDir}
if cfg, _ := settings.Load(); cfg.Prompt() {
p, ok := askSaveTarget(lj, j.Title, outDir)
if !ok {
_ = os.Remove(j.Path)
lj.Printf("process: Save As cancelled by user — job discarded (not stamped, no credit used)")
return nil
}
target.OutPath = p
}
res, err := stampFile(j.Path, target, lj)
if err != nil {
if isAuthFailure(err) {
m, herr := holdJob(j.Path, j.Title, j.Source, outDir, err, true)
m, herr := holdJob(j.Path, target, j.Source, err, true)
if herr != nil {
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
return err

View file

@ -37,14 +37,14 @@ func clientForChannel(ch auth.Channel, lj vlog.Job) (*veraeapi.Client, auth.Chan
}
// stampFile stamps one PDF with the current key (no holding / prompting).
func stampFile(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) {
client, ch, err := clientForChannel(auth.PreferredChannel(), lj)
if err != nil {
return job.Result{}, err
}
client.HTTPDo = loggingDo(lj)
start := time.Now()
res, err := job.Run(job.Options{SourcePDF: pdf, OutDir: outDir, DocumentName: safeStem(title) + ".pdf", Channel: ch, Client: client})
res, err := job.Run(job.Options{SourcePDF: pdf, OutDir: t.OutDir, OutPath: t.OutPath, DocumentName: safeStem(t.Title) + ".pdf", Channel: ch, Client: client})
if err != nil {
return res, err
}

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())
}
}

View file

@ -56,7 +56,9 @@ else
fi
echo "stamping stem=$STEM pdf=$PDF"
"$VERAE_PRINT" stamp -out-dir "$OUT_DIR" -name "$STEM" -open "$PDF"
# -prompt-name: Save As dialog first when promptForFilename is on (default);
# Cancel discards the job (nothing stamped, no credit used).
"$VERAE_PRINT" stamp -out-dir "$OUT_DIR" -name "$STEM" -title "${IPP_JOB_NAME:-${IPP_DOCUMENT_NAME:-}}" -prompt-name -open "$PDF"
echo "ATTR: job-impressions-completed=1" >&2
rm -rf "$WORK"
exit 0

View file

@ -9,7 +9,7 @@ SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}"
# shellcheck source=/dev/null
source "$SIGNING_ENV"
VERSION="${VERSION:-0.3.1}"
VERSION="${VERSION:-0.3.2}"
BUILD_ROOT="$ROOT/build/macos-pkg"
STAGE="$BUILD_ROOT/stage"
SCRIPTS="$BUILD_ROOT/scripts"

Some files were not shown because too many files have changed in this diff Show more