Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ gander share README.md --comments anyone # public review comments (opt-in; defa
gander share README.md --no-comments # hide viewer threads (comment_access=disabled)
gander share README.md --private # only the author and invited team can read the doc
gander share README.md --visibility=hidden # unpublish the viewer URL (404); still listed
gander list # table of active shares (includes COMMENTING + VISIBILITY)
gander share README.md --label review --label agent # replace labels (omit on later share/watch to leave them)
gander share README.md --no-labels # clear labels
gander list # table of active shares (includes COMMENTING, VISIBILITY, LABELS)
gander remove README.md # 404s the short link
gander remove --all # remove every share in your account
gander invite # print a team invite URL (shown once)
Expand Down Expand Up @@ -369,8 +371,8 @@ Subcommands:

```
gander signup --email <addr> Open the signup form in your browser, save the API token
gander share [--watch] [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file|dir>
gander watch [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file|dir>
gander share [--watch] [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] [--label name] [--no-labels] <file|dir>
gander watch [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] [--label name] [--no-labels] <file|dir>
gander status Show runner + active watches + URLs
gander stop [<file>|<id>] [--all] Stop a watch (by file, id, or --all)
gander logs [<id>] Tail the runner log (optionally filtered by watch id)
Expand Down
31 changes: 18 additions & 13 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,26 +37,28 @@ type signupIntentPollResp struct {
}

type shareResp struct {
UUID string `json:"uuid"`
ShortID string `json:"short_id"`
Filename string `json:"filename"`
Path string `json:"path,omitempty"`
Watch bool `json:"watch"`
URL string `json:"url"`
CommentAccess string `json:"comment_access"`
DocVisibility string `json:"doc_visibility"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
SizeBytes int `json:"size_bytes"`
UnresolvedCount int `json:"unresolved_count"`
AgentUnresolvedCount int `json:"agent_unresolved_count"`
UUID string `json:"uuid"`
ShortID string `json:"short_id"`
Filename string `json:"filename"`
Path string `json:"path,omitempty"`
Watch bool `json:"watch"`
URL string `json:"url"`
CommentAccess string `json:"comment_access"`
DocVisibility string `json:"doc_visibility"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
SizeBytes int `json:"size_bytes"`
UnresolvedCount int `json:"unresolved_count"`
AgentUnresolvedCount int `json:"agent_unresolved_count"`
Labels []string `json:"labels"`
}

// shareOpts are POST /api/shares policy fields. Empty strings are omitted so
// an upsert does not clobber stored values (server omitted = no-touch).
type shareOpts struct {
CommentAccess string
DocVisibility string
Labels *[]string
}

type commentView struct {
Expand Down Expand Up @@ -217,6 +219,9 @@ func (c *apiClient) CreateShare(filename, path, content string, watch bool, opts
if opts.DocVisibility != "" {
body["doc_visibility"] = opts.DocVisibility
}
if opts.Labels != nil {
body["labels"] = *opts.Labels
}
status, err := c.doStatus("POST", "/api/shares", body, &out)
if err != nil {
return nil, false, err
Expand Down
4 changes: 2 additions & 2 deletions completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestRunCompletionBashNonEmpty(t *testing.T) {
if out == "" {
t.Fatal("bash completion is empty")
}
for _, want := range []string{"complete -F", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent", "--existing", "--no-recursive", "--glob"} {
for _, want := range []string{"complete -F", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--label", "--no-labels", "--silent", "--existing", "--no-recursive", "--glob"} {
if !strings.Contains(out, want) {
t.Errorf("bash completion missing %q\n%s", want, out)
}
Expand Down Expand Up @@ -72,7 +72,7 @@ func TestRunCompletionZshNonEmpty(t *testing.T) {
if out == "" {
t.Fatal("zsh completion is empty")
}
for _, want := range []string{"#compdef gander", "_gander", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent", "--existing", "--no-recursive", "--glob"} {
for _, want := range []string{"#compdef gander", "_gander", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--label", "--no-labels", "--silent", "--existing", "--no-recursive", "--glob"} {
if !strings.Contains(out, want) {
t.Errorf("zsh completion missing %q\n%s", want, out)
}
Expand Down
4 changes: 4 additions & 0 deletions completions/_gander
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ _gander() {
'--private[Make the document private (alias for --visibility private)]' \
'--comments[Who may comment]:comments:(anyone private disabled)' \
'--no-comments[Turn off commenting (alias for --comments disabled)]' \
'*--label[Set a label (repeatable; replaces the set)]:label:' \
'--no-labels[Clear labels]' \
'*:markdown file or directory:_files'
;;
watch)
Expand All @@ -68,6 +70,8 @@ _gander() {
'--private[Make the document private (alias for --visibility private)]' \
'--comments[Who may comment]:comments:(anyone private disabled)' \
'--no-comments[Turn off commenting (alias for --comments disabled)]' \
'*--label[Set a label (repeatable; replaces the set)]:label:' \
'--no-labels[Clear labels]' \
'*:markdown file or directory:_files'
;;
remove)
Expand Down
4 changes: 2 additions & 2 deletions completions/gander.bash
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ _gander_completions() {
return 0
;;
esac
COMPREPLY=( $(compgen -W "--watch --foreground --silent --existing --no-recursive --glob --yes --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY=( $(compgen -W "--watch --foreground --silent --existing --no-recursive --glob --yes --visibility --comments --private --no-comments --label --no-labels" -- "${cur}") )
return 0
;;
watch)
Expand All @@ -43,7 +43,7 @@ _gander_completions() {
return 0
;;
esac
COMPREPLY=( $(compgen -W "--foreground --silent --existing --no-recursive --glob --yes --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY=( $(compgen -W "--foreground --silent --existing --no-recursive --glob --yes --visibility --comments --private --no-comments --label --no-labels" -- "${cur}") )
COMPREPLY+=( $(compgen -f -- "${cur}") )
return 0
;;
Expand Down
82 changes: 82 additions & 0 deletions labels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
)

type stringList []string

func (s *stringList) String() string { return strings.Join(*s, ",") }

func (s *stringList) Set(v string) error {
v = strings.TrimSpace(v)
if v == "" {
return errEmptyLabel
}
*s = append(*s, v)
return nil
}

var errEmptyLabel = errLabel("label must not be empty")

type errLabel string

func (e errLabel) Error() string { return string(e) }

func inferProjectLabel(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
dir := path
if st, err := os.Stat(path); err == nil && !st.IsDir() {
dir = filepath.Dir(path)
}
cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
return ""
}
root := strings.TrimSpace(string(out))
if root == "" {
return ""
}
return slugifyLabel(filepath.Base(root))
}

func slugifyLabel(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '/':
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte('-')
}
}
out := b.String()
if out == "" || utf8.RuneCountInString(out) > 32 {
return ""
}
return out
}

func applyAutoLabel(opts shareOpts, path string, isNew bool) shareOpts {
if opts.Labels != nil || !isNew {
return opts
}
if inferred := inferProjectLabel(path); inferred != "" {
v := []string{inferred}
opts.Labels = &v
}
return opts
}
64 changes: 64 additions & 0 deletions labels_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"testing"
)

func TestInferProjectLabelFromGitToplevel(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
tmp := t.TempDir()
proj := filepath.Join(tmp, "gander-cli")
if err := os.Mkdir(proj, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init", "--quiet", proj)
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
plans := filepath.Join(proj, "plans")
if err := os.Mkdir(plans, 0755); err != nil {
t.Fatal(err)
}
md := filepath.Join(plans, "foo.md")
if err := os.WriteFile(md, []byte("# hi\n"), 0644); err != nil {
t.Fatal(err)
}
if got := inferProjectLabel(md); got != "gander-cli" {
t.Errorf("inferProjectLabel(%q) = %q, want gander-cli", md, got)
}
outside := filepath.Join(tmp, "notes.md")
if err := os.WriteFile(outside, []byte("# x\n"), 0644); err != nil {
t.Fatal(err)
}
if got := inferProjectLabel(outside); got != "" {
t.Errorf("non-git path labeled %q, want empty", got)
}
}

func TestApplyAutoLabel(t *testing.T) {
review := []string{"review"}
opts := shareOpts{Labels: &review}
got := applyAutoLabel(opts, "/tmp/doc.md", true)
if got.Labels == nil || len(*got.Labels) != 1 || (*got.Labels)[0] != "review" {
t.Errorf("explicit labels overwritten: %v", got.Labels)
}
got = applyAutoLabel(shareOpts{}, "/tmp/doc.md", false)
if got.Labels != nil {
t.Errorf("existing share auto-labeled: %v", got.Labels)
}
}

func TestSlugifyLabel(t *testing.T) {
if got := slugifyLabel("gander cli"); got != "gander-cli" {
t.Errorf("slugify = %q", got)
}
if got := slugifyLabel(" "); got != "" {
t.Errorf("blank slug = %q", got)
}
}
10 changes: 8 additions & 2 deletions list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
)
Expand All @@ -27,7 +28,7 @@ func runList(_ []string) error {
}

tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SHORT ID\tFILE\tPATH\tWATCH\tCOMMENTING\tVISIBILITY\tUPDATED\tURL")
fmt.Fprintln(tw, "SHORT ID\tFILE\tPATH\tWATCH\tCOMMENTING\tVISIBILITY\tLABELS\tUPDATED\tURL")
for i := range all {
watch := "no"
if all[i].Watch {
Expand All @@ -37,14 +38,19 @@ func runList(_ []string) error {
if path == "" {
path = "-"
}
labels := "-"
if len(all[i].Labels) > 0 {
labels = strings.Join(all[i].Labels, ",")
}
updated, _ := time.Parse(time.RFC3339, all[i].UpdatedAt)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
all[i].ShortID,
all[i].Filename,
path,
watch,
all[i].CommentAccess,
all[i].DocVisibility,
labels,
updated.Format("2006-01-02 15:04 MST"),
all[i].URL,
)
Expand Down
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,11 @@ func printUsage(w io.Writer) {
if authed {
fmt.Fprintln(w, " gander share [--watch] [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes]")
fmt.Fprintln(w, " [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] <file|dir>")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] [--label name] [--no-labels] <file|dir>")
fmt.Fprintln(w, " Upload to gander.md (keeps a dashboard share of the same file)")
fmt.Fprintln(w, " gander watch [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes]")
fmt.Fprintln(w, " [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] <file|dir>")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] [--label name] [--no-labels] <file|dir>")
fmt.Fprintln(w, " Live-share to gander.md and push every save (alias for `share --watch`)")
fmt.Fprintln(w, " gander remove [--all|--pick <short_id>|--yes|--non-interactive] <file|short_id|url>")
fmt.Fprintln(w, " Delete a share from gander.md")
Expand Down
23 changes: 20 additions & 3 deletions man/man1/gander.1
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ gander \- render Markdown locally, optionally share it on gander.md
.RB [ \-\-private ]
.RB [ \-\-comments =\fIanyone|private|disabled\fR]
.RB [ \-\-no\-comments ]
.RB [ \-\-label \ \fIname\fR]
.RB [ \-\-no\-labels ]
.IR file.md | dir
.sp
.B gander watch
Expand All @@ -32,6 +34,8 @@ gander \- render Markdown locally, optionally share it on gander.md
.RB [ \-\-private ]
.RB [ \-\-comments =\fIanyone|private|disabled\fR]
.RB [ \-\-no\-comments ]
.RB [ \-\-label \ \fIname\fR]
.RB [ \-\-no\-labels ]
.IR file.md | dir
.sp
.B gander status
Expand Down Expand Up @@ -211,7 +215,7 @@ is required: the runner adopts new matching
.B .md
files, shares them, and does not open a browser.
Each policy flag is omitted from the request unless set, so a content
refresh does not reset visibility or commenting. The same flags are
refresh does not reset visibility, commenting, or labels. The same flags are
accepted by
.BR watch .
.sp
Expand Down Expand Up @@ -264,6 +268,17 @@ is only valid when visibility is anyone.
Alias for
.BR \-\-comments = disabled .
Not an alias for private commenting.
.TP
.BI \-\-label " name"
Attach a label (repeatable). Passing any
.B \-\-label
replaces the set and skips auto\-labeling from the git toplevel.
.TP
.B \-\-no\-labels
Clear labels
.RB ( labels: [] ).
Cannot be combined with
.BR \-\-label .
.RE
.SS "Watch"
.TP
Expand Down Expand Up @@ -291,8 +306,10 @@ Accepts the same
.BR \-\-visibility ","
.BR \-\-private ","
.BR \-\-comments ","
.BR \-\-no\-comments ","
.BR \-\-label ","
and
.B \-\-no\-comments
.B \-\-no\-labels
flags as
.BR share .
.SS "Status"
Expand Down Expand Up @@ -369,7 +386,7 @@ when the argument is ambiguous.
.TP
.B list
Print a table of every share in the user's gander.md account (short id,
file, path, watch flag, commenting, visibility, last update, URL).
file, path, watch flag, commenting, visibility, labels, last update, URL).
.SS "Invite"
.TP
.B invite
Expand Down
Loading