summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortjp <tjp@ctrl-c.club>2024-01-03 12:17:37 -0700
committertjp <tjp@ctrl-c.club>2024-01-03 12:17:37 -0700
commit859f74231f2b48d2dcf6a29682e7651b504fda12 (patch)
treee03126c48c8385d98ba81525e7b628d5ca2257ca
parent6c9558c0d2201d933b1d396febeb6e70ceaad058 (diff)
tours
-rw-r--r--README.gmi48
-rw-r--r--actions.go103
-rw-r--r--command.go32
-rw-r--r--files.go93
-rw-r--r--main.go10
-rw-r--r--state.go14
-rw-r--r--tour.go214
7 files changed, 488 insertions, 26 deletions
diff --git a/README.gmi b/README.gmi
index 4dfb32d..0264565 100644
--- a/README.gmi
+++ b/README.gmi
@@ -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
diff --git a/actions.go b/actions.go
index 8ea7ce8..d9a5d06 100644
--- a/actions.go
+++ b/actions.go
@@ -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)
}
diff --git a/command.go b/command.go
index 760ee25..46390da 100644
--- a/command.go
+++ b/command.go
@@ -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)
}
diff --git a/files.go b/files.go
index e5fba09..f818ddb 100644
--- a/files.go
+++ b/files.go
@@ -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] = &current
+ }
+ 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] = &current
+ }
+
+ 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
diff --git a/main.go b/main.go
index c1a833b..d2740e1 100644
--- a/main.go
+++ b/main.go
@@ -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 {
diff --git a/state.go b/state.go
index 5838fcd..1af2503 100644
--- a/state.go
+++ b/state.go
@@ -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
}
diff --git a/tour.go b/tour.go
new file mode 100644
index 0000000..d74bef4
--- /dev/null
+++ b/tour.go
@@ -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)
+ }
+}