diff options
| -rw-r--r-- | README.gmi | 48 | ||||
| -rw-r--r-- | actions.go | 342 | ||||
| -rw-r--r-- | command.go | 279 | ||||
| -rw-r--r-- | files.go | 122 | ||||
| -rw-r--r-- | go.mod | 12 | ||||
| -rw-r--r-- | go.sum | 24 | ||||
| -rw-r--r-- | handlers.go | 201 | ||||
| -rw-r--r-- | main.go | 51 | ||||
| -rw-r--r-- | mark.go | 64 | ||||
| -rw-r--r-- | state.go | 49 |
10 files changed, 1181 insertions, 11 deletions
@@ -14,7 +14,7 @@ These posts offer up some helpful but uncommon ideas for a browser: ## Commands -The default action is "go", so just putting an integer or URL in the command bar goes there. +The default action for a completely empty command is "print", which will display the current page. If, however, a URL is entered then "go" is implied. * re[load]: re-request and re-display the current page * r[oot]: go to the root of the current site, or /~username root if in a tilde-path @@ -22,16 +22,33 @@ The default action is "go", so just putting an integer or URL in the command bar * b[ack]: back in history * f[orward]: forward in history * n[ext]: go to the next link in the previous history page - see below (like "back" followed by "go N+1" where N is the link index we used to get to the current page) -* p[revious]: go to the previous link in the previous history page - see below +* pre[vious]: go to the previous link in the previous history page - see below * u[p]: go to the parent directory of the current page * g[o] <link>: visits a URL, which may be an integer in which case it's the numbered link on the current page, or an absolute or relative URL * hi[story]: display the current history -* l[ess]: pipe the current page's content through less(1) +* 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" * 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 * m[ark]: bookmarks, see below * t[our]: tours, seee below +* q[uit]: exit the browser + +## URL specifiers + +Commands which take URLs can take them in a variety of forms: +* full absolute URLs with or without a scheme +* 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 + +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 +* "*" adds all the links on the current page ## Next/Previous @@ -46,9 +63,9 @@ On any other nagivation, this context is cleared and next/previous actions won't ## Marks * named "m[ark]" instead of "bookmark" so "m" is used instead of interfering with "back/b" -* "mark a[dd] <url> <name>" adds a single mark. <name> can contain slashes to create a heirarchy +* "mark a[dd] <name> <url>" adds a single mark. <name> can contain slashes to create a heirarchy * "mark g[o] <name>" visits the given url -* "mark l[s]" shows the list of marks +* "mark l[ist]" shows the list of marks * "mark X" with a non-command argument defaults to "mark go X" ## Tour @@ -56,19 +73,28 @@ On any other nagivation, this context is cleared and next/previous actions won't * "t[our]" on it's own defaults to "tour next" * "tour x y z", where x is not a valid command name, defaults to "tour add x y z" -* "tour a[dd]" allows any valid links as arguments including both integer indices and absolute or relative URLs -* additional URL specifiers specific to "tour add" are "x-y" which expands to the integer range between x and y (inclusive on both ends), and "*" meaning everything on the current page -* the variant "tour add n[ext] x y z" adds its items to the next position in the tour, before anything else that was still to come +* "tour a[dd]" allows any valid links as additional arguments - see "URL specifiers" above for details +* the variant "tour add n[ext] x y z" adds items next in the tour after the current position * "tour next" visits the next link in the current tour * "tour sh[ow]" prints the current tour and where we currently are * "tour c[lear]" empties the current tour -* "tour l[s]" lists the default tour and any non-empty letter tours +* "tour l[ist]" lists the default tour and any non-empty letter tours * "tour p[revious]" goes back to the previous link in the tour -* "tour g[o]" takes an index and jumps to that position in the tour +* "tour g[o] i" takes an index and jumps to that position in the tour -* "tour s[et]" takes a single lower-case alphabetic character to select *which* tour should become active +* "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 + +## Config file + +The config file is located at XDG_DATA_HOME/x-1/config.toml. This is usually under .local/share in your home directory. + +It contains the following configuration options: + +* 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 new file mode 100644 index 0000000..8ea7ce8 --- /dev/null +++ b/actions.go @@ -0,0 +1,342 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "tildegit.org/tjp/sliderule" +) + +var client sliderule.Client + +func init() { + client = sliderule.NewClient(nil) +} + +var ( + ErrMustBeOnAPage = errors.New("you must be on a page to do that, use the \"go\" command first") + ErrNoPreviousHistory = errors.New("there is no previous page in the history") + ErrNoNextHistory = errors.New("there is no page to go forward to") + ErrOnLastLink = errors.New("already on the last link") + ErrOnFirstLink = errors.New("already on the first link") + ErrCantMoveRelative = errors.New("next/previous only work after navigating to a link on a page") + ErrAlreadyAtTop = errors.New("already at the site root") + 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?") +) + +func Navigate(state *BrowserState, target *url.URL, navIndex int, conf *Config) error { + hist := state.History + + if hist.Url == nil || target.String() != hist.Url.String() { + state.History = &History{ + Url: target, + Depth: hist.Depth + 1, + Back: hist, + NavIndex: navIndex, + } + hist.Forward = state.History + } + state.Modal = nil + + return Reload(state, conf) +} + +func gopherURL(u *url.URL) (string, sliderule.Status) { + if u.Scheme != "gopher" || len(u.Path) < 2 || !strings.HasPrefix(u.Path, "/") { + return u.String(), 0 + } + itemType := u.Path[1] + clone := *u + clone.Path = u.Path[2:] + return clone.String(), sliderule.Status(itemType) +} + +func Reload(state *BrowserState, conf *Config) error { + if state.Url == nil { + return ErrMustBeOnAPage + } + + urlStr, _ := gopherURL(state.Url) + response, err := client.Fetch(urlStr) + if err != nil { + return err + } + + state.DocType = docType(state.Url, response) + state.Body, err = io.ReadAll(response.Body) + if err != nil { + return err + } + + state.Formatted, state.Links, err = parseDoc(state.DocType, state.Body, conf) + if err != nil { + return err + } + + return Print(state) +} + +func back(state *BrowserState) error { + if state.Back == nil { + return ErrNoPreviousHistory + } + state.History = state.Back + state.Modal = nil + return nil +} + +func Back(state *BrowserState) error { + if err := back(state); err != nil { + return err + } + + _, _ = fmt.Fprintf(os.Stdout, "Back: %s\n", state.Url.String()) + return Print(state) +} + +func Forward(state *BrowserState) error { + if state.Forward == nil { + return ErrNoNextHistory + } + state.History = state.Forward + state.Modal = nil + + _, _ = fmt.Fprintf(os.Stdout, "Forward: %s\n", state.Url.String()) + + return Print(state) +} + +func Next(state *BrowserState, conf *Config) error { + switch state.NavIndex { + case -1: + return ErrCantMoveRelative + case len(state.Back.Links) - 1: + return ErrOnLastLink + } + + index := state.NavIndex + 1 + + if err := back(state); err != nil { + return err + } + + u := state.Url.ResolveReference(state.Links[index].Target) + + _, _ = fmt.Fprintf(os.Stdout, "Next: %s\n", u.String()) + + return Navigate(state, u, index, conf) +} + +func Previous(state *BrowserState, conf *Config) error { + switch state.NavIndex { + case -1: + return ErrCantMoveRelative + case 0: + return ErrOnFirstLink + } + + index := state.NavIndex - 1 + + if err := back(state); err != nil { + return err + } + + u := state.Url.ResolveReference(state.Links[index].Target) + + _, _ = fmt.Fprintf(os.Stdout, "Previous: %s\n", u.String()) + + return Navigate(state, u, index, conf) +} + +func Root(state *BrowserState, tilde bool, conf *Config) error { + if state.Url == nil { + return ErrMustBeOnAPage + } + + u := *state.Url + u.RawQuery = "" + u.Fragment = "" + + base := "/" + if u.Scheme == "gopher" { + base = "/1/" + } + + if tilde && strings.HasPrefix(u.Path, "/~") { + u.Path = base + strings.SplitN(u.Path, "/", 3)[1] + "/" + } else { + u.Path = base + } + + _, _ = fmt.Fprintf(os.Stdout, "Root: %s\n", u.String()) + + return Navigate(state, &u, -1, conf) +} + +func Up(state *BrowserState, conf *Config) error { + if state.Url == nil { + return ErrMustBeOnAPage + } + + u := *state.Url + u.Path = strings.TrimSuffix(u.Path, "/") + if u.Path == "" { + return ErrAlreadyAtTop + } + + u.Path = u.Path[:strings.LastIndex(u.Path, "/")+1] + + _, _ = fmt.Fprintf(os.Stdout, "Up: %s\n", u.String()) + + return Navigate(state, &u, -1, conf) +} + +func Go(state *BrowserState, dest string, conf *Config) error { + u, idx, err := parseURL(dest, state, conf.DefaultScheme) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(os.Stdout, "Go: %s\n", u.String()) + + return Navigate(state, u, idx, conf) +} + +func parseURL(str string, state *BrowserState, defaultScheme string) (*url.URL, int, error) { + if str == "." { + return state.Url, -1, nil + } + + if strings.HasPrefix(str, "m:") { + target, err := findMark(state, str[2:]) + if err != nil { + return nil, -1, err + } + str = target + } + + var u *url.URL + i, err := strconv.Atoi(str) + if err == nil { + if len(state.Links) <= i { + return nil, -1, ErrInvalidNumericLink + } + u = state.Links[i].Target + } else { + i = -1 + u, err = url.Parse(str) + if err != nil { + return nil, -1, ErrInvalidLink + } + if u.Scheme == "" { + u.Scheme = defaultScheme + } + } + if state.Url != nil { + u = state.Url.ResolveReference(u) + } + return u, i, nil +} + +func Print(state *BrowserState) error { + if state.Body == nil && state.Modal == nil { + return ErrMustBeOnAPage + } + out := []byte(state.Formatted) + if state.Modal != nil { + out = state.Modal + } + _, err := os.Stdout.Write(out) + return err +} + +func Links(state *BrowserState, conf *Config) error { + if state.Links == nil { + return ErrMustBeOnAPage + } + + buf := &bytes.Buffer{} + for _, link := range state.Links { + fmt.Fprintf(buf, "=> %s %s\n", link.Target.String(), link.Text) + } + formatted, _, err := parseDoc("text/gemini", buf.Bytes(), conf) + if err != nil { + return err + } + state.Modal = []byte(formatted) + + return Print(state) +} + +func HistoryCmd(state *BrowserState) error { + if state.History == nil { + return ErrMustBeOnAPage + } + + h := state.History + i := 0 + for h.Forward != nil { + h = h.Forward + i += 1 + } + + buf := &bytes.Buffer{} + j := 0 + for h.Url != nil { + mark := "" + if j == i { + mark = "* " + } + fmt.Fprintf(buf, "%s%s\n", mark, h.Url.String()) + j += 1 + h = h.Back + } + state.Modal = buf.Bytes() + + return Print(state) +} + +func Save(state *BrowserState, filename string, conf *Config) error { + if state.Body == nil { + return ErrMustBeOnAPage + } + if filename == "" { + return ErrSaveNeedsFilename + } + + p := filepath.Join(conf.DownloadFolder, filename) + _, err := os.Stat(p) + pbase := p + i := 1 + for !errors.Is(err, syscall.ENOENT) { + p = pbase + "-" + strconv.Itoa(i) + _, err = os.Stat(p) + i += 1 + } + + return os.WriteFile(p, state.Body, 0o644) +} + +func Mark(state *BrowserState, args []string, conf *Config) error { + switch args[0] { + case "add": + return MarkAdd(state, conf, args[1], args[2]) + case "go": + return MarkGo(state, conf, args[1]) + case "list": + return MarkList(state) + } + + return ErrUnrecognizedMark +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..760ee25 --- /dev/null +++ b/command.go @@ -0,0 +1,279 @@ +package main + +import ( + "errors" + "os" + "strconv" + "strings" +) + +var ( + ErrInvalidArgs = errors.New("bad command arguments") + ErrUnknownCommand = errors.New("don't know that one") +) + +type Command struct { + Name string + Args []string +} + +func ParseCommand(line string) (*Command, error) { + line = strings.TrimSpace(line) + if line == "" { + return &Command{Name: "print"}, nil + } + + cmd, rest, _ := strings.Cut(line, " ") + + switch line[0] { + case 'r': + if strings.HasPrefix("root", cmd) { + return &Command{Name: "root"}, nil + } + if strings.HasPrefix("reload", cmd) { + return &Command{Name: "reload"}, nil + } + case 'R': + if strings.HasPrefix("Root", cmd) { + return &Command{Name: "Root"}, nil + } + case 'b': + if strings.HasPrefix("back", cmd) { + return &Command{Name: "back"}, nil + } + case 'f': + if strings.HasPrefix("forward", cmd) { + return &Command{Name: "forward"}, nil + } + case 'n': + if strings.HasPrefix("next", cmd) { + return &Command{Name: "next"}, nil + } + case 'p': + if strings.HasPrefix("print", cmd) { + return &Command{Name: "print"}, nil + } + if strings.HasPrefix("previous", cmd) { + return &Command{Name: "previous"}, nil + } + case 'u': + if strings.HasPrefix("up", cmd) { + return &Command{Name: "up"}, nil + } + case 'g': + if strings.HasPrefix("go", cmd) { + if rest == "" { + return nil, ErrInvalidArgs + } + return &Command{Name: "go", Args: []string{rest}}, nil + } + case 'h': + if strings.HasPrefix("help", cmd) { + return &Command{ + Name: "help", + Args: []string{rest}, + }, nil + } + if strings.HasPrefix("history", cmd) { + return &Command{Name: "history"}, nil + } + case '|': + return &Command{ + Name: "pipe", + Args: []string{strings.TrimSpace(line[1:])}, + }, nil + case 's': + if strings.HasPrefix("save", cmd) { + return &Command{ + Name: "save", + Args: []string{rest}, + }, nil + } + case 'l': + if strings.HasPrefix("links", cmd) { + return &Command{Name: "links"}, nil + } + case 'm': + if strings.HasPrefix("mark", cmd) { + args, err := parseMarkArgs(rest) + if err != nil { + return nil, err + } + return &Command{Name: "mark", Args: args}, nil + } + case 't': + if strings.HasPrefix("tour", cmd) { + args, err := parseTourArgs(rest) + if err != nil { + return nil, err + } + return &Command{Name: "tour", Args: args}, nil + } + case 'q': + if strings.HasPrefix("quit", cmd) { + return &Command{Name: "quit"}, nil + } + } + + if rest == "" { + return &Command{Name: "go", Args: []string{cmd}}, nil + } + + return nil, ErrUnknownCommand +} + +func parseMarkArgs(line string) ([]string, error) { + if line == "" { + return nil, ErrInvalidArgs + } + + fields := strings.Fields(line) + switch fields[0][0] { + case 'a': + if strings.HasPrefix("add", fields[0]) { + fields[0] = "add" + if len(fields) != 3 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 'g': + if strings.HasPrefix("go", fields[0]) { + fields[0] = "go" + if len(fields) != 2 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 'l': + if strings.HasPrefix("list", fields[0]) { + fields[0] = "list" + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + return fields, nil + } + } + + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + + return []string{"go", fields[0]}, nil +} + +func parseTourArgs(line string) ([]string, error) { + if line == "" { + return []string{"next"}, nil + } + + fields := strings.Fields(line) + switch fields[0][0] { + case 'a': + if strings.HasPrefix("add", fields[0]) { + fields[0] = "add" + if len(fields) == 1 { + return nil, ErrInvalidArgs + } + if strings.HasPrefix("next", fields[1]) { + fields[1] = "next" + } + return fields, nil + } + case 'n': + if strings.HasPrefix("next", fields[0]) { + fields[0] = "next" + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 's': + if strings.HasPrefix("set", fields[0]) { + fields[0] = "set" + if len(fields) == 1 { + return 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]) { + fields[0] = "show" + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 'l': + if strings.HasPrefix("list", fields[0]) { + fields[0] = "list" + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 'p': + if strings.HasPrefix("previous", fields[0]) { + fields[0] = "previous" + if len(fields) != 1 { + return nil, ErrInvalidArgs + } + return fields, nil + } + case 'g': + if strings.HasPrefix("go", fields[0]) { + fields[0] = "go" + if len(fields) != 2 { + return nil, ErrInvalidArgs + } + if _, err := strconv.Atoi(fields[1]); err != nil { + return nil, ErrInvalidArgs + } + return fields, nil + } + } + + return nil, ErrInvalidArgs +} + +func RunCommand(conf *Config, cmd *Command, state *BrowserState) error { + switch cmd.Name { + case "root": + return Root(state, true, conf) + case "Root": + return Root(state, false, conf) + case "reload": + return Reload(state, conf) + case "back": + return Back(state) + case "forward": + return Forward(state) + case "next": + return Next(state, conf) + case "previous": + return Previous(state, conf) + case "up": + return Up(state, conf) + case "go": + return Go(state, cmd.Args[0], conf) + case "print": + return Print(state) + case "links": + return Links(state, conf) + case "history": + return HistoryCmd(state) + case "save": + return Save(state, cmd.Args[0], conf) + case "mark": + return Mark(state, cmd.Args, conf) + case "quit": + os.Exit(0) + } + + return ErrUnknownCommand +} diff --git a/files.go b/files.go new file mode 100644 index 0000000..e5fba09 --- /dev/null +++ b/files.go @@ -0,0 +1,122 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/BurntSushi/toml" +) + +type Config struct { + DefaultScheme string `toml:"default_scheme"` + SoftWrap int `toml:"soft_wrap"` + DownloadFolder string `toml:"download_folder"` +} + +func getConfig() (*Config, error) { + home := os.Getenv("HOME") + path := os.Getenv("XDG_CONFIG_HOME") + if path == "" { + path = filepath.Join(home, ".config") + } + path = filepath.Join(path, "x-1", "config.toml") + + if err := ensurePath(path); err != nil { + return nil, err + } + + c := Config{ + DefaultScheme: "gemini", + SoftWrap: 80, + DownloadFolder: home, + } + if _, err := toml.DecodeFile(path, &c); err != nil { + return nil, err + } + if strings.HasPrefix(c.DownloadFolder, "~") { + c.DownloadFolder = home + c.DownloadFolder[1:] + } + return &c, nil +} + +func getMarks() (map[string]string, error) { + path, err := marksFilePath() + if err != nil { + return nil, err + } + + marks := make(map[string]string) + + f, err := os.Open(path) + if err != nil { + return nil, err + } + rdr := bufio.NewScanner(f) + + for rdr.Scan() { + line := rdr.Text() + name, target, _ := strings.Cut(line, ":") + marks[name] = target + } + if err := rdr.Err(); err != nil { + return nil, err + } + + return marks, nil +} + +func saveMarks(marks map[string]string) error { + path, err := marksFilePath() + if err != nil { + return err + } + + f, err := os.OpenFile(path, os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + for name, target := range marks { + _, err := fmt.Fprintf(f, "%s:%s\n", name, target) + if err != nil { + return err + } + } + + return nil +} + +func marksFilePath() (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") + + if err := ensurePath(path); err != nil { + return "", err + } + + return path, nil +} + +func ensurePath(fpath string) error { + if _, err := os.Stat(fpath); errors.Is(err, syscall.ENOENT) { + if err := os.MkdirAll(filepath.Dir(fpath), 0o700); err != nil { + return err + } + f, err := os.OpenFile(fpath, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return err + } + _ = f.Close() + } + return nil +} @@ -1,3 +1,15 @@ module tildegit.org/tjp/x-1 go 1.21.0 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/chzyer/readline v1.5.1 + tildegit.org/tjp/sliderule v1.6.1 +) + +require ( + github.com/go-kit/log v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.5.1 // indirect + golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect +) @@ -0,0 +1,24 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +tildegit.org/tjp/sliderule v1.6.1 h1:/w0fiD17wS5NnNmpxWdLu9hOX8/CmhjMV1vMwMBMuao= +tildegit.org/tjp/sliderule v1.6.1/go.mod h1:opdo8E25iS9X9pNismM8U7pCH8XO0PdRIIhdADn8Uik= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..587af68 --- /dev/null +++ b/handlers.go @@ -0,0 +1,201 @@ +package main + +import ( + "bytes" + "fmt" + "mime" + "net/url" + "strings" + + "tildegit.org/tjp/sliderule" + "tildegit.org/tjp/sliderule/gemini" + "tildegit.org/tjp/sliderule/gemini/gemtext" + "tildegit.org/tjp/sliderule/gopher" + "tildegit.org/tjp/sliderule/gopher/gophermap" +) + +func docType(u *url.URL, response *sliderule.Response) string { + _, gopherType := gopherURL(u) + switch gopherType { + case gopher.MenuType: + return "text/x-gophermap" + case gopher.TextFileType, gopher.ErrorType, gopher.InfoMessageType: + return "text/plain" + case gopher.MacBinHexType, + gopher.DosBinType, + gopher.BinaryFileType, + gopher.ImageFileType, + gopher.MovieFileType, + gopher.SoundFileType, + gopher.DocumentType: + return "application/octet-stream" + case gopher.UuencodedType: + return "text/x-uuencode" + case gopher.GifFileType: + return "image/gif" + case gopher.BitmapType: + return "image/bmp" + case gopher.PngImageFileType: + return "image/png" + case gopher.HTMLType: + return "text/html" + case gopher.RtfDocumentType: + return "application/rtf" + case gopher.WavSoundFileType: + return "audio/wav" + case gopher.PdfDocumentType: + return "application/pdf" + case gopher.XmlDocumentType: + return "application/xml" + } + + if u.Scheme == "gemini" { + if response.Status == gemini.StatusSuccess { + mtype, _, err := mime.ParseMediaType(response.Meta.(string)) + if err == nil { + return mtype + } + } + } + + return "text/plain" +} + +func parseDoc(doctype string, body []byte, conf *Config) (string, []Link, error) { + switch doctype { + case "text/x-gophermap": + return parseGophermapDoc(body, conf.SoftWrap) + case "text/gemini": + return parseGemtextDoc(body, conf.SoftWrap) + } + + return string(body), nil, nil +} + +func parseGophermapDoc(body []byte, softWrap int) (string, []Link, error) { + var b strings.Builder + var l []Link + mapdoc, err := gophermap.Parse(bytes.NewBuffer(body)) + if err != nil { + return "", nil, err + } + + i := 0 + for _, item := range mapdoc { + switch item.Type { + case gopher.InfoMessageType: + for _, line := range fold(item.Display, softWrap) { + if _, err := b.WriteString(" " + line + "\n"); err != nil { + return "", nil, err + } + } + default: + l = append(l, Link{ + Text: item.Display, + Target: fmtGopherURL(item.Type, item.Selector, item.Hostname, item.Port), + }) + if _, err := b.WriteString(fmt.Sprintf("[%d]%s %s\n", i, linkSpaces(i), item.Display)); err != nil { + return "", nil, err + } + i += 1 + } + } + + return b.String(), l, nil +} + +func parseGemtextDoc(body []byte, softWrap int) (string, []Link, error) { + var b strings.Builder + var l []Link + gemdoc, err := gemtext.Parse(bytes.NewBuffer(body)) + if err != nil { + return "", nil, err + } + + i := 0 + for _, item := range gemdoc { + switch item.Type() { + case gemtext.LineTypeLink: + ll := item.(gemtext.LinkLine) + u, err := url.Parse(ll.URL()) + if err != nil { + return "", nil, err + } + l = append(l, Link{ + Text: ll.Label(), + Target: u, + }) + if _, err := b.WriteString(fmt.Sprintf("[%d]%s %s\n", i, linkSpaces(i), ll.Label())); err != nil { + return "", nil, err + } + i += 1 + default: + for _, line := range fold(item.String(), softWrap) { + if _, err := b.WriteString(" " + line + "\n"); err != nil { + return "", nil, err + } + } + } + } + + return b.String(), l, nil +} + +func fold(line string, width int) []string { + rs := []rune(strings.TrimSuffix(line, "\n")) + if len(rs) == 0 { + return []string{""} + } + + var b []string +outer: + for len(rs) > 0 { + if len(rs) <= width { + b = append(b, string(rs)) + break + } + + w := width + for rs[w] != ' ' && w > 0 { + w -= 1 + } + if w == 0 { + for i := width + 1; i < len(rs); i += 1 { + if rs[i] == ' ' { + b = append(b, string(rs[:i])) + rs = rs[i+1:] + continue outer + } + } + b = append(b, string(rs)) + break outer + } + + b = append(b, string(rs[:w])) + rs = rs[w+1:] + } + + return b +} + +func fmtGopherURL(itemtype sliderule.Status, selector, hostname, port string) *url.URL { + if port != "70" { + hostname += ":" + port + } + return &url.URL{ + Scheme: "gopher", + Host: hostname, + Path: "/" + string(byte(itemtype)) + selector, + } +} + +func linkSpaces(i int) string { + switch { + case i < 10: + return " " + case i < 100: + return " " + default: + return "" + } +} @@ -1,4 +1,55 @@ package main +import ( + "fmt" + "io" + "log" + "os" + + "github.com/chzyer/readline" +) + func main() { + conf, err := getConfig() + if err != nil { + log.Fatal(err) + } + + state := NewBrowserState() + + rl, err := readline.New(Prompt) + if err != nil { + log.Fatal(err) + } + + marks, err := getMarks() + if err != nil { + log.Fatal(err) + } + state.Marks = marks + + rl.SetVimMode(true) + state.Readline = rl + + for { + line, err := rl.Readline() + if err == io.EOF { + break + } + if err != nil { + log.Fatal(err) + } + + if c, err := ParseCommand(line); err != nil { + writeError(err.Error()) + } else if err := RunCommand(conf, c, state); err != nil { + writeError(err.Error()) + } + } +} + +const Prompt = "\x1b[38;5;39mX-1\x1b[0m> " + +func writeError(msg string) { + fmt.Fprintf(os.Stdout, "\x1b[31m%s\x1b[0m\n", msg) } @@ -0,0 +1,64 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "strings" +) + +var ( + ErrInvalidURL = errors.New("that's not a valid URL") + ErrNotAMark = errors.New("that's not a known mark name") +) + +func MarkAdd(state *BrowserState, conf *Config, name, target string) error { + u, _, err := parseURL(target, state, conf.DefaultScheme) + if err != nil { + return ErrInvalidURL + } + + state.Marks[name] = u.String() + return saveMarks(state.Marks) +} + +func MarkGo(state *BrowserState, conf *Config, name string) error { + target, err := findMark(state, name) + if err != nil { + return err + } + + return Go(state, target, conf) +} + +func MarkList(state *BrowserState) error { + buf := &bytes.Buffer{} + for name, target := range state.Marks { + _, err := fmt.Fprintf(buf, "%s: %s\n", name, target) + if err != nil { + return err + } + } + state.Modal = buf.Bytes() + return Print(state) +} + +func findMark(state *BrowserState, prefix string) (string, error) { + found := 0 + value := "" + for name, target := range state.Marks { + if strings.HasPrefix(name, prefix) { + found += 1 + value = target + } + } + + switch found { + case 0: + return "", ErrNotAMark + case 1: + return value, nil + default: + return "", fmt.Errorf("too ambiguous - found %d matching marks", found) + } +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..5838fcd --- /dev/null +++ b/state.go @@ -0,0 +1,49 @@ +package main + +import ( + "net/url" + + "github.com/chzyer/readline" +) + +type BrowserState struct { + *History + + Modal []byte + Marks map[string]string + Readline *readline.Instance +} + +type History struct { + Url *url.URL + + Depth int + DocType string + Body []byte + Formatted string + Links []Link + + Back *History + Forward *History + + // Non-negative if we browsed here via a page link, else -1. + // + // The non-negative value is the index in the "back" page's + // list of links that got us here. + NavIndex int +} + +type Link struct { + Text string + Target *url.URL +} + +func NewBrowserState() *BrowserState { + return &BrowserState{ + History: &History{ + Url: nil, + Depth: 0, + NavIndex: -1, + }, + } +} |
