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
6 changed files with 561 additions and 48 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue