diff options
| author | tjp <tjp@ctrl-c.club> | 2024-01-03 12:17:37 -0700 |
|---|---|---|
| committer | tjp <tjp@ctrl-c.club> | 2024-01-03 12:17:37 -0700 |
| commit | 859f74231f2b48d2dcf6a29682e7651b504fda12 (patch) | |
| tree | e03126c48c8385d98ba81525e7b628d5ca2257ca | |
| parent | 6c9558c0d2201d933b1d396febeb6e70ceaad058 (diff) | |
tours
| -rw-r--r-- | README.gmi | 48 | ||||
| -rw-r--r-- | actions.go | 103 | ||||
| -rw-r--r-- | command.go | 32 | ||||
| -rw-r--r-- | files.go | 93 | ||||
| -rw-r--r-- | main.go | 10 | ||||
| -rw-r--r-- | state.go | 14 | ||||
| -rw-r--r-- | tour.go | 214 |
7 files changed, 488 insertions, 26 deletions
@@ -1,5 +1,32 @@ # X-1: fly around the small web at the speed of sound +```ASCII art image of a Bell X-1 in flight + ... + *=:::. + ---======- -+-..... + .#@@@@@@@= .+=-:....:++ + +@@@@@@@@# :-::.. ...*+ + :%@@@@@@@@@. =*+++==:.... + .*@@@@@@@@@@= -#%###**++-:.. + -@@@@@@@@@@@% .::-=*#@@#*#@@@@@@@%-. + ..:::----=====++#@@@@@@@@@@@@%%%@@@@@@@@@@%%#**@@@@@@@= + .-+#%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@- + =*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%@@*%@@@@@@@@@@- .. + -+*. .#@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@@@@%%%%###- =- .=.==----==--:... + ...:::::=#@@@@#**+*##@@@#*%%%%%%%%##############******+==-:-=-:.-==-:.. ... + .:.:-==++++====::-----::---:-+%@@@@@@@@@@@@@#-... + . ...............=@@@@@@@@@@@@@@+.... + -@@@@@@@@@@@@@#. + -@@@@@@@@@@@@%- + -@@%%##@@@@@@= + =%++*=-=%@@@*. + =--*%%%::#@#: + *@+=#-+*%@%- + .*%****###@+. + ::..:::.::*@@@%%%%%#: + +``` + ## Prior art => gemini://mozz.us/journal/2021-01-01.gmi Mozz post on gemini client navigation @@ -28,9 +55,9 @@ The default action for a completely empty command is "print", which will display * hi[story]: display the current history * p[rint]: display the current page * l[inks]: filter the page down to just a list of its links -* |<shell command>: pipe the page's content through any shell command, executed with "sh -c" +* pi[pe] <shell command>, or |<shell command>: pipe the page's content through any shell command, executed with "sh -c" * s[ave] <path>: save the page's content at a particular filesystem path -* h[elp] [<command>]: show a help screen, optionally the one about a specific command +* h[elp] [<topic>]: show a help screen * m[ark]: bookmarks, see below * t[our]: tours, seee below * q[uit]: exit the browser @@ -42,9 +69,9 @@ Commands which take URLs can take them in a variety of forms: * relative URLs interpreted relative to the current page * "." always refers to the current page's URL * positive integers - the link index shown next to links on the current page -* "m:<name>" - the link saved by a mark with a specific name -* "t:<N>" - the URL in the default tour at index N -* "t<X>:<N>" - The URL in the X tour at index N +* "m:<name>" - the link saved by a mark with a specific name (or unique prefix of a mark name) +* "t:<N>" - the URL in the current tour at index N +* "t[<X>]:<N>" - The URL in the named X tour at index N The "tour add" command additionally also supports ranges of URLs in these forms: * "x-y" where x and y are integers, this expands to the range of links in the current page from x to y, inclusive on both ends @@ -80,14 +107,14 @@ On any other nagivation, this context is cleared and next/previous actions won't * "tour sh[ow]" prints the current tour and where we currently are * "tour c[lear]" empties the current tour -* "tour l[ist]" lists the default tour and any non-empty letter tours +* "tour l[ist]" lists the default tour and any non-empty named tours * "tour p[revious]" goes back to the previous link in the tour * "tour g[o] i" takes an index and jumps to that position in the tour -* "tour s[et] x" takes a single lower-case alphabetic character to select *which* tour should become active -* without this letter, it re-selects the default tour -* the default tour is the empty one. it is always active and empty upon startup -* the other letter-identified tours have their state preserved when the program exits +* "tour s[et] x" takes a name to select *which* tour should become active - any unique prefix will work, but the precise name will be used if it doesn't match any existing tour +* without this name it re-selects the default tour +* the default tour is always active and empty upon startup +* the named tours have their state preserved upon any change to them ## Config file @@ -95,6 +122,7 @@ The config file is located at XDG_DATA_HOME/x-1/config.toml. This is usually und It contains the following configuration options: +* vim_keys (bool): whether to activate vim keybindings for the readline prompt. default "true" * default_scheme (string): the URL scheme to use in the "go" command when none is provided. default "gemini" * soft_wrap (int): the number of columns to wrap, or -1 to not add soft wrapping. default 72 * download_folder (string): path at which to store files saved by the "save" command. default $HOME @@ -7,9 +7,11 @@ import ( "io" "net/url" "os" + "os/exec" "path/filepath" "strconv" "strings" + "sync" "syscall" "tildegit.org/tjp/sliderule" @@ -32,7 +34,8 @@ var ( ErrInvalidNumericLink = errors.New("no link with that number") ErrInvalidLink = errors.New("that doesn't look like a valid URL") ErrSaveNeedsFilename = errors.New("save requires a filename argument") - ErrUnrecognizedMark = errors.New("mark what?") + ErrInvalidMarkArgs = errors.New("mark what?") + ErrInvalidTourArgs = errors.New("tour what?") ) func Navigate(state *BrowserState, target *url.URL, navIndex int, conf *Config) error { @@ -215,6 +218,9 @@ func Go(state *BrowserState, dest string, conf *Config) error { func parseURL(str string, state *BrowserState, defaultScheme string) (*url.URL, int, error) { if str == "." { + if state.Url == nil { + return nil, -1, ErrMustBeOnAPage + } return state.Url, -1, nil } @@ -224,6 +230,34 @@ func parseURL(str string, state *BrowserState, defaultScheme string) (*url.URL, return nil, -1, err } str = target + } else if strings.HasPrefix(str, "t:") { + i, err := strconv.Atoi(str[2:]) + if err != nil { + return nil, -1, ErrInvalidLink + } + + if i < 0 || i >= len(state.CurrentTour.Links) { + return nil, -1, ErrInvalidTourPos + } + return state.CurrentTour.Links[i], -1, nil + } else if strings.HasPrefix(str, "t[") { + idx := strings.IndexByte(str, ']') + if idx < 0 || idx >= len(str)-2 || str[idx+1] != ':' { + return nil, -1, ErrInvalidLink + } + if i, err := strconv.Atoi(str[idx+2:]); err != nil { + return nil, -1, ErrInvalidLink + } else { + tour, err := findTour(state, str[2:idx]) + if err != nil { + return nil, -1, err + } + + if i < 0 || i >= len(tour.Links) { + return nil, -1, ErrInvalidTourPos + } + return tour.Links[i], -1, nil + } } var u *url.URL @@ -246,6 +280,11 @@ func parseURL(str string, state *BrowserState, defaultScheme string) (*url.URL, if state.Url != nil { u = state.Url.ResolveReference(u) } + + if u.Hostname() == "" { + return nil, -1, ErrInvalidLink + } + return u, i, nil } @@ -338,5 +377,65 @@ func Mark(state *BrowserState, args []string, conf *Config) error { return MarkList(state) } - return ErrUnrecognizedMark + return ErrInvalidMarkArgs +} + +func TourCmd(state *BrowserState, args []string, conf *Config) error { + switch args[0] { + case "add": + if args[1] == "next" { + return TourAddNext(state, conf, args[2:]) + } + return TourAdd(state, conf, args[1:]) + case "show": + return TourShow(state) + case "set": + return TourSet(state, args[1]) + case "next": + return TourNext(state, conf) + case "previous": + return TourPrevious(state, conf) + case "clear": + return TourClear(state) + case "list": + return TourList(state) + case "go": + return TourGo(state, conf, args[1]) + } + + return ErrInvalidTourArgs +} + +func Pipe(state *BrowserState, cmdStr string) error { + if state.Body == nil { + return ErrMustBeOnAPage + } + + sh, err := exec.LookPath("sh") + if err != nil { + return err + } + + cmd := exec.Command(sh, "-c", cmdStr) + cmd.Stdin = bytes.NewBuffer(state.Body) + + r, w := io.Pipe() + cmd.Stdout = w + cmd.Stderr = w + + cmd.Start() + + wg := &sync.WaitGroup{} + wg.Add(1) + var copyErr error + go func() { + defer wg.Done() + _, copyErr = io.Copy(os.Stdout, r) + }() + + waitErr := cmd.Wait() + _ = w.Close() + wg.Wait() + + return errors.Join(waitErr, copyErr) } @@ -56,6 +56,12 @@ func ParseCommand(line string) (*Command, error) { if strings.HasPrefix("previous", cmd) { return &Command{Name: "previous"}, nil } + if strings.HasPrefix("pipe", cmd) { + return &Command{ + Name: "pipe", + Args: []string{rest}, + }, nil + } case 'u': if strings.HasPrefix("up", cmd) { return &Command{Name: "up"}, nil @@ -163,11 +169,11 @@ func parseMarkArgs(line string) ([]string, error) { } func parseTourArgs(line string) ([]string, error) { - if line == "" { + fields := strings.Fields(line) + if len(fields) == 0 { return []string{"next"}, nil } - fields := strings.Fields(line) switch fields[0][0] { case 'a': if strings.HasPrefix("add", fields[0]) { @@ -176,10 +182,21 @@ func parseTourArgs(line string) ([]string, error) { return nil, ErrInvalidArgs } if strings.HasPrefix("next", fields[1]) { + if len(fields) == 2 { + return nil, ErrInvalidArgs + } fields[1] = "next" } return fields, nil } + case 'c': + if strings.HasPrefix("clear", fields[0]) { + fields[0] = "clear" + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + return fields, nil + } case 'n': if strings.HasPrefix("next", fields[0]) { fields[0] = "next" @@ -192,14 +209,11 @@ func parseTourArgs(line string) ([]string, error) { if strings.HasPrefix("set", fields[0]) { fields[0] = "set" if len(fields) == 1 { - return fields, nil + return append(fields, ""), nil } if len(fields) != 2 { return nil, ErrInvalidArgs } - if len(fields[1]) != 1 || fields[1][0] < 'a' || fields[1][0] > 'z' { - return nil, ErrInvalidArgs - } return fields, nil } if strings.HasPrefix("show", fields[0]) { @@ -238,7 +252,7 @@ func parseTourArgs(line string) ([]string, error) { } } - return nil, ErrInvalidArgs + return append([]string{"add"}, fields...), nil } func RunCommand(conf *Config, cmd *Command, state *BrowserState) error { @@ -261,6 +275,8 @@ func RunCommand(conf *Config, cmd *Command, state *BrowserState) error { return Up(state, conf) case "go": return Go(state, cmd.Args[0], conf) + case "pipe": + return Pipe(state, cmd.Args[0]) case "print": return Print(state) case "links": @@ -271,6 +287,8 @@ func RunCommand(conf *Config, cmd *Command, state *BrowserState) error { return Save(state, cmd.Args[0], conf) case "mark": return Mark(state, cmd.Args, conf) + case "tour": + return TourCmd(state, cmd.Args, conf) case "quit": os.Exit(0) } @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -16,6 +17,7 @@ type Config struct { DefaultScheme string `toml:"default_scheme"` SoftWrap int `toml:"soft_wrap"` DownloadFolder string `toml:"download_folder"` + VimKeys bool `toml:"vim_keys"` } func getConfig() (*Config, error) { @@ -31,6 +33,7 @@ func getConfig() (*Config, error) { } c := Config{ + VimKeys: true, DefaultScheme: "gemini", SoftWrap: 80, DownloadFolder: home, @@ -56,8 +59,9 @@ func getMarks() (map[string]string, error) { if err != nil { return nil, err } - rdr := bufio.NewScanner(f) + defer func() { _ = f.Close() }() + rdr := bufio.NewScanner(f) for rdr.Scan() { line := rdr.Text() name, target, _ := strings.Cut(line, ":") @@ -76,7 +80,7 @@ func saveMarks(marks map[string]string) error { return err } - f, err := os.OpenFile(path, os.O_WRONLY, 0o600) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } @@ -93,12 +97,95 @@ func saveMarks(marks map[string]string) error { } func marksFilePath() (string, error) { + return dataFilePath("marks") +} + +func getTours() (map[string]*Tour, error) { + path, err := toursFilePath() + if err != nil { + return nil, err + } + + tours := make(map[string]*Tour) + var current Tour + var currentName string + + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + rdr := bufio.NewScanner(f) + for rdr.Scan() { + line := rdr.Text() + if strings.HasSuffix(line, ":") { + if currentName != "" { + tours[currentName] = ¤t + } + currentName = strings.TrimSuffix(line, ":") + current = Tour{} + } else { + u, err := url.Parse(line) + if err != nil { + return nil, err + } + current.Links = append(current.Links, u) + } + } + if err := rdr.Err(); err != nil { + return nil, err + } + + if currentName != "" { + tours[currentName] = ¤t + } + + return tours, nil +} + +func saveTours(tours map[string]*Tour) error { + path, err := toursFilePath() + if err != nil { + return err + } + + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + for name, tour := range tours { + if len(tour.Links) == 0 { + continue + } + + if _, err := fmt.Fprintf(f, "%s:\n", name); err != nil { + return err + } + + for _, link := range tour.Links { + if _, err := fmt.Fprintf(f, "%s\n", link.String()); err != nil { + return err + } + } + } + + return nil +} + +func toursFilePath() (string, error) { + return dataFilePath("tours") +} + +func dataFilePath(filename string) (string, error) { home := os.Getenv("HOME") path := os.Getenv("XDG_DATA_HOME") if path == "" { path = filepath.Join(home, ".local", "share") } - path = filepath.Join(path, "x-1", "marks") + path = filepath.Join(path, "x-1", filename) if err := ensurePath(path); err != nil { return "", err @@ -28,7 +28,15 @@ func main() { } state.Marks = marks - rl.SetVimMode(true) + tours, err := getTours() + if err != nil { + log.Fatal(err) + } + state.NamedTours = tours + + if conf.VimKeys { + rl.SetVimMode(true) + } state.Readline = rl for { @@ -9,8 +9,14 @@ import ( type BrowserState struct { *History - Modal []byte - Marks map[string]string + Modal []byte + + Marks map[string]string + + NamedTours map[string]*Tour + DefaultTour Tour + CurrentTour *Tour + Readline *readline.Instance } @@ -39,11 +45,13 @@ type Link struct { } func NewBrowserState() *BrowserState { - return &BrowserState{ + state := &BrowserState{ History: &History{ Url: nil, Depth: 0, NavIndex: -1, }, } + state.CurrentTour = &state.DefaultTour + return state } @@ -0,0 +1,214 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "slices" + "strconv" + "strings" +) + +var ( + ErrEndOfTour = errors.New("you've hit the end of the tour") + ErrStartOfTour = errors.New("you're at the start of the tour") + ErrInvalidTourPos = errors.New("that's not a valid tour link") +) + +type Tour struct { + Index int + Links []*url.URL +} + +func parseURLs(state *BrowserState, defaultScheme, str string) ([]*url.URL, error) { + urls := []*url.URL{} + + if str == "*" { + for _, link := range state.Links { + urls = append(urls, state.Url.ResolveReference(link.Target)) + } + return urls, nil + } + + if i := strings.IndexByte(str, '-'); i > 0 { + start, e1 := strconv.Atoi(str[:i]) + end, e2 := strconv.Atoi(str[i+1:]) + if e1 == nil && e2 == nil && end >= start && start >= 0 && start < len(state.Links) && end < len(state.Links) { + for _, link := range state.Links[start : end+1] { + urls = append(urls, state.Url.ResolveReference(link.Target)) + } + return urls, nil + } + } + + u, _, err := parseURL(str, state, defaultScheme) + if err != nil { + return nil, err + } + return []*url.URL{u}, nil +} + +func TourAdd(state *BrowserState, conf *Config, targets []string) error { + newurls := []*url.URL{} + for _, target := range targets { + urls, err := parseURLs(state, conf.DefaultScheme, target) + if err != nil { + return err + } + newurls = append(newurls, urls...) + } + + state.CurrentTour.Links = append(state.CurrentTour.Links, newurls...) + + if state.CurrentTour != &state.DefaultTour { + return saveTours(state.NamedTours) + } + return nil +} + +func TourAddNext(state *BrowserState, conf *Config, targets []string) error { + newurls := []*url.URL{} + for _, target := range targets { + urls, err := parseURLs(state, conf.DefaultScheme, target) + if err != nil { + return err + } + newurls = append(newurls, urls...) + } + + state.CurrentTour.Links = slices.Insert( + state.CurrentTour.Links, + state.CurrentTour.Index, + newurls..., + ) + + if state.CurrentTour != &state.DefaultTour { + return saveTours(state.NamedTours) + } + return nil +} + +func TourShow(state *BrowserState) error { + tour := state.CurrentTour + buf := &bytes.Buffer{} + for i, link := range tour.Links { + mark := "" + if i == tour.Index-1 { + mark = "* " + } + if _, err := fmt.Fprintf(buf, "%s%d %s\n", mark, i, link.String()); err != nil { + return err + } + } + + state.Modal = buf.Bytes() + if len(state.Modal) == 0 { + state.Modal = []byte("(empty)\n") + } + return Print(state) +} + +func TourNext(state *BrowserState, conf *Config) error { + tour := state.CurrentTour + if tour.Index >= len(tour.Links) || len(tour.Links) == 0 { + return ErrEndOfTour + } + page := tour.Links[tour.Index] + tour.Index += 1 + + return Navigate(state, page, -1, conf) +} + +func TourPrevious(state *BrowserState, conf *Config) error { + tour := state.CurrentTour + if tour.Index <= 0 { + return ErrStartOfTour + } + tour.Index -= 1 + if tour.Index <= 0 { + return ErrStartOfTour + } + page := tour.Links[tour.Index-1] + + return Navigate(state, page, -1, conf) +} + +func TourClear(state *BrowserState) error { + state.CurrentTour.Index = 0 + state.CurrentTour.Links = nil + + if state.CurrentTour != &state.DefaultTour { + return saveTours(state.NamedTours) + } + return nil +} + +func TourList(state *BrowserState) error { + buf := &bytes.Buffer{} + mark := "" + if state.CurrentTour == &state.DefaultTour { + mark = "* " + } + if _, err := fmt.Fprintf(buf, "%s(default): %d links\n", mark, len(state.DefaultTour.Links)); err != nil { + return err + } + for name, tour := range state.NamedTours { + mark = "" + if tour == state.CurrentTour { + mark = "* " + } + if _, err := fmt.Fprintf(buf, "%s%s: %d links\n", mark, name, len(tour.Links)); err != nil { + return err + } + } + + state.Modal = buf.Bytes() + return Print(state) +} + +func TourGo(state *BrowserState, conf *Config, pos string) error { + tour := state.CurrentTour + + i, _ := strconv.Atoi(pos) + if i < 0 || i >= len(tour.Links) { + return ErrInvalidTourPos + } + + tour.Index = i + 1 + return Navigate(state, tour.Links[i], -1, conf) +} + +func TourSet(state *BrowserState, name string) error { + tour, err := findTour(state, name) + if err == nil { + state.CurrentTour = tour + } + return err +} + +func findTour(state *BrowserState, prefix string) (*Tour, error) { + if prefix == "" { + return &state.DefaultTour, nil + } + + found := 0 + var value *Tour + for name, tour := range state.NamedTours { + if strings.HasPrefix(name, prefix) { + found += 1 + value = tour + } + } + + switch found { + case 0: + tour := &Tour{} + state.NamedTours[prefix] = tour + return tour, nil + case 1: + return value, nil + default: + return nil, fmt.Errorf("too ambiguous - found %d matching tours", found) + } +} |
