x-1/actions.go

458 lines
10 KiB
Go

package main
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"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")
ErrInvalidMarkArgs = errors.New("mark what?")
ErrInvalidTourArgs = errors.New("tour what?")
)
func About(state *BrowserState) error {
_, err := fmt.Println(`
...
*=:::.
---======- -+-.....
.#@@@@@@@= .+=-:....:++
+@@@@@@@@# :-::.. ...*+
:%@@@@@@@@@. =*+++==:....
.*@@@@@@@@@@= -#%###**++-:..
-@@@@@@@@@@@% .::-=*#@@#*#@@@@@@@%-.
..:::----=====++#@@@@@@@@@@@@%%%@@@@@@@@@@%%#**@@@@@@@=
.-+#%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-
=*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%@@*%@@@@@@@@@@- ..
-+*. .#@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@@@@%%%%###- =- .=.==----==--:...
...:::::=#@@@@#**+*##@@@#*%%%%%%%%##############******+==-:-=-:.-==-:.. ...
.:.:-==++++====::-----::---:-+%@@@@@@@@@@@@@#-...
. ...............=@@@@@@@@@@@@@@+....
-@@@@@@@@@@@@@#.
-@@@@@@@@@@@@%-
-@@%%##@@@@@@=
=%++*=-=%@@@*.
=--*%%%::#@#:
*@+=#-+*%@%-
.*%****###@+.
::..:::.::*@@@%%%%%#:
x-1 is a browser for small web protocols gemini, gopher, finger, spartan, and nex.
It also has very limited support for HTTP[S].
It was written by TJP and released to the public domain.
`[1:])
return err
}
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
}
return print(state)
}
func Forward(state *BrowserState) error {
if state.Forward == nil {
return ErrNoNextHistory
}
state.History = state.Forward
state.Modal = nil
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)
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)
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
}
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]
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
}
return Navigate(state, u, idx, conf)
}
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
}
if strings.HasPrefix(str, "m:") {
target, err := findMark(state, str[2:])
if err != nil {
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
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)
}
if u.Hostname() == "" {
return nil, -1, ErrInvalidLink
}
return u, i, nil
}
func print(state *BrowserState) error {
if state.Quiet {
return nil
}
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 Print(state *BrowserState) error {
q := state.Quiet
defer func() { state.Quiet = q }()
state.Quiet = false
return print(state)
}
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 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 "select":
return TourSelect(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)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}