summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortjp <tjp@ctrl-c.club>2024-01-03 08:29:40 -0700
committertjp <tjp@ctrl-c.club>2024-01-03 08:29:40 -0700
commit6c9558c0d2201d933b1d396febeb6e70ceaad058 (patch)
tree565b8b048fc3e63b6c2eb6892a6f356f0c3c15aa
parent4b3dd896fb0c157e0d727f39ccb6d940a75d1cce (diff)
working basic navigation and marks
-rw-r--r--README.gmi48
-rw-r--r--actions.go342
-rw-r--r--command.go279
-rw-r--r--files.go122
-rw-r--r--go.mod12
-rw-r--r--go.sum24
-rw-r--r--handlers.go201
-rw-r--r--main.go51
-rw-r--r--mark.go64
-rw-r--r--state.go49
10 files changed, 1181 insertions, 11 deletions
diff --git a/README.gmi b/README.gmi
index 13175ca..4dfb32d 100644
--- a/README.gmi
+++ b/README.gmi
@@ -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
+}
diff --git a/go.mod b/go.mod
index 9a6458a..8afedc5 100644
--- a/go.mod
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..ecf2a9c
--- /dev/null
+++ b/go.sum
@@ -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 ""
+ }
+}
diff --git a/main.go b/main.go
index da29a2c..c1a833b 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
diff --git a/mark.go b/mark.go
new file mode 100644
index 0000000..63284c5
--- /dev/null
+++ b/mark.go
@@ -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,
+ },
+ }
+}