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 {
|
if *askOpen {
|
||||||
mode := askOpenOnComplete()
|
mode := askOpenOnComplete()
|
||||||
fmt.Println("openOnComplete:", mode)
|
fmt.Println("openOnComplete:", mode)
|
||||||
|
fmt.Println("promptForFilename:", askPromptForFilename())
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
@ -368,3 +369,23 @@ func askOpenOnComplete() string {
|
||||||
}
|
}
|
||||||
return mode
|
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/auth"
|
||||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||||
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
"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')")
|
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")
|
skipSetup := fs.Bool("skip-setup", false, "do not open first-run account dialog")
|
||||||
jobFlag := fs.String("job-id", "", "debug job id (for log correlation)")
|
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)
|
_ = fs.Parse(args)
|
||||||
if fs.NArg() < 1 {
|
if fs.NArg() < 1 {
|
||||||
fmt.Fprintln(os.Stderr, "stamp: PDF path required")
|
fmt.Fprintln(os.Stderr, "stamp: PDF path required")
|
||||||
|
|
@ -140,6 +143,23 @@ func runStamp(args []string) int {
|
||||||
return 2
|
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)
|
// First-run: customer account + xVerae- signing key (preferred path)
|
||||||
if ch == auth.ChannelZapier && !auth.HasXVeraeToken() {
|
if ch == auth.ChannelZapier && !auth.HasXVeraeToken() {
|
||||||
if *skipSetup {
|
if *skipSetup {
|
||||||
|
|
@ -174,6 +194,7 @@ func runStamp(args []string) int {
|
||||||
res, err := job.Run(job.Options{
|
res, err := job.Run(job.Options{
|
||||||
SourcePDF: pdfPath,
|
SourcePDF: pdfPath,
|
||||||
OutDir: *outDir,
|
OutDir: *outDir,
|
||||||
|
OutPath: outPath,
|
||||||
DocumentName: *name,
|
DocumentName: *name,
|
||||||
Channel: ch,
|
Channel: ch,
|
||||||
Client: client,
|
Client: client,
|
||||||
|
|
@ -185,7 +206,7 @@ func runStamp(args []string) int {
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = filepath.Base(pdfPath)
|
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 {
|
if herr != nil {
|
||||||
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
|
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
|
||||||
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ func runConfig(args []string) int {
|
||||||
fs := flag.NewFlagSet("config", flag.ContinueOnError)
|
fs := flag.NewFlagSet("config", flag.ContinueOnError)
|
||||||
open := fs.String("open-on-complete", "", "folder | pdf | none")
|
open := fs.String("open-on-complete", "", "folder | pdf | none")
|
||||||
file := fs.String("file", "", "config file to update (default ~/.verae/config.json)")
|
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")
|
machine := fs.Bool("machine", false, "update the all-users default file instead")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return 2
|
return 2
|
||||||
|
|
@ -53,7 +54,15 @@ func runConfig(args []string) int {
|
||||||
vlog.Init("cli", false)
|
vlog.Init("cli", false)
|
||||||
vlog.Printf("config: openOnComplete=%s written to %s", settings.NormalizeOpen(*open), path)
|
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()
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ type pendingMeta struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
OutDir string `json:"outDir,omitempty"`
|
OutDir string `json:"outDir,omitempty"`
|
||||||
|
OutPath string `json:"outPath,omitempty"` // file chosen in the Save As dialog
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
Created string `json:"created"`
|
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.
|
// 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 {
|
if err := os.MkdirAll(pendingDir(), 0o700); err != nil {
|
||||||
return pendingMeta{}, err
|
return pendingMeta{}, err
|
||||||
}
|
}
|
||||||
rb := make([]byte, 3)
|
rb := make([]byte, 3)
|
||||||
_, _ = rand.Read(rb)
|
_, _ = 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)}
|
Reason: reason.Error(), Created: time.Now().Format(time.RFC3339)}
|
||||||
if err := moveOrCopy(pdfPath, m.pdf(), move); err != nil {
|
if err := moveOrCopy(pdfPath, m.pdf(), move); err != nil {
|
||||||
return pendingMeta{}, err
|
return pendingMeta{}, err
|
||||||
|
|
@ -136,7 +137,10 @@ func removePending(m pendingMeta) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// stampFn stamps one PDF with the CURRENT stored key.
|
// 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.
|
// acquireKeyHook replaces the interactive prompt in tests.
|
||||||
var acquireKeyHook func(reason string) bool
|
var acquireKeyHook func(reason string) bool
|
||||||
|
|
@ -150,7 +154,7 @@ func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
|
||||||
for _, m := range listPending() {
|
for _, m := range listPending() {
|
||||||
lj := vlog.Job(vlog.NewJobID("pending"))
|
lj := vlog.Job(vlog.NewJobID("pending"))
|
||||||
lj.Printf("pending: retrying held job %s title=%q (held because: %s)", m.ID, m.Title, m.Reason)
|
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 err != nil {
|
||||||
if isAuthFailure(err) {
|
if isAuthFailure(err) {
|
||||||
lj.Errorf("pending: %s still not authorised: %v", m.ID, 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")) {
|
if !isAuthFailure(authErr) || !isAuthFailure(errNoKey) || isAuthFailure(errors.New("timeout")) {
|
||||||
t.Fatal("isAuthFailure")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +38,10 @@ func TestHoldAndRetryAfterSignIn(t *testing.T) {
|
||||||
t.Fatalf("list %v", l)
|
t.Fatalf("list %v", l)
|
||||||
}
|
}
|
||||||
keyOK := false
|
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 {
|
if !keyOK {
|
||||||
return job.Result{}, authErr
|
return job.Result{}, authErr
|
||||||
}
|
}
|
||||||
|
|
@ -72,12 +75,12 @@ func TestRetryKeepsNonAuthFailures(t *testing.T) {
|
||||||
for _, n := range []string{"a.pdf", "b.pdf"} {
|
for _, n := range []string{"a.pdf", "b.pdf"} {
|
||||||
p := filepath.Join(d, n)
|
p := filepath.Join(d, n)
|
||||||
_ = os.WriteFile(p, []byte("%PDF"), 0o600)
|
_ = 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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calls := 0
|
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++
|
calls++
|
||||||
if calls == 1 {
|
if calls == 1 {
|
||||||
return job.Result{}, errors.New("network down")
|
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) {
|
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)
|
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 err != nil {
|
||||||
if isAuthFailure(err) {
|
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 {
|
if herr != nil {
|
||||||
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
|
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
|
||||||
return err
|
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).
|
// 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)
|
client, ch, err := clientForChannel(auth.PreferredChannel(), lj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return job.Result{}, err
|
return job.Result{}, err
|
||||||
}
|
}
|
||||||
client.HTTPDo = loggingDo(lj)
|
client.HTTPDo = loggingDo(lj)
|
||||||
start := time.Now()
|
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 {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ func SaveXVeraeToken(token string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Keychain (best-effort): delete old, add new
|
// Keychain (best-effort): delete old, add new
|
||||||
if runtime.GOOS != "darwin" {
|
if runtime.GOOS != "darwin" || os.Getenv("VERAE_SKIP_KEYCHAIN") == "1" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
account := os.Getenv("USER")
|
account := os.Getenv("USER")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ func TestSaveXVeraeToken(t *testing.T) {
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
t.Setenv("USERPROFILE", home) // os.UserHomeDir on Windows
|
t.Setenv("USERPROFILE", home) // os.UserHomeDir on Windows
|
||||||
t.Setenv("XVERAE_TOKEN", "") // force file path
|
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 {
|
if err := SaveXVeraeToken("xVerae-testtoken123"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import (
|
||||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const HelperVersion = "0.3.1"
|
const HelperVersion = "0.3.2"
|
||||||
|
|
||||||
// Result paths written for one print job.
|
// Result paths written for one print job.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
|
|
@ -32,6 +32,11 @@ type Options struct {
|
||||||
SourcePDF string // temp PDF from macOS PDF Services
|
SourcePDF string // temp PDF from macOS PDF Services
|
||||||
OutDir string
|
OutDir string
|
||||||
DocumentName string // optional override for stem; default from source basename
|
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
|
Channel auth.Channel
|
||||||
Client *veraeapi.Client
|
Client *veraeapi.Client
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +93,9 @@ func Run(opt Options) (Result, error) {
|
||||||
return Result{}, fmt.Errorf("client required")
|
return Result{}, fmt.Errorf("client required")
|
||||||
}
|
}
|
||||||
outDir := opt.OutDir
|
outDir := opt.OutDir
|
||||||
|
if opt.OutPath != "" {
|
||||||
|
outDir = filepath.Dir(opt.OutPath)
|
||||||
|
}
|
||||||
if outDir == "" {
|
if outDir == "" {
|
||||||
d, err := DefaultOutDir()
|
d, err := DefaultOutDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -117,10 +125,22 @@ func Run(opt Options) (Result, error) {
|
||||||
}
|
}
|
||||||
// Reserve a name that collides with nothing already there: the original,
|
// Reserve a name that collides with nothing already there: the original,
|
||||||
// its .verae.json sidecar. Never overwrite; add -2, -3, ... instead.
|
// 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 {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
origBase = filepath.Base(origPath)
|
origBase = filepath.Base(origPath)
|
||||||
|
|
||||||
digest, err := hash.FileSHA256(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)
|
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)
|
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"`
|
OpenOnComplete string `json:"openOnComplete,omitempty"`
|
||||||
// LogLevel "debug" enables verbose helper logs (same as VERAE_LOG_LEVEL=debug).
|
// LogLevel "debug" enables verbose helper logs (same as VERAE_LOG_LEVEL=debug).
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
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.
|
// Default settings.
|
||||||
|
|
@ -95,9 +110,18 @@ func Load() (Settings, string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, p := range []string{MachinePath(), UserPath("")} {
|
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))
|
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 != "" {
|
if v := NormalizeOpen(os.Getenv("VERAE_OPEN_ON_COMPLETE")); v != "" {
|
||||||
eff.OpenOnComplete, src = v, "env VERAE_OPEN_ON_COMPLETE"
|
eff.OpenOnComplete, src = v, "env VERAE_OPEN_ON_COMPLETE"
|
||||||
|
|
@ -105,17 +129,13 @@ func Load() (Settings, string) {
|
||||||
return eff, src
|
return eff, src
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOpenOnComplete updates (merges) the JSON file at path, preserving unknown keys.
|
// SetKey merges one key into the JSON file at path (unknown keys preserved).
|
||||||
func SetOpenOnComplete(path, value string) error {
|
func SetKey(path, key string, value any) error {
|
||||||
v := NormalizeOpen(value)
|
|
||||||
if v == "" {
|
|
||||||
return fmt.Errorf("openOnComplete must be folder, pdf or none (got %q)", value)
|
|
||||||
}
|
|
||||||
m := map[string]any{}
|
m := map[string]any{}
|
||||||
if b, err := os.ReadFile(path); err == nil {
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
_ = json.Unmarshal([]byte(strings.TrimPrefix(string(b), "\uFEFF")), &m)
|
_ = 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 {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -126,3 +146,45 @@ func SetOpenOnComplete(path, value string) error {
|
||||||
}
|
}
|
||||||
return os.Rename(tmp, path)
|
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
|
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
|
fi
|
||||||
|
|
||||||
echo "stamping stem=$STEM pdf=$PDF"
|
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
|
echo "ATTR: job-impressions-completed=1" >&2
|
||||||
rm -rf "$WORK"
|
rm -rf "$WORK"
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}"
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
source "$SIGNING_ENV"
|
source "$SIGNING_ENV"
|
||||||
|
|
||||||
VERSION="${VERSION:-0.3.1}"
|
VERSION="${VERSION:-0.3.2}"
|
||||||
BUILD_ROOT="$ROOT/build/macos-pkg"
|
BUILD_ROOT="$ROOT/build/macos-pkg"
|
||||||
STAGE="$BUILD_ROOT/stage"
|
STAGE="$BUILD_ROOT/stage"
|
||||||
SCRIPTS="$BUILD_ROOT/scripts"
|
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