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:
parent
e960883a0a
commit
13ebc351f5
23 changed files with 561 additions and 48 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
102
cmd/verae-print/saveas.go
Normal 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
|
||||
}
|
||||
37
cmd/verae-print/saveas_darwin.go
Normal file
37
cmd/verae-print/saveas_darwin.go
Normal 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
|
||||
}
|
||||
9
cmd/verae-print/saveas_other.go
Normal file
9
cmd/verae-print/saveas_other.go
Normal 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")
|
||||
}
|
||||
39
cmd/verae-print/saveas_test.go
Normal file
39
cmd/verae-print/saveas_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
66
cmd/verae-print/saveas_windows.go
Normal file
66
cmd/verae-print/saveas_windows.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ func TestSaveXVeraeToken(t *testing.T) {
|
|||
t.Setenv("HOME", home)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +32,11 @@ type Options struct {
|
|||
SourcePDF string // temp PDF from macOS PDF Services
|
||||
OutDir string
|
||||
DocumentName string // optional override for stem; default from source basename
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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,10 +125,22 @@ 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)
|
||||
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)
|
||||
|
||||
digest, err := hash.FileSHA256(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,9 +110,18 @@ func Load() (Settings, string) {
|
|||
}
|
||||
}
|
||||
for _, p := range []string{MachinePath(), UserPath("")} {
|
||||
if s, ok := readFile(p); ok && 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"
|
||||
|
|
@ -105,17 +129,13 @@ func Load() (Settings, string) {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue