diff --git a/cmd/verae-print/keysetup_darwin.go b/cmd/verae-print/keysetup_darwin.go index 6fe3d5e..10c4b4f 100644 --- a/cmd/verae-print/keysetup_darwin.go +++ b/cmd/verae-print/keysetup_darwin.go @@ -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 +} diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index 232e373..d6bd74d 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -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) diff --git a/cmd/verae-print/opencomplete.go b/cmd/verae-print/opencomplete.go index 9e6e5a7..e8fb6c3 100644 --- a/cmd/verae-print/opencomplete.go +++ b/cmd/verae-print/opencomplete.go @@ -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 } diff --git a/cmd/verae-print/pending.go b/cmd/verae-print/pending.go index 62cace6..f9836f4 100644 --- a/cmd/verae-print/pending.go +++ b/cmd/verae-print/pending.go @@ -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) diff --git a/cmd/verae-print/pending_test.go b/cmd/verae-print/pending_test.go index 98c5e7f..b996518 100644 --- a/cmd/verae-print/pending_test.go +++ b/cmd/verae-print/pending_test.go @@ -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") diff --git a/cmd/verae-print/saveas.go b/cmd/verae-print/saveas.go new file mode 100644 index 0000000..c753279 --- /dev/null +++ b/cmd/verae-print/saveas.go @@ -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 ".pdf" exists we +// pick "-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 +} diff --git a/cmd/verae-print/saveas_darwin.go b/cmd/verae-print/saveas_darwin.go new file mode 100644 index 0000000..e693de9 --- /dev/null +++ b/cmd/verae-print/saveas_darwin.go @@ -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 +} diff --git a/cmd/verae-print/saveas_other.go b/cmd/verae-print/saveas_other.go new file mode 100644 index 0000000..81ed491 --- /dev/null +++ b/cmd/verae-print/saveas_other.go @@ -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") +} diff --git a/cmd/verae-print/saveas_test.go b/cmd/verae-print/saveas_test.go new file mode 100644 index 0000000..9c34d07 --- /dev/null +++ b/cmd/verae-print/saveas_test.go @@ -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) + } +} diff --git a/cmd/verae-print/saveas_windows.go b/cmd/verae-print/saveas_windows.go new file mode 100644 index 0000000..b437d63 --- /dev/null +++ b/cmd/verae-print/saveas_windows.go @@ -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 +} diff --git a/cmd/verae-print/serve.go b/cmd/verae-print/serve.go index 3abbeea..1cfd3fe 100644 --- a/cmd/verae-print/serve.go +++ b/cmd/verae-print/serve.go @@ -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 diff --git a/cmd/verae-print/stampcore.go b/cmd/verae-print/stampcore.go index c103d4b..f2d80f8 100644 --- a/cmd/verae-print/stampcore.go +++ b/cmd/verae-print/stampcore.go @@ -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 } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c6ffa9c..05b34c5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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") diff --git a/internal/auth/token_save_test.go b/internal/auth/token_save_test.go index 7d7a403..15ea0f0 100644 --- a/internal/auth/token_save_test.go +++ b/internal/auth/token_save_test.go @@ -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) } diff --git a/internal/job/job.go b/internal/job/job.go index 0429fa3..287b820 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -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 +} diff --git a/internal/job/job_test.go b/internal/job/job_test.go index 745c339..9660123 100644 --- a/internal/job/job_test.go +++ b/internal/job/job_test.go @@ -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") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 35a4f89..8322d87 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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) +} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index e818cb3..0785b9a 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -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()) + } +} diff --git a/platform/macos/ipp/verae-print-command.sh b/platform/macos/ipp/verae-print-command.sh index 378ab8b..067dafc 100755 --- a/platform/macos/ipp/verae-print-command.sh +++ b/platform/macos/ipp/verae-print-command.sh @@ -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 diff --git a/platform/macos/pkg/build-pkg.sh b/platform/macos/pkg/build-pkg.sh index e1398b6..9736648 100755 --- a/platform/macos/pkg/build-pkg.sh +++ b/platform/macos/pkg/build-pkg.sh @@ -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" diff --git a/platform/windows/README-WINDOWS.txt b/platform/windows/README-WINDOWS.txt index 0da8644..60f39cb 100644 --- a/platform/windows/README-WINDOWS.txt +++ b/platform/windows/README-WINDOWS.txt @@ -15,8 +15,10 @@ Install Your password goes only to customer.verae.ai over HTTPS and is never saved. Only the issued key is saved, in %USERPROFILE%\.verae\xverae-token (readable only by you). -3. After each print: choose whether to show the new certificate in File - Explorer (default), open it in your PDF viewer, or do nothing. +3. After each print: "Ask for a file name for each print" (on by default) + shows a Save As dialog for every print; also choose whether to show the + new certificate in File Explorer (default), open it in your PDF viewer, or + do nothing. 4. The last page lists each step as OK / FAIL / SKIP. Printing @@ -25,7 +27,20 @@ Printing by the Verae helper that runs in your session. * Printer "Verae TimeStamp PDF (Local Port)" (fallback): built-in Microsoft Print to PDF driver; use it if the main printer is not available. -Results in Documents\Verae Timestamps: +Save As (promptForFilename on, the default): after each print a Save As +dialog appears on top of your app. It starts in the folder you used last +(first time: Documents\Verae Timestamps) with the document's name filled in +(or "Printed "). The printed PDF is saved where you choose and +the certificate and receipt are saved next to it: + .pdf + -verae-timestamp-.pdf + .verae.json +If the file already exists, Windows asks whether to replace it (the +certificate and receipt are never overwritten; they get "-2", ...). +Cancel discards the print: nothing is timestamped and no credit is used. + +Without the dialog (promptForFilename false), results go to +Documents\Verae Timestamps: .pdf the printed document -verae-timestamp-.pdf the timestamp certificate .verae.json receipt details @@ -42,6 +57,8 @@ after you sign in (Start > Verae > Setup signing key), or after the next print. Settings (%USERPROFILE%\.verae\config.json) -------- + { "promptForFilename": true } Save As dialog for each print (true/false; + read for every print, no restart needed) { "openOnComplete": "folder" } folder | pdf | none { "logLevel": "debug" } verbose helper logging (or set the environment variable VERAE_LOG_LEVEL=debug) diff --git a/platform/windows/build-windows.ps1 b/platform/windows/build-windows.ps1 index e1238d7..41b68ca 100644 --- a/platform/windows/build-windows.ps1 +++ b/platform/windows/build-windows.ps1 @@ -1,6 +1,6 @@ # Build the Windows installer natively on Windows (Go + NSIS). # Usage: powershell -ExecutionPolicy Bypass -File platform\windows\build-windows.ps1 [-Arch amd64|arm64] -param([string]$Arch = "amd64", [string]$Version = "0.3.1") +param([string]$Arch = "amd64", [string]$Version = "0.3.2") $ErrorActionPreference = "Stop" $Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $Out = Join-Path $Root "dist\windows" diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index 0a9a8fe..d4196b9 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -7,7 +7,7 @@ ; Logging: one concise %ProgramData%\Verae\logs\installer-