Fixes merge conflicts and removes dead files

This commit is contained in:
sloumdrone 2019-09-23 09:02:48 -07:00
commit e68dc3b414
33 changed files with 2230 additions and 1467 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
bombadillo
*.asciinema

53
LICENSE
View File

@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

201
bombadillo.1 Normal file
View File

@ -0,0 +1,201 @@
." Text automatically generated by txt2man
.TH "bombadillo" 1 "21 SEP 2019" "" "General Opperation Manual"
.SH NAME
\fBbombadillo \fP- a non-web client
.SH SYNOPSIS
.nf
.fam C
\fBbombadillo\fP [\fB-v\fP] [\fB-h\fP] [\fIurl\fP]
.fam T
.fi
.SH DESCRIPTION
\fBbombadillo\fP is a terminal based client for a number of internet protocols, including gopher and gemini. \fBbombadillo\fP will also connect links to a user's default web browser or telnet client. Commands input is loosely based on Vi and Less and is comprised of two modes: key and line input mode.
.SH OPTIONS
.TP
.B
\fB-v\fP
Display the version number of \fBbombadillo\fP.
.TP
.B
\fB-h\fP
Usage help. Displays all command line options with a short description.
.SH COMMANDS
.SS KEY COMMANDS
These commands work as a single keypress anytime \fBbombadillo\fP is not taking in a line based command. This is the default command mode of \fBbombadillo\fP.
.TP
.B
b
Navigate back one place in your document history.
.TP
.B
B
Toggle the bookmarks panel open/closed.
.TP
.B
d
Scroll down an amount corresponding to 75% of your terminal window height in the current document.
.TP
.B
f
Navigate forward one place in your document history.
.TP
.B
g
Scroll to the top of the current document.
.TP
.B
G
Scroll to the bottom of the current document.
.TP
.B
j
Scroll down a single line in the current document.
.TP
.B
k
Scroll up a single line.
.TP
.B
q
Quit \fBbombadillo\fP.
.TP
.B
u
Scroll up an amount corresponding to 75% of your terminal window height in the current document.
.TP
.B
<tab>
Toggle the scroll focus between the bookmarks panel and the document panel. Only has an effect if the bookmarks panel is open.
.TP
.B
<spc>
Enter line command mode. Once a line command is input, the mode will automatically revert to key command mode.
.TP
.B
:
Alias for <space>. Enter line command mode.
.SS LINE COMMANDS
These commands are typed in by the user to perform an action of some sort. As listed in KEY COMMANDS, this mode is initiated by pressing : or <space>. The command names themselves are not case sensitive, though the arguments supplied to them may be.
.SS NAVIGATION
.TP
.B
[url]
Navigates to the requested url.
.TP
.B
[link id]
Follows a link on the current document with the given number.
.TP
.B
bookmarks [bookmark id]
Navigates to the url represented by the bookmark matching bookmark id. \fIb\fP can be entered, rather than the full \fIbookmarks\fP.
.TP
.B
home
Navigates to the document set by the \fIhomeurl\fP setting. \fIh\fP can be entered, rather than the full \fIhome\fP.
.TP
.B
search [keywords\.\.\.]
Submits a search to the search engine set by the \fIsearchengine\fP setting, with the query being the provided keyword(s).
.TP
.B
search
Queries the user for search terms and submits a search to the search engine set by the \fIsearchengine\fP setting.
.TP
.B
write [url]
Writes data from a given url to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [url] [filename\.\.\.]
Writes data from a given url to a file. The file is named by the filename argument should should not include a leading \fI/\fP. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [link id]]
Writes data from a given link id in the current document to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [link id] [filename\.\.\.]
Writes data from a given link id in the current document to a file. The file is named by the filename argument should should not include a leading \fI/\fP. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write .
Writes the current document to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write . [filename\.\.\.]
Writes the current document to a file. The file is named by the filename argument should should not include a leading \fI/\fP. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
help
Navigates to the gopher based help page for \fBbombadillo\fP. \fI?\fP can be used instead of the full \fIhelp\fP.
.SS BOOKMARKS
.TP
.B
bookmarks
Toggles the bookmarks panel open/closed. Alias for KEY COMMAND \fIB\fP. \fIb\fP can be used instead of the full \fIbookmarks\fP.
.TP
.B
add [url] [name\.\.\.]
Adds the url as a bookmarks labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
add [link id] [name\.\.\.]
Adds the url represented by the link id within the current document as a bookmark labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
add [.] [name\.\.\.]
Adds the current document's url as a bookmark labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
delete [bookmark id]]
Deletes the bookmark matching the bookmark id. \fId\fP can be used instead of the full \fIdelete\fP.
.SS MISC
.TP
.B
check [link id]
Displays the url corresponding to a given link id for the current document. \fIc\fP can be used instead of the full \fIcheck\fP.
.TP
.B
check [setting name]
Displays the current value for a given configuration setting. \fIc\fP can be used instead of the full \fIcheck\fP.
.TP
.B
set [setting name]
Sets the value for a given configuration setting. \fIs\fP can be used instead of the full \fIset\fP.
.TP
.B
quit
Quits \fBbombadillo\fP. Alias for KEY COMMAND \fIq\fP. \fIq\fP can be used instead of the full \fIquit\fP.
.SH FILES
\fBbombadillo\fP keeps a hidden configuration file in a user's home directory. The file is a simplified ini file titled '.bombadillo.ini'. It is generated when a user first loads \fBbombadillo\fP and is updated with bookmarks and settings as a user adds them. The file can be directly edited, but it is best to use the SET command to update settings whenever possible. To return to the state of a fresh install, simply remove the file and a new one will be generated with the \fBbombadillo\fP defaults.
.SH SETTINGS
The following is a list of the settings that \fBbombadillo\fP recognizes, as well as a description of their valid values.
.TP
.B
homeurl
The url that \fBbombadillo\fP navigates to when the program loads or when the \fIhome\fP or \fIh\fP LINE COMMAND is issued. This should be a valid url. If a scheme/protocol is not included, gopher will be assumed.
.TP
.B
savelocation
The path to the folder that \fBbombadillo\fP should write files to. This should be a valid filepath for the system and should end in a \fI/\fP. Defaults to a user's home directory.
.TP
.B
searchengine
The url to use for the LINE COMMANDs \fI?\fP and \fIsearch\fP. Should be a valid search path that terms may be appended to. Defaults to \fIgopher://gopher.floodgap.com:70/7/v2/vs\fP.
.TP
.B
openhttp
Tells the client whether or not to try to follow web (http/https) links. If set to \fItrue\fP, \fBbombadillo\fP will try to open a user's default web browser to the link in question. Any value other than \fItrue\fP is considered false. Defaults to \fIfalse\fP.
.TP
.B
telnetcommand
Tells the client what command to use to start a telnet session. Should be a valid command, including any flags. The address being navigated to will be added to the end of the command. Defaults to \fItelnet\fP.
.TP
.B
theme
Can toggle between visual modes. Valid values are \fInormal\fP and \fIinverse\fP. When set to ivnerse, the terminal color mode is inversed. Defaults to \fInormal\fP.
.TP
.B
terminalonly
Sets whether or not to try to open non-text files served via gemini in gui programs or not. If set to \fItrue\fP, bombdaillo will only attempt to use terminal programs to open files. If set to anything else, \fBbombadillo\fP may choose graphical and terminal programs. Defaults to \fItrue\fP.

144
bookmarks.go Normal file
View File

@ -0,0 +1,144 @@
package main
import (
"fmt"
"strings"
"tildegit.org/sloum/bombadillo/cui"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type Bookmarks struct {
IsOpen bool
IsFocused bool
Position int
Length int
Titles []string
Links []string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (b *Bookmarks) Add(v []string) (string, error) {
if len(v) < 2 {
return "", fmt.Errorf("Received %d arguments, expected 2+", len(v))
}
b.Titles = append(b.Titles, strings.Join(v[1:], " "))
b.Links = append(b.Links, v[0])
b.Length = len(b.Titles)
return "Bookmark added successfully", nil
}
func (b *Bookmarks) Delete(i int) (string, error) {
if i < len(b.Titles) && len(b.Titles) == len(b.Links) {
b.Titles = append(b.Titles[:i], b.Titles[i+1:]...)
b.Links = append(b.Links[:i], b.Links[i+1:]...)
b.Length = len(b.Titles)
return "Bookmark deleted successfully", nil
}
return "", fmt.Errorf("Bookmark %d does not exist", i)
}
func (b *Bookmarks) ToggleOpen() {
b.IsOpen = !b.IsOpen
if b.IsOpen {
b.IsFocused = true
} else {
b.IsFocused = false
cui.Clear("screen")
}
}
func (b *Bookmarks) ToggleFocused() {
if b.IsOpen {
b.IsFocused = !b.IsFocused
}
}
func (b Bookmarks) IniDump() string {
if len(b.Titles) < 0 {
return ""
}
out := "[BOOKMARKS]\n"
for i := 0; i < len(b.Titles); i++ {
out += b.Titles[i]
out += "="
out += b.Links[i]
out += "\n"
}
return out
}
// Get a list, including link nums, of bookmarks
// as a string slice
func (b Bookmarks) List() []string {
var out []string
for i, t := range b.Titles {
out = append(out, fmt.Sprintf("[%d] %s", i, t))
}
return out
}
func (b Bookmarks) Render(termwidth, termheight int) []string {
width := 40
termheight -= 3
var walll, wallr, floor, ceil, tr, tl, br, bl string
if termwidth < 40 {
width = termwidth
}
if b.IsFocused {
walll = cui.Shapes["awalll"]
wallr = cui.Shapes["awallr"]
ceil = cui.Shapes["aceiling"]
floor = cui.Shapes["afloor"]
tr = cui.Shapes["atr"]
br = cui.Shapes["abr"]
tl = cui.Shapes["atl"]
bl = cui.Shapes["abl"]
} else {
walll = cui.Shapes["walll"]
wallr = cui.Shapes["wallr"]
ceil = cui.Shapes["ceiling"]
floor = cui.Shapes["floor"]
tr = cui.Shapes["tr"]
br = cui.Shapes["br"]
tl = cui.Shapes["tl"]
bl = cui.Shapes["bl"]
}
out := make([]string, 0, 5)
contentWidth := width - 2
top := fmt.Sprintf("%s%s%s", tl, strings.Repeat(ceil, contentWidth), tr)
out = append(out, top)
marks := b.List()
for i := 0; i < termheight - 2; i++ {
if i + b.Position >= len(b.Titles) {
out = append(out, fmt.Sprintf("%s%-*.*s%s", walll, contentWidth, contentWidth, "", wallr))
} else {
out = append(out, fmt.Sprintf("%s%-*.*s%s", walll, contentWidth, contentWidth, marks[i + b.Position], wallr))
}
}
bottom := fmt.Sprintf("%s%s%s", bl, strings.Repeat(floor, contentWidth), br)
out = append(out, bottom)
return out
}
// TODO handle scrolling of the bookmarks list
// either here with a scroll up/down or in the client
// code for scroll
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakeBookmarks() Bookmarks {
return Bookmarks{false, false, 0, 0, make([]string, 0), make([]string, 0)}
}

961
client.go Normal file
View File

@ -0,0 +1,961 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"tildegit.org/sloum/bombadillo/cmdparse"
"tildegit.org/sloum/bombadillo/cui"
"tildegit.org/sloum/bombadillo/gemini"
"tildegit.org/sloum/bombadillo/gopher"
"tildegit.org/sloum/bombadillo/http"
"tildegit.org/sloum/bombadillo/telnet"
// "tildegit.org/sloum/mailcap"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type client struct {
Height int
Width int
Options map[string]string
Message string
MessageIsErr bool
PageState Pages
BookMarks Bookmarks
TopBar Headbar
FootBar Footbar
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (c *client) GetSizeOnce() {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
fmt.Println("Fatal error: Unable to retrieve terminal size")
os.Exit(5)
}
var h, w int
fmt.Sscan(string(out), &h, &w)
c.Height = h
c.Width = w
}
func (c *client) GetSize() {
c.GetSizeOnce()
c.SetMessage("Loading...", false)
c.Draw()
for {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
fmt.Println("Fatal error: Unable to retrieve terminal size")
os.Exit(5)
}
var h, w int
fmt.Sscan(string(out), &h, &w)
if h != c.Height || w != c.Width {
c.Height = h
c.Width = w
c.SetPercentRead()
c.Draw()
}
time.Sleep(500 * time.Millisecond)
}
}
func (c *client) Draw() {
var screen strings.Builder
screen.Grow(c.Height * c.Width + c.Width)
screen.WriteString("\033[0m")
screen.WriteString(c.TopBar.Render(c.Width, c.Options["theme"]))
screen.WriteString("\n")
pageContent := c.PageState.Render(c.Height, c.Width - 1)
if c.Options["theme"] == "inverse" {
screen.WriteString("\033[7m")
}
if c.BookMarks.IsOpen {
bm := c.BookMarks.Render(c.Width, c.Height)
bmWidth := len([]rune(bm[0]))
for i := 0; i < c.Height - 3; i++ {
if c.Width > bmWidth {
contentWidth := c.Width - bmWidth
if i < len(pageContent) {
screen.WriteString(fmt.Sprintf("%-*.*s", contentWidth, contentWidth, pageContent[i]))
} else {
screen.WriteString(fmt.Sprintf("%-*.*s", contentWidth, contentWidth, " "))
}
screen.WriteString("\033[500C\033[39D")
}
if c.Options["theme"] == "inverse" && !c.BookMarks.IsFocused {
screen.WriteString("\033[2;7m")
} else if !c.BookMarks.IsFocused {
screen.WriteString("\033[2m")
}
screen.WriteString(bm[i])
if c.Options["theme"] == "inverse" && !c.BookMarks.IsFocused {
screen.WriteString("\033[7;22m")
} else if !c.BookMarks.IsFocused {
screen.WriteString("\033[0m")
}
screen.WriteString("\n")
}
} else {
for i := 0; i < c.Height - 3; i++ {
if i < len(pageContent) {
screen.WriteString(fmt.Sprintf("%-*.*s", c.Width - 1, c.Width - 1, pageContent[i]))
screen.WriteString("\n")
} else {
screen.WriteString(fmt.Sprintf("%-*.*s", c.Width, c.Width, " "))
screen.WriteString("\n")
}
}
}
screen.WriteString("\033[0m")
// TODO using message here breaks on resize, must regenerate
screen.WriteString(c.RenderMessage())
screen.WriteString("\n") // for the input line
screen.WriteString(c.FootBar.Render(c.Width, c.PageState.Position, c.Options["theme"]))
// cui.Clear("screen")
cui.MoveCursorTo(0,0)
fmt.Print(screen.String())
}
func (c *client) TakeControlInput() {
input := cui.Getch()
switch input {
case 'j', 'J':
// scroll down one line
c.ClearMessage()
c.Scroll(1)
case 'k', 'K':
// scroll up one line
c.ClearMessage()
c.Scroll(-1)
case 'q', 'Q':
// quite bombadillo
cui.Exit()
case 'g':
// scroll to top
c.ClearMessage()
c.Scroll(-len(c.PageState.History[c.PageState.Position].WrappedContent))
case 'G':
// scroll to bottom
c.ClearMessage()
c.Scroll(len(c.PageState.History[c.PageState.Position].WrappedContent))
case 'd':
// scroll down 75%
c.ClearMessage()
distance := c.Height - c.Height / 4
c.Scroll(distance)
case 'u':
// scroll up 75%
c.ClearMessage()
distance := c.Height - c.Height / 4
c.Scroll(-distance)
case 'b':
// go back
c.ClearMessage()
err := c.PageState.NavigateHistory(-1)
if err != nil {
c.SetMessage(err.Error(), false)
c.DrawMessage()
} else {
c.SetHeaderUrl()
c.SetPercentRead()
c.Draw()
}
case 'B':
// open the bookmarks browser
c.BookMarks.ToggleOpen()
c.Draw()
case 'f', 'F':
// go forward
c.ClearMessage()
err := c.PageState.NavigateHistory(1)
if err != nil {
c.SetMessage(err.Error(), false)
c.DrawMessage()
} else {
c.SetHeaderUrl()
c.SetPercentRead()
c.Draw()
}
case '\t':
// Toggle bookmark browser focus on/off
c.BookMarks.ToggleFocused()
c.Draw()
case ':', ' ':
// Process a command
c.ClearMessage()
c.ClearMessageLine()
if c.Options["theme"] == "normal" {
fmt.Printf("\033[7m%*.*s\r", c.Width, c.Width, "")
}
entry, err := cui.GetLine()
c.ClearMessageLine()
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
break
} else if strings.TrimSpace(entry) == "" {
c.DrawMessage()
break
}
parser := cmdparse.NewParser(strings.NewReader(entry))
p, err := parser.Parse()
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
} else {
err := c.routeCommandInput(p)
if err != nil {
c.SetMessage(err.Error(), true)
c.Draw()
}
}
}
}
func (c *client) routeCommandInput(com *cmdparse.Command) error {
var err error
switch com.Type {
case cmdparse.SIMPLE:
c.simpleCommand(com.Action)
case cmdparse.GOURL:
c.goToURL(com.Target)
case cmdparse.GOLINK:
c.goToLink(com.Target)
case cmdparse.DO:
c.doCommand(com.Action, com.Value)
case cmdparse.DOLINK:
c.doLinkCommand(com.Action, com.Target)
case cmdparse.DOAS:
c.doCommandAs(com.Action, com.Value)
case cmdparse.DOLINKAS:
c.doLinkCommandAs(com.Action, com.Target, com.Value)
default:
return fmt.Errorf("Unknown command entry!")
}
return err
}
func (c *client) simpleCommand(action string) {
action = strings.ToUpper(action)
switch action {
case "Q", "QUIT":
cui.Exit()
case "H", "HOME":
if c.Options["homeurl"] != "unset" {
go c.Visit(c.Options["homeurl"])
} else {
c.SetMessage(fmt.Sprintf("No home address has been set"), false)
c.DrawMessage()
}
case "B", "BOOKMARKS":
c.BookMarks.ToggleOpen()
c.Draw()
case "SEARCH":
c.search("", "", "?")
case "HELP", "?":
go c.Visit(helplocation)
default:
c.SetMessage(fmt.Sprintf("Unknown action %q", action), true)
c.DrawMessage()
}
}
func (c *client) doCommand(action string, values []string) {
if length := len(values); length != 1 {
c.SetMessage(fmt.Sprintf("Expected 1 argument, received %d", len(values)), true)
c.DrawMessage()
return
}
switch action {
case "CHECK", "C":
c.displayConfigValue(values[0])
case "SEARCH":
c.search(strings.Join(values, " "), "", "")
case "WRITE", "W":
if values[0] == "." {
values[0] = c.PageState.History[c.PageState.Position].Location.Full
}
u, err := MakeUrl(values[0])
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
fns := strings.Split(u.Resource, "/")
var fn string
if len(fns) > 0 {
fn = strings.Trim(fns[len(fns) - 1], "\t\r\n \a\f\v")
} else {
fn = "index"
}
if fn == "" {
fn = "index"
}
c.saveFile(u, fn)
default:
c.SetMessage(fmt.Sprintf("Unknown action %q", action), true)
c.DrawMessage()
}
}
func (c *client) doCommandAs(action string, values []string) {
if len(values) < 2 {
c.SetMessage(fmt.Sprintf("Expected 1 argument, received %d", len(values)), true)
c.DrawMessage()
return
}
if values[0] == "." {
values[0] = c.PageState.History[c.PageState.Position].Location.Full
}
switch action {
case "ADD", "A":
msg, err := c.BookMarks.Add(values)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else {
c.SetMessage(msg, false)
c.DrawMessage()
}
err = saveConfig()
if err != nil {
c.SetMessage("Error saving bookmark to file", true)
c.DrawMessage()
}
if c.BookMarks.IsOpen {
c.Draw()
}
case "WRITE", "W":
u, err := MakeUrl(values[0])
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
fileName := strings.Join(values[1:], "-")
fileName = strings.Trim(fileName, " \t\r\n\a\f\v")
c.saveFile(u, fileName)
case "SET", "S":
if _, ok := c.Options[values[0]]; ok {
val := strings.Join(values[1:], " ")
if values[0] == "theme" && val != "normal" && val != "inverse" {
c.SetMessage("Theme can only be set to 'normal' or 'inverse'", true)
c.DrawMessage()
return
}
c.Options[values[0]] = val
err := saveConfig()
if err != nil {
c.SetMessage("Value set, but error saving config to file", true)
c.DrawMessage()
} else {
c.SetMessage(fmt.Sprintf("%s is now set to %q", values[0], c.Options[values[0]]), false)
c.Draw()
}
return
}
c.SetMessage(fmt.Sprintf("Unable to set %s, it does not exist", values[0]), true)
c.DrawMessage()
return
}
c.SetMessage(fmt.Sprintf("Unknown command structure"), true)
}
func (c *client) doLinkCommandAs(action, target string, values []string) {
num, err := strconv.Atoi(target)
if err != nil {
c.SetMessage(fmt.Sprintf("Expected link number, got %q", target), true)
c.DrawMessage()
return
}
num -= 1
links := c.PageState.History[c.PageState.Position].Links
if num >= len(links) || num < 0 {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", target), true)
c.DrawMessage()
return
}
switch action {
case "ADD", "A":
bm := make([]string, 0, 5)
bm = append(bm, links[num])
bm = append(bm, values...)
msg, err := c.BookMarks.Add(bm)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else {
c.SetMessage(msg, false)
c.DrawMessage()
}
err = saveConfig()
if err != nil {
c.SetMessage("Error saving bookmark to file", true)
c.DrawMessage()
}
if c.BookMarks.IsOpen {
c.Draw()
}
case "WRITE", "W":
out := make([]string, 0, len(values) + 1)
out = append(out, links[num])
out = append(out, values...)
c.doCommandAs(action, out)
default:
c.SetMessage(fmt.Sprintf("Unknown command structure"), true)
}
}
func (c *client) getCurrentPageUrl() (string, error) {
if c.PageState.Length < 1 {
return "", fmt.Errorf("There are no pages in history")
}
return c.PageState.History[c.PageState.Position].Location.Full, nil
}
func (c *client) getCurrentPageRawData() (string, error) {
if c.PageState.Length < 1 {
return "", fmt.Errorf("There are no pages in history")
}
return c.PageState.History[c.PageState.Position].RawContent, nil
}
func (c *client) saveFile(u Url, name string) {
var file []byte
var err error
c.SetMessage(fmt.Sprintf("Saving %s ...", name), false)
c.DrawMessage()
switch u.Scheme {
case "gopher":
file, err = gopher.Retrieve(u.Host, u.Port, u.Resource)
case "gemini":
file, err = gemini.Fetch(u.Host, u.Port, u.Resource)
default:
c.SetMessage(fmt.Sprintf("Saving files over %s is not supported", u.Scheme), true)
c.DrawMessage()
return
}
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
savePath := c.Options["savelocation"] + name
err = ioutil.WriteFile(savePath, file, 0644)
if err != nil {
c.SetMessage("Error writing file to disk", true)
c.DrawMessage()
return
}
c.SetMessage(fmt.Sprintf("File saved to: %s", savePath), false)
c.DrawMessage()
}
func (c *client) saveFileFromData(d, name string) {
data := []byte(d)
c.SetMessage(fmt.Sprintf("Saving %s ...", name), false)
c.DrawMessage()
savePath := c.Options["savelocation"] + name
err := ioutil.WriteFile(savePath, data, 0644)
if err != nil {
c.SetMessage("Error writing file to disk", true)
c.DrawMessage()
return
}
c.SetMessage(fmt.Sprintf("File saved to: %s", savePath), false)
c.DrawMessage()
}
func (c *client) doLinkCommand(action, target string) {
num, err := strconv.Atoi(target)
if err != nil {
c.SetMessage(fmt.Sprintf("Expected number, got %q", target), true)
c.DrawMessage()
}
switch action {
case "DELETE", "D":
msg, err := c.BookMarks.Delete(num)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else {
c.SetMessage(msg, false)
c.DrawMessage()
}
err = saveConfig()
if err != nil {
c.SetMessage("Error saving bookmark deletion to file", true)
c.DrawMessage()
}
if c.BookMarks.IsOpen {
c.Draw()
}
case "BOOKMARKS", "B":
if num > len(c.BookMarks.Links)-1 {
c.SetMessage(fmt.Sprintf("There is no bookmark with ID %d", num), true)
c.DrawMessage()
return
}
c.Visit(c.BookMarks.Links[num])
case "CHECK", "C":
num -= 1
links := c.PageState.History[c.PageState.Position].Links
if num >= len(links) || num < 0 {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", target), true)
c.DrawMessage()
return
}
link := links[num]
c.SetMessage(fmt.Sprintf("[%d] %s", num + 1, link), false)
c.DrawMessage()
case "WRITE", "W":
links := c.PageState.History[c.PageState.Position].Links
if len(links) < num || num < 1 {
c.SetMessage("Invalid link ID", true)
c.DrawMessage()
return
}
u, err := MakeUrl(links[num-1])
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
fns := strings.Split(u.Resource, "/")
var fn string
if len(fns) > 0 {
fn = strings.Trim(fns[len(fns) - 1], "\t\r\n \a\f\v")
} else {
fn = "index"
}
if fn == "" {
fn = "index"
}
c.saveFile(u, fn)
default:
c.SetMessage(fmt.Sprintf("Action %q does not exist for target %q", action, target), true)
c.DrawMessage()
}
}
func (c *client) search(query, url, question string) {
var entry string
var err error
if query == "" {
c.ClearMessage()
c.ClearMessageLine()
if c.Options["theme"] == "normal" {
fmt.Printf("\033[7m%*.*s\r", c.Width, c.Width, "")
}
fmt.Print(question)
entry, err = cui.GetLine()
c.ClearMessageLine()
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else if strings.TrimSpace(entry) == "" {
return
}
} else {
entry = query
}
if url == "" {
url = c.Options["searchengine"]
}
u, err := MakeUrl(url)
if err != nil {
c.SetMessage("The search url is not a valid url", true)
c.DrawMessage()
return
}
switch u.Scheme {
case "gopher":
go c.Visit(fmt.Sprintf("%s\t%s",u.Full,entry))
case "gemini":
// TODO url escape the entry variable
escapedEntry := entry
go c.Visit(fmt.Sprintf("%s?%s",u.Full,escapedEntry))
case "http", "https":
c.Visit(u.Full)
default:
c.SetMessage(fmt.Sprintf("%q is not a supported protocol", u.Scheme), true)
c.DrawMessage()
}
}
func (c *client) Scroll(amount int) {
if c.BookMarks.IsFocused {
bottom := len(c.BookMarks.Titles) - c.Height + 5 // 3 for the three bars: top, msg, bottom
if amount < 0 && c.BookMarks.Position == 0 {
c.SetMessage("The bookmark ladder does not go up any further", false)
c.DrawMessage()
fmt.Print("\a")
return
} else if (amount > 0 && c.BookMarks.Position == bottom) || bottom < 0 {
c.SetMessage("Feel the ground beneath your bookmarks", false)
c.DrawMessage()
fmt.Print("\a")
return
}
newScrollPosition := c.BookMarks.Position + amount
if newScrollPosition < 0 {
newScrollPosition = 0
} else if newScrollPosition > bottom {
newScrollPosition = bottom
}
c.BookMarks.Position = newScrollPosition
} else {
var percentRead int
page := c.PageState.History[c.PageState.Position]
bottom := len(page.WrappedContent) - c.Height + 3 // 3 for the three bars: top, msg, bottom
if amount < 0 && page.ScrollPosition == 0 {
c.SetMessage("You are already at the top", false)
c.DrawMessage()
fmt.Print("\a")
return
} else if (amount > 0 && page.ScrollPosition == bottom) || bottom < 0 {
c.FootBar.SetPercentRead(100)
c.SetMessage("You are already at the bottom", false)
c.DrawMessage()
fmt.Print("\a")
return
}
newScrollPosition := page.ScrollPosition + amount
if newScrollPosition < 0 {
newScrollPosition = 0
} else if newScrollPosition > bottom {
newScrollPosition = bottom
}
c.PageState.History[c.PageState.Position].ScrollPosition = newScrollPosition
if len(page.WrappedContent) < c.Height - 3 {
percentRead = 100
} else {
percentRead = int(float32(newScrollPosition + c.Height - 3) / float32(len(page.WrappedContent)) * 100.0)
}
c.FootBar.SetPercentRead(percentRead)
}
c.Draw()
}
func (c *client) SetPercentRead() {
page := c.PageState.History[c.PageState.Position]
var percentRead int
if len(page.WrappedContent) < c.Height - 3 {
percentRead = 100
} else {
percentRead = int(float32(page.ScrollPosition + c.Height - 3) / float32(len(page.WrappedContent)) * 100.0)
}
c.FootBar.SetPercentRead(percentRead)
}
func (c *client) displayConfigValue(setting string) {
if val, ok := c.Options[setting]; ok {
c.SetMessage(fmt.Sprintf("%s is set to: %q", setting, val), false)
c.DrawMessage()
} else {
c.SetMessage(fmt.Sprintf("Invalid: %q does not exist", setting), true)
c.DrawMessage()
}
}
func (c *client) SetMessage(msg string, isError bool) {
c.MessageIsErr = isError
c.Message = strings.ReplaceAll(msg, "\t", "%09")
}
func (c *client) DrawMessage() {
cui.MoveCursorTo(c.Height-1, 0)
fmt.Print(c.RenderMessage())
}
func (c *client) RenderMessage() string {
leadIn, leadOut := "", ""
if c.Options["theme"] == "normal" {
leadIn = "\033[7m"
leadOut = "\033[0m"
}
if c.MessageIsErr {
leadIn = "\033[31;1m"
leadOut = "\033[0m"
if c.Options["theme"] == "normal" {
leadIn = "\033[41;1;7m"
}
}
return fmt.Sprintf("%s%-*.*s%s", leadIn, c.Width, c.Width, c.Message, leadOut)
}
func (c *client) ClearMessage() {
c.SetMessage("", false)
}
func (c *client) ClearMessageLine() {
cui.MoveCursorTo(c.Height-1, 0)
cui.Clear("line")
}
func (c *client) goToURL(u string) {
if num, _ := regexp.MatchString(`^-?\d+.?\d*$`, u); num {
c.goToLink(u)
return
}
go c.Visit(u)
}
func (c *client) goToLink(l string) {
if num, _ := regexp.MatchString(`^-?\d+$`, l); num && c.PageState.Length > 0 {
linkcount := len(c.PageState.History[c.PageState.Position].Links)
item, err := strconv.Atoi(l)
if err != nil {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", l), true)
c.DrawMessage()
return
}
if item <= linkcount && item > 0 {
linkurl := c.PageState.History[c.PageState.Position].Links[item-1]
c.Visit(linkurl)
} else {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", l), true)
c.DrawMessage()
return
}
}
}
func (c *client) SetHeaderUrl() {
if c.PageState.Length > 0 {
u := c.PageState.History[c.PageState.Position].Location.Full
c.TopBar.url = strings.ReplaceAll(u, "\t", "%09")
} else {
c.TopBar.url = ""
}
}
func (c *client) Visit(url string) {
c.SetMessage("Loading...", false)
c.DrawMessage()
url = strings.ReplaceAll(url, "%09", "\t")
u, err := MakeUrl(url)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
switch u.Scheme {
case "gopher":
if u.DownloadOnly {
nameSplit := strings.Split(u.Resource, "/")
filename := nameSplit[len(nameSplit) - 1]
filename = strings.Trim(filename, " \t\r\n\v\f\a")
if filename == "" {
filename = "gopherfile"
}
c.saveFile(u, filename)
} else if u.Mime == "7" {
c.search("", u.Full, "?")
} else {
content, links, err := gopher.Visit(u.Mime, u.Host, u.Port, u.Resource)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
pg := MakePage(u, content, links)
pg.WrapContent(c.Width - 1)
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
c.SetHeaderUrl()
c.Draw()
}
case "gemini":
capsule, err := gemini.Visit(u.Host, u.Port, u.Resource)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
switch capsule.Status {
case 1:
c.search("", u.Full, capsule.Content)
case 2:
if capsule.MimeMaj == "text" {
pg := MakePage(u, capsule.Content, capsule.Links)
pg.WrapContent(c.Width - 1)
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
c.SetHeaderUrl()
c.Draw()
} else {
c.SetMessage("The file is non-text: (o)pen or (w)rite to disk", false)
c.DrawMessage()
var ch rune
for {
ch = cui.Getch()
if ch == 'o' || ch == 'w' {
break
}
}
switch ch {
case 'o':
mime := fmt.Sprintf("%s/%s", capsule.MimeMaj, capsule.MimeMin)
var term bool
if c.Options["terminalonly"] == "true" {
term = true
} else {
term = false
}
mcEntry, err := mc.FindMatch(mime, "view", term)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
file, err := ioutil.TempFile("/tmp/", "bombadillo-*.tmp")
if err != nil {
c.SetMessage("Unable to create temporary file for opening, aborting file open", true)
c.DrawMessage()
return
}
// defer os.Remove(file.Name())
file.Write([]byte(capsule.Content))
com, e := mcEntry.Command(file.Name())
if e != nil {
c.SetMessage(e.Error(), true)
c.DrawMessage()
return
}
com.Stdin = os.Stdin
com.Stdout = os.Stdout
com.Stderr = os.Stderr
if c.Options["terminalonly"] == "true" {
cui.Clear("screen")
}
com.Run()
c.SetMessage("File opened by an appropriate program", true)
c.DrawMessage()
c.Draw()
case 'w':
nameSplit := strings.Split(u.Resource, "/")
filename := nameSplit[len(nameSplit) - 1]
c.saveFileFromData(capsule.Content, filename)
}
}
case 3:
c.SetMessage("[3] Redirect. Follow redirect? y or any other key for no", false)
c.DrawMessage()
ch := cui.Getch()
if ch == 'y' || ch == 'Y' {
c.Visit(capsule.Content)
} else {
c.SetMessage("Redirect aborted", false)
c.DrawMessage()
}
}
case "telnet":
c.SetMessage("Attempting to start telnet session", false)
c.DrawMessage()
msg, err := telnet.StartSession(u.Host, u.Port)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
} else {
c.SetMessage(msg, true)
c.DrawMessage()
}
c.Draw()
case "http", "https":
c.SetMessage("Attempting to open in web browser", false)
c.DrawMessage()
if strings.ToUpper(c.Options["openhttp"]) == "TRUE" {
msg, err := http.OpenInBrowser(u.Full)
if err != nil {
c.SetMessage(err.Error(), true)
} else {
c.SetMessage(msg, false)
}
c.DrawMessage()
} else {
c.SetMessage("'openhttp' is not set to true, cannot open web link", false)
c.DrawMessage()
}
default:
c.SetMessage(fmt.Sprintf("%q is not a supported protocol", u.Scheme), true)
c.DrawMessage()
}
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakeClient(name string) *client {
c := client{0, 0, defaultOptions, "", false, MakePages(), MakeBookmarks(), MakeHeadbar(name), MakeFootbar()}
return &c
}

View File

@ -4,7 +4,6 @@ import (
"fmt"
"io"
"strings"
"tildegit.org/sloum/bombadillo/gopher"
)
//------------------------------------------------\\
@ -21,7 +20,10 @@ type Parser struct {
}
type Config struct {
Bookmarks gopher.Bookmarks
// Bookmarks gopher.Bookmarks
Bookmarks struct {
Titles, Links []string
}
Colors []KeyValue
Settings []KeyValue
}
@ -86,10 +88,8 @@ func (p *Parser) Parse() (Config, error) {
}
switch section {
case "BOOKMARKS":
err := c.Bookmarks.Add([]string{keyval.Value, keyval.Key})
if err != nil {
return c, err
}
c.Bookmarks.Titles = append(c.Bookmarks.Titles, keyval.Value)
c.Bookmarks.Links = append(c.Bookmarks.Links, keyval.Key)
case "COLORS":
c.Colors = append(c.Colors, keyval)
case "SETTINGS":

View File

@ -2,30 +2,32 @@ package cui
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
var shapes = map[string]string{
"wall": "╵",
"ceiling": "╴",
"tl": "┌",
"tr": "┐",
"bl": "└",
"br": "┘",
"awall": "║",
"aceiling": "═",
"atl": "╔",
"atr": "╗",
"abl": "╚",
"abr": "╝",
var Shapes = map[string]string{
"walll": "╎",
"wallr": " ",
"ceiling": " ",
"floor": " ",
"tl": "╎",
"tr": " ",
"bl": "╎",
"br": " ",
"awalll": "▌",
"awallr": "▐",
"aceiling": "▀",
"afloor": "▄",
"atl": "▞",
"atr": "▜",
"abl": "▚",
"abr": "▟",
}
func drawShape(shape string) {
if val, ok := shapes[shape]; ok {
if val, ok := Shapes[shape]; ok {
fmt.Printf("%s", val)
} else {
fmt.Print("x")
@ -61,7 +63,8 @@ func Exit() {
fmt.Print("\n")
fmt.Print("\033[?25h")
HandleAlternateScreen("rmcup")
Tput("smam") // turn off line wrap
Tput("rmcup") // use alternate screen
os.Exit(0)
}
@ -81,41 +84,6 @@ func Clear(dir string) {
}
// takes the document content (as a slice) and modifies any lines that are longer
// than the specified console width, splitting them over two lines. returns the
// amended document content as a slice.
// word wrapping uses a "greedy" algorithm
func wrapLines(s []string, consolewidth int) []string {
out := []string{}
for _, ln := range s {
if len(ln) <= consolewidth {
out = append(out, ln)
} else {
words := strings.SplitAfter(ln, " ")
var subout bytes.Buffer
for i, wd := range words {
sublen := subout.Len()
wdlen := len(wd)
if sublen+wdlen <= consolewidth {
subout.WriteString(wd)
if i == len(words)-1 {
out = append(out, subout.String())
}
} else {
out = append(out, subout.String())
subout.Reset()
subout.WriteString(wd)
if i == len(words)-1 {
out = append(out, subout.String())
subout.Reset()
}
}
}
}
}
return out
}
func Getch() rune {
reader := bufio.NewReader(os.Stdin)
char, _, err := reader.ReadRune()
@ -161,7 +129,7 @@ func SetLineMode() {
}
}
func HandleAlternateScreen(opt string) {
func Tput(opt string) {
cmd := exec.Command("tput", opt)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout

View File

@ -1,52 +1,5 @@
package cui
import (
"reflect"
"testing"
)
// tests related to issue 31
func Test_wrapLines_space_preservation(t *testing.T) {
tables := []struct {
testinput []string
expectedoutput []string
linelength int
}{
{
//normal sentence - 20 characters - should not wrap
[]string{"it is her fav thingy"},
[]string{"it is her fav thingy"},
20,
},
{
//normal sentence - more than 20 characters - should wrap with a space at the end of the first line
[]string{"it is her favourite thing in the world"},
[]string{
"it is her favourite ",
"thing in the world",
},
20,
},
}
for _, table := range tables {
output := wrapLines(table.testinput, table.linelength)
if !reflect.DeepEqual(output, table.expectedoutput) {
t.Errorf("Expected %v, got %v", table.expectedoutput, output)
}
}
}
func Benchmark_wrapLines(b *testing.B) {
teststring := []string{
"0123456789",
"a really long line that will prolly be wrapped",
"a l i n e w i t h a l o t o f w o r d s",
"onehugelongwordaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
wrapLines(teststring, 20)
}
}

View File

@ -1,32 +0,0 @@
package cui
// MsgBar is a struct to represent a single row horizontal
// bar on the screen.
type MsgBar struct {
row int
title string
message string
showTitle bool
}
// SetTitle sets the title for the MsgBar in question
func (m *MsgBar) SetTitle(s string) {
m.title = s
}
// SetMessage sets the message for the MsgBar in question
func (m *MsgBar) SetMessage(s string) {
m.message = s
}
// ClearAll clears all text from the message bar (title and message)
func (m MsgBar) ClearAll() {
MoveCursorTo(m.row, 1)
Clear("line")
}
// ClearMessage clears all message text while leaving the title in place
func (m *MsgBar) ClearMessage() {
MoveCursorTo(m.row, len(m.title)+1)
Clear("right")
}

View File

@ -1,143 +0,0 @@
package cui
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// screenInit records whether or not the screen has been initialized
// this is used to prevent more than one screen from being used
var screenInit bool = false
// Screen represent the top level abstraction for a cui application.
// It takes up the full width and height of the terminal window and
// holds the various Windows and MsgBars for the application as well
// as a record of which window is active for control purposes.
type Screen struct {
Height int
Width int
Windows []*Window
Activewindow int
Bars []*MsgBar
}
// AddWindow adds a new window to the Screen struct in question
func (s *Screen) AddWindow(r1, c1, r2, c2 int, scroll, border, show bool) {
w := Window{box{r1, c1, r2, c2}, scroll, 0, []string{}, border, false, show, 1}
s.Windows = append(s.Windows, &w)
}
// AddMsgBar adds a new MsgBar to the Screen struct in question
func (s *Screen) AddMsgBar(row int, title, msg string, showTitle bool) {
b := MsgBar{row, title, msg, showTitle}
s.Bars = append(s.Bars, &b)
}
// DrawAllWindows loops over every window in the Screen struct and
// draws it to screen in index order (smallest to largest)
func (s Screen) DrawAllWindows() {
for _, w := range s.Windows {
if w.Show {
w.DrawWindow()
}
}
MoveCursorTo(s.Height-1, 1)
}
// Clear removes all content from the interior of the screen
func (s Screen) Clear() {
for i := 0; i <= s.Height; i++ {
MoveCursorTo(i, 0)
Clear("line")
}
}
// Clears message/error/command area
func (s *Screen) ClearCommandArea() {
MoveCursorTo(s.Height-1, 1)
Clear("line")
MoveCursorTo(s.Height, 1)
Clear("line")
MoveCursorTo(s.Height-1, 1)
}
// ReflashScreen checks for a screen resize and resizes windows if
// needed then redraws the screen. It takes a bool to decide whether
// to redraw the full screen or just the content. On a resize
// event, the full screen will always be redrawn.
func (s *Screen) ReflashScreen(clearScreen bool) {
s.DrawAllWindows()
if clearScreen {
s.DrawMsgBars()
s.ClearCommandArea()
}
}
// DrawMsgBars draws all MsgBars present in the Screen struct.
// All MsgBars are looped over and drawn in index order (sm - lg).
func (s *Screen) DrawMsgBars() {
for _, bar := range s.Bars {
fmt.Print("\033[7m")
var buf bytes.Buffer
title := bar.title
if len(bar.title) > s.Width {
title = string(bar.title[:s.Width-3]) + "..."
}
_, _ = buf.WriteString(title)
msg := bar.message
if len(bar.message) > s.Width-len(title) {
msg = string(bar.message[:s.Width-len(title)-3]) + "..."
}
_, _ = buf.WriteString(msg)
MoveCursorTo(bar.row, 1)
fmt.Print(strings.Repeat(" ", s.Width))
fmt.Print("\033[0m")
MoveCursorTo(bar.row, 1)
fmt.Print("\033[7m")
fmt.Print(buf.String())
MoveCursorTo(bar.row, s.Width)
fmt.Print("\033[0m")
}
}
// GetSize retrieves the terminal size and sets the Screen
// width and height to that size
func (s *Screen) GetSize() {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
fmt.Println("Fatal error: Unable to retrieve terminal size")
os.Exit(1)
}
var h, w int
fmt.Sscan(string(out), &h, &w)
s.Height = h
s.Width = w
}
// - - - - - - - - - - - - - - - - - - - - - - - - - -
// NewScreen is a constructor function that returns a pointer
// to a Screen struct
func NewScreen() *Screen {
if screenInit {
fmt.Println("Fatal error: Cannot create multiple screens")
os.Exit(1)
}
var s Screen
s.GetSize()
for i := 0; i < s.Height; i++ {
fmt.Println()
}
SetCharMode()
Clear("screen")
screenInit = true
return &s
}

View File

@ -1,187 +0,0 @@
package cui
import (
"fmt"
"strings"
)
type box struct {
Row1 int
Col1 int
Row2 int
Col2 int
}
// TODO add coloring
type Window struct {
Box box
Scrollbar bool
Scrollposition int
Content []string
drawBox bool
Active bool
Show bool
tempContentLen int
}
func (w *Window) DrawWindow() {
w.DrawContent()
if w.drawBox {
w.DrawBox()
}
}
func (w *Window) DrawBox() {
lead := ""
if w.Active {
lead = "a"
}
moveThenDrawShape(w.Box.Row1, w.Box.Col1, lead+"tl")
moveThenDrawShape(w.Box.Row1, w.Box.Col2, lead+"tr")
moveThenDrawShape(w.Box.Row2, w.Box.Col1, lead+"bl")
moveThenDrawShape(w.Box.Row2, w.Box.Col2, lead+"br")
for i := w.Box.Col1 + 1; i < w.Box.Col2; i++ {
moveThenDrawShape(w.Box.Row1, i, lead+"ceiling")
moveThenDrawShape(w.Box.Row2, i, lead+"ceiling")
}
for i := w.Box.Row1 + 1; i < w.Box.Row2; i++ {
moveThenDrawShape(i, w.Box.Col1, lead+"wall")
moveThenDrawShape(i, w.Box.Col2, lead+"wall")
}
}
func (w *Window) DrawContent() {
var maxlines, borderThickness, contenth int
var short_content bool = false
if w.drawBox {
borderThickness, contenth = -1, 1
} else {
borderThickness, contenth = 1, 0
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
width := w.Box.Col2 - w.Box.Col1 + borderThickness
content := wrapLines(w.Content, width)
w.tempContentLen = len(content)
if w.Scrollposition > w.tempContentLen-height {
w.Scrollposition = w.tempContentLen - height
if w.Scrollposition < 0 {
w.Scrollposition = 0
}
}
if len(content) < w.Scrollposition+height {
maxlines = len(content)
short_content = true
} else {
maxlines = w.Scrollposition + height
}
for i := w.Scrollposition; i < maxlines; i++ {
MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth)
fmt.Print(strings.Repeat(" ", width))
MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth)
fmt.Print(content[i])
}
if short_content {
for i := len(content); i <= height; i++ {
MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth)
fmt.Print(strings.Repeat(" ", width))
}
}
}
func (w *Window) ScrollDown() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
if w.Scrollposition < w.tempContentLen-height {
w.Scrollposition++
} else {
fmt.Print("\a")
}
}
func (w *Window) ScrollUp() {
if w.Scrollposition > 0 {
w.Scrollposition--
} else {
fmt.Print("\a")
}
}
func (w *Window) PageDown() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
if w.Scrollposition < w.tempContentLen-height {
w.Scrollposition += height
if w.Scrollposition > w.tempContentLen-height {
w.Scrollposition = w.tempContentLen - height
}
} else {
fmt.Print("\a")
}
}
func (w *Window) PageUp() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
contentLength := len(w.Content)
if w.Scrollposition > 0 && height < contentLength {
w.Scrollposition -= height
if w.Scrollposition < 0 {
w.Scrollposition = 0
}
} else {
fmt.Print("\a")
}
}
func (w *Window) ScrollHome() {
if w.Scrollposition > 0 {
w.Scrollposition = 0
} else {
fmt.Print("\a")
}
}
func (w *Window) ScrollEnd() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
if w.Scrollposition < w.tempContentLen-height {
w.Scrollposition = w.tempContentLen - height
} else {
fmt.Print("\a")
}
}

25
defaults.go Normal file
View File

@ -0,0 +1,25 @@
package main
import (
"os/user"
)
var userinfo, _ = user.Current()
var defaultOptions = map[string]string{
//
// General configuration options
//
// Edit these values before compile to have different default values
// ... though they can always be edited from within bombadillo as well
// it just may take more time/work.
"homeurl": "gopher://colorfield.space:70/1/bombadillo-info",
"savelocation": userinfo.HomeDir,
"searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs",
"openhttp": "false",
"httpbrowser": "lynx",
"telnetcommand": "telnet",
"configlocation": userinfo.HomeDir,
"theme": "normal", // "normal", "inverted"
"terminalonly": "true",
}

53
footbar.go Normal file
View File

@ -0,0 +1,53 @@
package main
import (
"fmt"
"strconv"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type Footbar struct {
PercentRead string
PageType string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (f *Footbar) SetPercentRead(p int) {
if p > 100 {
p = 100
} else if p < 0 {
p = 0
}
f.PercentRead = strconv.Itoa(p) + "%"
}
func (f *Footbar) SetPageType(t string) {
f.PageType = t
}
func (f *Footbar) Render(termWidth, position int, theme string) string {
pre := fmt.Sprintf("HST: (%2.2d) - - - %4s Read ", position + 1, f.PercentRead)
out := "\033[0m%*.*s "
if theme == "inverse" {
out = "\033[7m%*.*s \033[0m"
}
return fmt.Sprintf(out, termWidth - 1, termWidth - 1, pre)
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakeFootbar() Footbar {
return Footbar{"---", "N/A"}
}

233
gemini/gemini.go Normal file
View File

@ -0,0 +1,233 @@
package gemini
import (
"crypto/tls"
"fmt"
"io/ioutil"
"strconv"
"strings"
// "tildegit.org/sloum/mailcap"
)
type Capsule struct {
MimeMaj string
MimeMin string
Status int
Content string
Links []string
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func Retrieve(host, port, resource string) (string, error) {
if host == "" || port == "" {
return "", fmt.Errorf("Incomplete request url")
}
addr := host + ":" + port
conf := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
}
conn, err := tls.Dial("tcp", addr, conf)
if err != nil {
return "", err
}
defer conn.Close()
send := "gemini://" + addr + "/" + resource + "\r\n"
_, err = conn.Write([]byte(send))
if err != nil {
return "", err
}
result, err := ioutil.ReadAll(conn)
if err != nil {
return "", err
}
return string(result), nil
}
func Fetch(host, port, resource string) ([]byte, error) {
rawResp, err := Retrieve(host, port, resource)
if err != nil {
return make([]byte, 0), err
}
resp := strings.SplitN(rawResp, "\r\n", 2)
if len(resp) != 2 {
if err != nil {
return make([]byte, 0), fmt.Errorf("Invalid response from server")
}
}
header := strings.SplitN(resp[0], " ", 2)
if len([]rune(header[0])) != 2 {
header = strings.SplitN(resp[0], "\t", 2)
if len([]rune(header[0])) != 2 {
return make([]byte,0), fmt.Errorf("Invalid response format from server")
}
}
// Get status code single digit form
status, err := strconv.Atoi(string(header[0][0]))
if err != nil {
return make([]byte, 0), fmt.Errorf("Invalid status response from server")
}
if status != 2 {
switch status {
case 1:
return make([]byte, 0), fmt.Errorf("[1] Queries cannot be saved.")
case 3:
return make([]byte, 0), fmt.Errorf("[3] Redirects cannot be saved.")
case 4:
return make([]byte, 0), fmt.Errorf("[4] Temporary Failure.")
case 5:
return make([]byte, 0), fmt.Errorf("[5] Permanent Failure.")
case 6:
return make([]byte, 0), fmt.Errorf("[6] Client Certificate Required (Not supported by Bombadillo)")
default:
return make([]byte, 0), fmt.Errorf("Invalid response status from server")
}
}
return []byte(resp[1]), nil
}
func Visit(host, port, resource string) (Capsule, error) {
capsule := MakeCapsule()
rawResp, err := Retrieve(host, port, resource)
if err != nil {
return capsule, err
}
resp := strings.SplitN(rawResp, "\r\n", 2)
if len(resp) != 2 {
if err != nil {
return capsule, fmt.Errorf("Invalid response from server")
}
}
header := strings.SplitN(resp[0], " ", 2)
if len([]rune(header[0])) != 2 {
header = strings.SplitN(resp[0], "\t", 2)
if len([]rune(header[0])) != 2 {
return capsule, fmt.Errorf("Invalid response format from server")
}
}
body := resp[1]
// Get status code single digit form
capsule.Status, err = strconv.Atoi(string(header[0][0]))
if err != nil {
return capsule, fmt.Errorf("Invalid status response from server")
}
// Parse the meta as needed
var meta string
switch capsule.Status {
case 1:
capsule.Content = header[1]
return capsule, nil
case 2:
mimeAndCharset := strings.Split(header[1], ";")
meta = mimeAndCharset[0]
minMajMime := strings.Split(meta, "/")
if len(minMajMime) < 2 {
return capsule, fmt.Errorf("Improperly formatted mimetype received from server")
}
capsule.MimeMaj = minMajMime[0]
capsule.MimeMin = minMajMime[1]
if capsule.MimeMaj == "text" && capsule.MimeMin == "gemini" {
if len(resource) > 0 && resource[0] != '/' {
resource = fmt.Sprintf("/%s", resource)
} else if resource == "" {
resource = "/"
}
currentUrl := fmt.Sprintf("gemini://%s:%s%s", host, port, resource)
rootUrl := fmt.Sprintf("gemini://%s:%s", host, port)
capsule.Content, capsule.Links = parseGemini(body, rootUrl, currentUrl)
} else {
capsule.Content = body
}
return capsule, nil
case 3:
// The client will handle informing the user of a redirect
// and then request the new url
capsule.Content = header[1]
return capsule, nil
case 4:
return capsule, fmt.Errorf("[4] Temporary Failure. %s", header[1])
case 5:
return capsule, fmt.Errorf("[5] Permanent Failure. %s", header[1])
case 6:
return capsule, fmt.Errorf("[6] Client Certificate Required (Not supported by Bombadillo)")
default:
return capsule, fmt.Errorf("Invalid response status from server")
}
}
func parseGemini(b, rootUrl, currentUrl string) (string, []string) {
splitContent := strings.Split(b, "\n")
links := make([]string, 0, 10)
for i, ln := range splitContent {
splitContent[i] = strings.Trim(ln, "\r\n")
if len([]rune(ln)) > 3 && ln[:2] == "=>" {
var link, decorator string
subLn := strings.Trim(ln[2:], "\r\n\t \a")
splitPoint := strings.IndexAny(subLn, " \t")
if splitPoint < 0 || len([]rune(subLn)) - 1 <= splitPoint {
link = subLn
decorator = subLn
} else {
link = strings.Trim(subLn[:splitPoint], "\t\n\r \a")
decorator = strings.Trim(subLn[splitPoint:], "\t\n\r \a")
}
if strings.Index(link, "://") < 0 {
link = handleRelativeUrl(link, rootUrl, currentUrl)
}
links = append(links, link)
linknum := fmt.Sprintf("[%d]", len(links))
splitContent[i] = fmt.Sprintf("%-5s %s", linknum, decorator)
}
}
return strings.Join(splitContent, "\n"), links
}
func handleRelativeUrl(u, root, current string) string {
if len(u) < 1 {
return u
}
if u[0] == '/' {
return fmt.Sprintf("%s%s", root, u)
}
ind := strings.LastIndex(current, "/")
if ind < 10 {
return fmt.Sprintf("%s/%s", root, u)
}
current = current[:ind + 1]
return fmt.Sprintf("%s%s", current, u)
}
func MakeCapsule() Capsule {
return Capsule{"", "", 0, "", make([]string, 0, 5)}
}

View File

@ -1,65 +0,0 @@
package gopher
import (
"fmt"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
//Bookmarks is a holder for titles and links that
//can be retrieved by index
type Bookmarks struct {
Titles []string
Links []string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// Add adds a new title and link combination to the bookmarks
// struct. It takes as input a string slice in which the first
// element represents the link and all following items represent
// the title of the bookmark (they will be joined with spaces).
func (b *Bookmarks) Add(v []string) error {
if len(v) < 2 {
return fmt.Errorf("Received %d arguments, expected 2 or more", len(v))
}
b.Titles = append(b.Titles, strings.Join(v[1:], " "))
b.Links = append(b.Links, v[0])
return nil
}
func (b *Bookmarks) Del(i int) error {
if i < len(b.Titles) && i < len(b.Links) {
b.Titles = append(b.Titles[:i], b.Titles[i+1:]...)
b.Links = append(b.Links[:i], b.Links[i+1:]...)
return nil
}
return fmt.Errorf("Bookmark %d does not exist", i)
}
func (b Bookmarks) List() []string {
var out []string
for i, t := range b.Titles {
out = append(out, fmt.Sprintf("[%d] %s", i, t))
}
return out
}
func (b Bookmarks) IniDump() string {
if len(b.Titles) < 0 {
return ""
}
out := "[BOOKMARKS]\n"
for i := 0; i < len(b.Titles); i++ {
out += b.Titles[i]
out += "="
out += b.Links[i]
out += "\n"
}
return out
}

View File

@ -21,17 +21,21 @@ import (
var types = map[string]string{
"0": "TXT",
"1": "MAP",
"h": "HTM",
"3": "ERR",
"4": "BIN",
"5": "DOS",
"s": "SND",
"g": "GIF",
"I": "IMG",
"9": "BIN",
"7": "FTS",
"6": "UUE",
"7": "FTS",
"8": "TEL",
"9": "BIN",
"g": "GIF",
"G": "GEM",
"h": "HTM",
"I": "IMG",
"p": "PNG",
"s": "SND",
"S": "SSH",
"T": "TEL",
}
//------------------------------------------------\\
@ -43,25 +47,22 @@ var types = map[string]string{
// available to use directly, but in most implementations
// using the "Visit" receiver of the History struct will
// be better.
func Retrieve(u Url) ([]byte, error) {
func Retrieve(host, port, resource string) ([]byte, error) {
nullRes := make([]byte, 0)
timeOut := time.Duration(5) * time.Second
if u.Host == "" || u.Port == "" {
if host == "" || port == "" {
return nullRes, errors.New("Incomplete request url")
}
addr := u.Host + ":" + u.Port
addr := host + ":" + port
conn, err := net.DialTimeout("tcp", addr, timeOut)
if err != nil {
return nullRes, err
}
send := u.Resource + "\n"
if u.Scheme == "http" || u.Scheme == "https" {
send = u.Gophertype
}
send := resource + "\n"
_, err = conn.Write([]byte(send))
if err != nil {
@ -73,43 +74,30 @@ func Retrieve(u Url) ([]byte, error) {
return nullRes, err
}
return result, err
return result, nil
}
// Visit is a high level combination of a few different
// types that makes it easy to create a Url, make a request
// to that Url, and add the response and Url to a View.
// Returns a copy of the view and an error (or nil).
func Visit(addr, openhttp string) (View, error) {
u, err := MakeUrl(addr)
// Visit handles the making of the request, parsing of maps, and returning
// the correct information to the client
func Visit(gophertype, host, port, resource string) (string, []string, error) {
resp, err := Retrieve(host, port, resource)
if err != nil {
return View{}, err
}
if u.Gophertype == "h" {
if res, tf := isWebLink(u.Resource); tf && strings.ToUpper(openhttp) == "TRUE" {
err := openBrowser(res)
if err != nil {
return View{}, err
}
return View{}, fmt.Errorf("")
}
return "", []string{}, err
}
text := string(resp)
links := []string{}
text, err := Retrieve(u)
if err != nil {
return View{}, err
if IsDownloadOnly(gophertype) {
return text, []string{}, nil
}
var pageContent []string
if u.IsBinary && u.Gophertype != "7" {
pageContent = []string{string(text)}
} else {
pageContent = strings.Split(string(text), "\n")
if gophertype == "1" {
text, links = parseMap(text)
}
return MakeView(u, pageContent), nil
return text, links, nil
}
func getType(t string) string {
@ -127,3 +115,70 @@ func isWebLink(resource string) (string, bool) {
}
return "", false
}
func parseMap(text string) (string, []string) {
splitContent := strings.Split(text, "\n")
links := make([]string, 0, 10)
for i, e := range splitContent {
e = strings.Trim(e, "\r\n")
if e == "." {
splitContent[i] = ""
continue
}
line := strings.Split(e, "\t")
var title string
if len(line[0]) > 1 {
title = line[0][1:]
} else {
title = ""
}
if len(line) > 1 && len(line[0]) > 0 && string(line[0][0]) == "i" {
splitContent[i] = " " + string(title)
} else if len(line) >= 4 {
link := buildLink(line[2], line[3], string(line[0][0]), line[1])
links = append(links, link)
linktext := fmt.Sprintf("(%s) %2d %s", getType(string(line[0][0])), len(links), title)
splitContent[i] = linktext
}
}
return strings.Join(splitContent, "\n"), links
}
// Returns false for all text formats (including html
// even though it may link out. Things like telnet
// should never make it into the retrieve call for
// this module, having been handled in the client
// based on their protocol.
func IsDownloadOnly(gophertype string) bool {
switch gophertype {
case "0", "1", "3", "7", "h":
return false
default:
return true
}
}
func buildLink(host, port, gtype, resource string) string {
switch gtype {
case "8", "T":
return fmt.Sprintf("telnet://%s:%s", host, port)
case "G":
return fmt.Sprintf("gemini://%s:%s%s", host, port, resource)
case "h":
u, tf := isWebLink(resource)
if tf {
if strings.Index(u, "://") > 0 {
return u
} else {
return fmt.Sprintf("http://%s", u)
}
}
return fmt.Sprintf("gopher://%s:%s/h%s", host, port, resource)
default:
return fmt.Sprintf("gopher://%s:%s/%s%s", host, port, gtype, resource)
}
}

View File

@ -1,112 +0,0 @@
package gopher
import (
"errors"
"fmt"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// The history struct represents the history of the browsing
// session. It contains the current history position, the
// length of the active history space (this can be different
// from the available capacity in the Collection), and a
// collection array containing View structs representing
// each page in the current history. In general usage this
// struct should be initialized via the MakeHistory function.
type History struct {
Position int
Length int
Collection [20]View
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// The "Add" receiver takes a view and adds it to
// the history struct that called it. "Add" returns
// nothing. "Add" will shift history down if the max
// history length would be exceeded, and will reset
// history length if something is added in the middle.
func (h *History) Add(v View) {
v.ParseMap()
if h.Position == h.Length-1 && h.Length < len(h.Collection) {
h.Collection[h.Length] = v
h.Length++
h.Position++
} else if h.Position == h.Length-1 && h.Length == 20 {
for x := 1; x < len(h.Collection); x++ {
h.Collection[x-1] = h.Collection[x]
}
h.Collection[len(h.Collection)-1] = v
} else {
h.Position += 1
h.Length = h.Position + 1
h.Collection[h.Position] = v
}
}
// The "Get" receiver is called by a history struct
// and returns a View from the current position, will
// return an error if history is empty and there is
// nothing to get.
func (h History) Get() (*View, error) {
if h.Position < 0 {
return nil, errors.New("History is empty, cannot get item from empty history.")
}
return &h.Collection[h.Position], nil
}
// The "GoBack" receiver is called by a history struct.
// When called it decrements the current position and
// displays the content for the View in that position.
// If history is at position 0, no action is taken.
func (h *History) GoBack() bool {
if h.Position > 0 {
h.Position--
return true
}
fmt.Print("\a")
return false
}
// The "GoForward" receiver is called by a history struct.
// When called it increments the current position and
// displays the content for the View in that position.
// If history is at position len - 1, no action is taken.
func (h *History) GoForward() bool {
if h.Position+1 < h.Length {
h.Position++
return true
}
fmt.Print("\a")
return false
}
// The "DisplayCurrentView" receiver is called by a history
// struct. It calls the Display receiver for th view struct
// at the current history position. "DisplayCurrentView" does
// not return anything, and does nothing if position is less
// that 0.
func (h *History) DisplayCurrentView() {
h.Collection[h.Position].Display()
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// Constructor function for History struct.
// This is used to initialize history position
// as -1, which is needed. Returns a copy of
// initialized History struct (does NOT return
// a pointer to the struct).
func MakeHistory() History {
return History{-1, 0, [20]View{}}
}

View File

@ -1,9 +0,0 @@
// +build darwin
package gopher
import "os/exec"
func openBrowser(url string) error {
return exec.Command("open", url).Start()
}

View File

@ -1,9 +0,0 @@
// +build linux
package gopher
import "os/exec"
func openBrowser(url string) error {
return exec.Command("xdg-open", url).Start()
}

View File

@ -1,11 +0,0 @@
// +build !linux
// +build !darwin
// +build !windows
package gopher
import "fmt"
func openBrowser(url string) error {
return fmt.Errorf("Unsupported os for browser detection")
}

View File

@ -1,9 +0,0 @@
// +build windows
package gopher
import "os/exec"
func openBrowser(url string) error {
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}

View File

@ -1,94 +0,0 @@
package gopher
import (
"errors"
"regexp"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// The url struct represents a URL for the rest of the system.
// It includes component parts as well as a full URL string.
type Url struct {
Scheme string
Host string
Port string
Gophertype string
Resource string
Full string
IsBinary bool
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeUrl is a Url constructor that takes in a string
// representation of a url and returns a Url struct and
// an error (or nil).
func MakeUrl(u string) (Url, error) {
var out Url
re := regexp.MustCompile(`^((?P<scheme>gopher|http|https|ftp|telnet):\/\/)?(?P<host>[\w\-\.\d]+)(?::(?P<port>\d+)?)?(?:/(?P<type>[01345679gIhisp])?)?(?P<resource>.*)?$`)
match := re.FindStringSubmatch(u)
if valid := re.MatchString(u); !valid {
return out, errors.New("Invalid URL or command character")
}
for i, name := range re.SubexpNames() {
switch name {
case "scheme":
out.Scheme = match[i]
case "host":
out.Host = match[i]
case "port":
out.Port = match[i]
case "type":
out.Gophertype = match[i]
case "resource":
out.Resource = match[i]
}
}
if out.Scheme == "" {
out.Scheme = "gopher"
}
if out.Host == "" {
return out, errors.New("no host")
}
if out.Scheme == "gopher" && out.Port == "" {
out.Port = "70"
} else if out.Scheme == "http" && out.Port == "" {
out.Port = "80"
} else if out.Scheme == "https" && out.Port == "" {
out.Port = "443"
}
if out.Gophertype == "" && (out.Resource == "" || out.Resource == "/") {
out.Gophertype = "1"
}
if out.Scheme == "gopher" && out.Gophertype == "" {
out.Gophertype = "0"
}
if out.Gophertype == "7" && strings.Contains(out.Resource, "\t") {
out.Gophertype = "1"
}
switch out.Gophertype {
case "1", "0", "h", "7":
out.IsBinary = false
default:
out.IsBinary = true
}
out.Full = out.Scheme + "://" + out.Host + ":" + out.Port + "/" + out.Gophertype + out.Resource
return out, nil
}

View File

@ -1,83 +0,0 @@
package gopher
import (
"fmt"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// View is a struct representing a gopher page. It contains
// the page content as a string slice, a list of link URLs
// as string slices, and the Url struct representing the page.
type View struct {
Content []string
Links []string
Address Url
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// ParseMap is called by a view struct to parse a gophermap.
// It checks if the view is for a gophermap. If not,it does
// nothing. If so, it parses the gophermap into comment lines
// and link lines. For link lines it adds a link to the links
// slice and changes the content value to just the printable
// string plus a gophertype indicator and a link number that
// relates to the link position in the links slice. This
// receiver does not return anything.
func (v *View) ParseMap() {
if v.Address.Gophertype == "1" || v.Address.Gophertype == "7" {
for i, e := range v.Content {
e = strings.Trim(e, "\r\n")
if e == "." {
v.Content[i] = " "
continue
}
line := strings.Split(e, "\t")
var title string
if len(line[0]) > 1 {
title = line[0][1:]
} else {
title = ""
}
if len(line) > 1 && len(line[0]) > 0 && string(line[0][0]) == "i" {
v.Content[i] = " " + string(title)
} else if len(line) >= 4 {
fulllink := fmt.Sprintf("%s:%s/%s%s", line[2], line[3], string(line[0][0]), line[1])
v.Links = append(v.Links, fulllink)
linktext := fmt.Sprintf("(%s) %2d %s", getType(string(line[0][0])), len(v.Links), title)
v.Content[i] = linktext
}
}
}
}
// Display is called on a view struct to print the contents of the view.
// This receiver does not return anything.
func (v View) Display() {
fmt.Println()
for _, el := range v.Content {
fmt.Println(el)
}
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeView creates and returns a new View struct from
// a Url and a string splice of content. This is used to
// initialize a View with a Url struct, links, and content.
// It takes a Url struct and a content []string and returns
// a View (NOT a pointer to a View).
func MakeView(url Url, content []string) View {
v := View{content, make([]string, 0), url}
v.ParseMap()
return v
}

38
headbar.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"fmt"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type Headbar struct {
title string
url string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (h *Headbar) Render(width int, theme string) string {
maxMsgWidth := width - len([]rune(h.title)) - 2
if theme == "inverse" {
return fmt.Sprintf("\033[7m%s▟\033[27m %-*.*s\033[0m", h.title, maxMsgWidth, maxMsgWidth, h.url)
} else {
return fmt.Sprintf("%s▟\033[7m %-*.*s\033[0m", h.title, maxMsgWidth, maxMsgWidth, h.url)
}
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakeHeadbar(title string) Headbar {
return Headbar{title, ""}
}

View File

@ -0,0 +1,13 @@
// +build darwin
package http
import "os/exec"
func OpenInBrowser(url string) (string, error) {
err := exec.Command("open", url).Start()
if err != nil {
return "", err
}
return "Opened in system default web browser", nil
}

View File

@ -0,0 +1,13 @@
// +build linux
package http
import "os/exec"
func OpenInBrowser(url string) (string, error) {
err := exec.Command("xdg-open", url).Start()
if err != nil {
return "", err
}
return "Opened in system default web browser", nil
}

View File

@ -0,0 +1,11 @@
// +build !linux
// +build !darwin
// +build !windows
package http
import "fmt"
func OpenInBrowser(url string) (string, error) {
return "", fmt.Errorf("Unsupported os for browser detection")
}

View File

@ -0,0 +1,13 @@
// +build windows
package http
import "os/exec"
func OpenInBrowser(url string) (string, error) {
err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
if err != nil {
return "", err
}
return "Opened in system default web browser", nil
}

600
main.go
View File

@ -1,402 +1,63 @@
package main
// Bombadillo is a gopher and gemini client for the terminal of unix or unix-like systems.
//
// Copyright (C) 2019 Brian Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import (
"flag"
"fmt"
"io/ioutil"
"os"
"os/user"
"regexp"
"strconv"
"strings"
"tildegit.org/sloum/bombadillo/cmdparse"
"tildegit.org/sloum/bombadillo/config"
"tildegit.org/sloum/bombadillo/cui"
"tildegit.org/sloum/bombadillo/gopher"
"tildegit.org/sloum/mailcap"
)
const version = "2.0.0"
var bombadillo *client
var helplocation string = "gopher://colorfield.space:70/1/bombadillo-info"
var history gopher.History = gopher.MakeHistory()
var screen *cui.Screen
var userinfo, _ = user.Current()
var settings config.Config
var options = map[string]string{
"homeurl": "gopher://colorfield.space:70/1/bombadillo-info",
"savelocation": userinfo.HomeDir,
"searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs",
"openhttp": "false",
"httpbrowser": "lynx",
}
func saveFile(address, name string) error {
quickMessage("Saving file...", false)
url, err := gopher.MakeUrl(address)
if err != nil {
quickMessage("Saving file...", true)
return err
}
data, err := gopher.Retrieve(url)
if err != nil {
quickMessage("Saving file...", true)
return err
}
err = ioutil.WriteFile(options["savelocation"]+name, data, 0644)
if err != nil {
quickMessage("Saving file...", true)
return err
}
quickMessage(fmt.Sprintf("Saved file to %s%s", options["savelocation"], name), false)
return nil
}
func saveFileFromData(v gopher.View) error {
quickMessage("Saving file...", false)
urlsplit := strings.Split(v.Address.Full, "/")
filename := urlsplit[len(urlsplit)-1]
saveMsg := fmt.Sprintf("Saved file as %q", options["savelocation"]+filename)
err := ioutil.WriteFile(options["savelocation"]+filename, []byte(strings.Join(v.Content, "")), 0644)
if err != nil {
quickMessage("Saving file...", true)
return err
}
quickMessage(saveMsg, false)
return nil
}
func search(u string) error {
cui.MoveCursorTo(screen.Height-1, 0)
cui.Clear("line")
fmt.Print("Enter form input: ")
cui.MoveCursorTo(screen.Height-1, 17)
entry, err := cui.GetLine()
if err != nil {
return err
}
quickMessage("Searching...", false)
searchurl := fmt.Sprintf("%s\t%s", u, entry)
sv, err := gopher.Visit(searchurl, options["openhttp"])
if err != nil {
quickMessage("Searching...", true)
return err
}
history.Add(sv)
quickMessage("Searching...", true)
updateMainContent()
screen.Windows[0].Scrollposition = 0
screen.ReflashScreen(true)
return nil
}
func routeInput(com *cmdparse.Command) error {
var err error
switch com.Type {
case cmdparse.SIMPLE:
err = simpleCommand(com.Action)
case cmdparse.GOURL:
err = goToURL(com.Target)
case cmdparse.GOLINK:
err = goToLink(com.Target)
case cmdparse.DO:
err = doCommand(com.Action, com.Value)
case cmdparse.DOLINK:
err = doLinkCommand(com.Action, com.Target)
case cmdparse.DOAS:
err = doCommandAs(com.Action, com.Value)
case cmdparse.DOLINKAS:
err = doLinkCommandAs(com.Action, com.Target, com.Value)
default:
return fmt.Errorf("Unknown command entry!")
}
return err
}
func toggleBookmarks() {
bookmarks := screen.Windows[1]
main := screen.Windows[0]
if bookmarks.Show {
bookmarks.Show = false
screen.Activewindow = 0
main.Active = true
bookmarks.Active = false
} else {
bookmarks.Show = true
screen.Activewindow = 1
main.Active = false
bookmarks.Active = true
}
screen.ReflashScreen(false)
}
func simpleCommand(a string) error {
a = strings.ToUpper(a)
switch a {
case "Q", "QUIT":
cui.Exit()
case "H", "HOME":
return goHome()
case "B", "BOOKMARKS":
toggleBookmarks()
case "SEARCH":
return search(options["searchengine"])
case "HELP", "?":
return goToURL(helplocation)
default:
return fmt.Errorf("Unknown action %q", a)
}
return nil
}
func goToURL(u string) error {
if num, _ := regexp.MatchString(`^-?\d+.?\d*$`, u); num {
return goToLink(u)
}
quickMessage("Loading...", false)
v, err := gopher.Visit(u, options["openhttp"])
if err != nil {
quickMessage("Loading...", true)
return err
}
quickMessage("Loading...", true)
if v.Address.Gophertype == "7" {
err := search(v.Address.Full)
if err != nil {
return err
}
} else if v.Address.IsBinary {
return saveFileFromData(v)
} else {
history.Add(v)
}
updateMainContent()
screen.Windows[0].Scrollposition = 0
screen.ReflashScreen(true)
return nil
}
func goToLink(l string) error {
if num, _ := regexp.MatchString(`^-?\d+$`, l); num && history.Length > 0 {
linkcount := len(history.Collection[history.Position].Links)
item, _ := strconv.Atoi(l)
if item <= linkcount && item > 0 {
linkurl := history.Collection[history.Position].Links[item-1]
quickMessage("Loading...", false)
v, err := gopher.Visit(linkurl, options["openhttp"])
if err != nil {
quickMessage("Loading...", true)
return err
}
quickMessage("Loading...", true)
if v.Address.Gophertype == "7" {
err := search(linkurl)
if err != nil {
return err
}
} else if v.Address.IsBinary {
return saveFileFromData(v)
} else {
history.Add(v)
}
} else {
return fmt.Errorf("Invalid link id: %s", l)
}
} else {
return fmt.Errorf("Invalid link id: %s", l)
}
updateMainContent()
screen.Windows[0].Scrollposition = 0
screen.ReflashScreen(true)
return nil
}
func goHome() error {
if options["homeurl"] != "unset" {
return goToURL(options["homeurl"])
}
return fmt.Errorf("No home address has been set")
}
func doLinkCommand(action, target string) error {
num, err := strconv.Atoi(target)
if err != nil {
return fmt.Errorf("Expected number, got %q", target)
}
switch action {
case "DELETE", "D":
err := settings.Bookmarks.Del(num)
if err != nil {
return err
}
screen.Windows[1].Content = settings.Bookmarks.List()
err = saveConfig()
if err != nil {
return err
}
screen.ReflashScreen(false)
return nil
case "BOOKMARKS", "B":
if num > len(settings.Bookmarks.Links)-1 {
return fmt.Errorf("There is no bookmark with ID %d", num)
}
err := goToURL(settings.Bookmarks.Links[num])
return err
}
return fmt.Errorf("This method has not been built")
}
func doCommandAs(action string, values []string) error {
if len(values) < 2 {
return fmt.Errorf("%q", values)
}
if values[0] == "." {
values[0] = history.Collection[history.Position].Address.Full
}
switch action {
case "ADD", "A":
err := settings.Bookmarks.Add(values)
if err != nil {
return err
}
screen.Windows[1].Content = settings.Bookmarks.List()
err = saveConfig()
if err != nil {
return err
}
screen.ReflashScreen(false)
return nil
case "WRITE", "W":
return saveFile(values[0], strings.Join(values[1:], " "))
case "SET", "S":
if _, ok := options[values[0]]; ok {
options[values[0]] = strings.Join(values[1:], " ")
return saveConfig()
}
return fmt.Errorf("Unable to set %s, it does not exist", values[0])
}
return fmt.Errorf("Unknown command structure")
}
func doCommand(action string, values []string) error {
if length := len(values); length != 1 {
return fmt.Errorf("Expected 1 argument, received %d", length)
}
switch action {
case "CHECK", "C":
err := checkConfigValue(values[0])
if err != nil {
return err
}
return nil
}
return fmt.Errorf("Unknown command structure")
}
func checkConfigValue(setting string) error {
if val, ok := options[setting]; ok {
quickMessage(fmt.Sprintf("%s is set to: %q", setting, val), false)
return nil
}
return fmt.Errorf("Unable to check %q, it does not exist", setting)
}
func doLinkCommandAs(action, target string, values []string) error {
num, err := strconv.Atoi(target)
if err != nil {
return fmt.Errorf("Expected number, got %q", target)
}
links := history.Collection[history.Position].Links
if num >= len(links) {
return fmt.Errorf("Invalid link id: %s", target)
}
switch action {
case "ADD", "A":
newBookmark := append([]string{links[num-1]}, values...)
err := settings.Bookmarks.Add(newBookmark)
if err != nil {
return err
}
screen.Windows[1].Content = settings.Bookmarks.List()
err = saveConfig()
if err != nil {
return err
}
screen.ReflashScreen(false)
return nil
case "WRITE", "W":
return saveFile(links[num-1], strings.Join(values, " "))
}
return fmt.Errorf("This method has not been built")
}
func updateMainContent() {
screen.Windows[0].Content = history.Collection[history.Position].Content
screen.Bars[0].SetMessage(history.Collection[history.Position].Address.Full)
}
func clearInput(incError bool) {
cui.MoveCursorTo(screen.Height-1, 0)
cui.Clear("line")
if incError {
cui.MoveCursorTo(screen.Height, 0)
cui.Clear("line")
}
}
func quickMessage(msg string, clearMsg bool) {
xPos := screen.Width - 2 - len(msg)
if xPos < 2 {
xPos = 2
}
cui.MoveCursorTo(screen.Height, xPos)
if clearMsg {
cui.Clear("right")
} else {
fmt.Print("\033[48;5;21m\033[38;5;15m", msg, "\033[0m")
}
}
var mc *mailcap.Mailcap
func saveConfig() error {
bkmrks := settings.Bookmarks.IniDump()
opts := "\n[SETTINGS]\n"
for k, v := range options {
opts += k
opts += "="
opts += v
opts += "\n"
var opts strings.Builder
bkmrks := bombadillo.BookMarks.IniDump()
opts.WriteString(bkmrks)
opts.WriteString("\n[SETTINGS]\n")
for k, v := range bombadillo.Options {
if k == "theme" && v != "normal" && v != "inverse" {
v = "normal"
bombadillo.Options["theme"] = "normal"
}
opts.WriteString(k)
opts.WriteRune('=')
opts.WriteString(v)
opts.WriteRune('\n')
}
return ioutil.WriteFile(userinfo.HomeDir+"/.bombadillo.ini", []byte(bkmrks+opts), 0644)
return ioutil.WriteFile(bombadillo.Options["configlocation"] + "/.bombadillo.ini", []byte(opts.String()), 0644)
}
func loadConfig() error {
file, err := os.Open(userinfo.HomeDir + "/.bombadillo.ini")
file, err := os.Open(bombadillo.Options["configlocation"] + "/.bombadillo.ini")
if err != nil {
err = saveConfig()
if err != nil {
@ -407,164 +68,77 @@ func loadConfig() error {
confparser := config.NewParser(file)
settings, _ = confparser.Parse()
file.Close()
screen.Windows[1].Content = settings.Bookmarks.List()
for _, v := range settings.Settings {
lowerkey := strings.ToLower(v.Key)
if _, ok := options[lowerkey]; ok {
options[lowerkey] = v.Value
if lowerkey == "configlocation" {
// The config should always be stored in home
// folder. Users cannot really edit this value.
// It is still stored in the ini and as a part
// of the options map.
continue
}
if _, ok := bombadillo.Options[lowerkey]; ok {
if lowerkey == "theme" && v.Value != "normal" && v.Value != "inverse" {
v.Value = "normal"
}
bombadillo.Options[lowerkey] = v.Value
}
}
for i, v := range settings.Bookmarks.Titles {
bombadillo.BookMarks.Add([]string{v, settings.Bookmarks.Links[i]})
}
return nil
}
func toggleActiveWindow() {
if screen.Windows[1].Show {
if screen.Windows[0].Active {
screen.Windows[0].Active = false
screen.Windows[1].Active = true
screen.Activewindow = 1
} else {
screen.Windows[0].Active = true
screen.Windows[1].Active = false
screen.Activewindow = 0
}
screen.Windows[1].DrawWindow()
}
}
func displayError(err error) {
cui.MoveCursorTo(screen.Height, 0)
fmt.Print("\033[41m\033[37m", err, "\033[0m")
}
func initClient() error {
history.Position = -1
screen = cui.NewScreen()
bombadillo = MakeClient(" ((( Bombadillo ))) ")
cui.SetCharMode()
screen.AddWindow(2, 1, screen.Height-2, screen.Width, false, false, true)
screen.Windows[0].Active = true
screen.AddMsgBar(1, " ((( Bombadillo ))) ", " A fun gopher client!", true)
bookmarksWidth := 40
if screen.Width < 40 {
bookmarksWidth = screen.Width
}
screen.AddWindow(2, screen.Width-bookmarksWidth, screen.Height-2, screen.Width, false, true, false)
return loadConfig()
}
func handleResize() {
oldh, oldw := screen.Height, screen.Width
screen.GetSize()
if screen.Height != oldh || screen.Width != oldw {
screen.Windows[0].Box.Row2 = screen.Height - 2
screen.Windows[0].Box.Col2 = screen.Width
bookmarksWidth := 40
if screen.Width < 40 {
bookmarksWidth = screen.Width
}
screen.Windows[1].Box.Row2 = screen.Height - 2
screen.Windows[1].Box.Col1 = screen.Width - bookmarksWidth
screen.Windows[1].Box.Col2 = screen.Width
screen.DrawAllWindows()
screen.DrawMsgBars()
screen.ClearCommandArea()
}
err := loadConfig()
return err
}
func main() {
cui.HandleAlternateScreen("smcup")
getVersion := flag.Bool("v", false, "See version number")
flag.Parse()
if *getVersion {
fmt.Printf("Bombadillo v%s\n", version)
os.Exit(0)
}
args := flag.Args()
// Build the mailcap db
// So that we can open files from gemini
mc = mailcap.NewMailcap()
cui.Tput("rmam") // turn off line wrapping
cui.Tput("smcup") // use alternate screen
defer cui.Exit()
err := initClient()
if err != nil {
// if we can't initialize the window,
// we can't do anything!
// if we can't initialize we should bail out
panic(err)
}
mainWindow := screen.Windows[0]
// Start polling for terminal size changes
go bombadillo.GetSize()
if len(os.Args) > 1 {
err = goToURL(os.Args[1])
if len(args) > 0 {
// If a url was passed, move it down the line
// Goroutine so keypresses can be made during
// page load
bombadillo.Visit(args[0])
} else {
err = goHome()
}
if err != nil {
displayError(err)
} else {
updateMainContent()
// Otherwise, load the homeurl
// Goroutine so keypresses can be made during
// page load
bombadillo.Visit(bombadillo.Options["homeurl"])
}
// Loop indefinitely on user input
for {
c := cui.Getch()
handleResize()
switch c {
case 'j', 'J':
screen.Windows[screen.Activewindow].ScrollDown()
screen.ReflashScreen(false)
case 'k', 'K':
screen.Windows[screen.Activewindow].ScrollUp()
screen.ReflashScreen(false)
case 'q', 'Q':
cui.Exit()
case 'g':
screen.Windows[screen.Activewindow].ScrollHome()
screen.ReflashScreen(false)
case 'G':
screen.Windows[screen.Activewindow].ScrollEnd()
screen.ReflashScreen(false)
case 'd':
screen.Windows[screen.Activewindow].PageDown()
screen.ReflashScreen(false)
case 'u':
screen.Windows[screen.Activewindow].PageUp()
screen.ReflashScreen(false)
case 'b':
success := history.GoBack()
if success {
mainWindow.Scrollposition = 0
updateMainContent()
screen.ReflashScreen(true)
}
case 'B':
toggleBookmarks()
case 'f', 'F':
success := history.GoForward()
if success {
mainWindow.Scrollposition = 0
updateMainContent()
screen.ReflashScreen(true)
}
case '\t':
toggleActiveWindow()
case ':', ' ':
cui.MoveCursorTo(screen.Height-1, 0)
entry, err := cui.GetLine()
if err != nil {
displayError(err)
}
// Clear entry line and error line
clearInput(true)
if entry == "" {
continue
}
parser := cmdparse.NewParser(strings.NewReader(entry))
p, err := parser.Parse()
if err != nil {
displayError(err)
} else {
err := routeInput(p)
if err != nil {
displayError(err)
}
}
}
bombadillo.TakeControlInput()
}
}

92
page.go Normal file
View File

@ -0,0 +1,92 @@
package main
import (
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type Page struct {
WrappedContent []string
RawContent string
Links []string
Location Url
ScrollPosition int
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (p *Page) ScrollPositionRange(termHeight int) (int, int) {
termHeight -= 3
if len(p.WrappedContent) - p.ScrollPosition < termHeight {
p.ScrollPosition = len(p.WrappedContent) - termHeight
}
if p.ScrollPosition < 0 {
p.ScrollPosition = 0
}
var end int
if len(p.WrappedContent) < termHeight {
end = len(p.WrappedContent)
} else {
end = p.ScrollPosition + termHeight
}
return p.ScrollPosition, end
}
// Performs a hard wrap to the requested
// width and updates the WrappedContent
// of the Page struct width a string slice
// of the wrapped data
func (p *Page) WrapContent(width int) {
counter := 0
var content strings.Builder
content.Grow(len(p.RawContent))
for _, ch := range []rune(p.RawContent) {
if ch == '\n' {
content.WriteRune(ch)
counter = 0
} else if ch == '\t' {
if counter + 4 < width {
content.WriteString(" ")
counter += 4
} else {
content.WriteRune('\n')
counter = 0
}
} else if ch == '\r' || ch == '\v' || ch == '\b' || ch == '\f' || ch == 27 {
// Get rid of control characters we dont want
continue
} else {
if counter < width {
content.WriteRune(ch)
counter++
} else {
content.WriteRune('\n')
counter = 0
if p.Location.Mime == "1" {
spacer := " "
content.WriteString(spacer)
counter += len(spacer)
}
content.WriteRune(ch)
}
}
}
p.WrappedContent = strings.Split(content.String(), "\n")
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakePage(url Url, content string, links []string) Page {
p := Page{make([]string, 0), content, links, url, 0}
return p
}

87
pages.go Normal file
View File

@ -0,0 +1,87 @@
package main
import (
"fmt"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type Pages struct {
Position int
Length int
History [20]Page
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (p *Pages) NavigateHistory(qty int) error {
newPosition := p.Position + qty
if newPosition < 0 {
return fmt.Errorf("You are already at the beginning of history")
} else if newPosition > p.Length - 1 {
return fmt.Errorf("Your way is blocked by void, there is nothing forward")
}
p.Position = newPosition
return nil
}
func (p *Pages) Add(pg Page) {
if p.Position == p.Length - 1 && p.Length < len(p.History) {
p.History[p.Length] = pg
p.Length++
p.Position++
} else if p.Position == p.Length - 1 && p.Length == 20 {
for x := 1; x < len(p.History); x++ {
p.History[x-1] = p.History[x]
}
p.History[len(p.History)-1] = pg
} else {
p.Position += 1
p.Length = p.Position + 1
p.History[p.Position] = pg
}
}
func (p *Pages) Render(termHeight, termWidth int) []string {
if p.Length < 1 {
return make([]string, 0)
}
pos := p.History[p.Position].ScrollPosition
prev := len(p.History[p.Position].WrappedContent)
p.History[p.Position].WrapContent(termWidth)
now := len(p.History[p.Position].WrappedContent)
if prev > now {
diff := prev - now
pos = pos - diff
} else if prev < now {
diff := now - prev
pos = pos + diff
if pos > now - termHeight {
pos = now - termHeight
}
}
if pos < 0 || now < termHeight - 3 {
pos = 0
}
p.History[p.Position].ScrollPosition = pos
return p.History[p.Position].WrappedContent[pos:]
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakePages() Pages {
return Pages{-1, 0, [20]Page{}}
}

24
telnet/telnet.go Normal file
View File

@ -0,0 +1,24 @@
package telnet
import (
"fmt"
"os"
"os/exec"
)
func StartSession(host string, port string) (string, error) {
// Case for telnet links
c := exec.Command("telnet", host, port)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
// Clear the screen and position the cursor at the top left
fmt.Print("\033[2J\033[0;0H")
err := c.Run()
if err != nil {
return "", fmt.Errorf("Telnet error response: %s", err.Error())
}
return "Telnet session terminated", nil
}

111
url.go Normal file
View File

@ -0,0 +1,111 @@
package main
import (
"fmt"
"regexp"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type Url struct {
Scheme string
Host string
Port string
Resource string
Full string
Mime string
DownloadOnly bool
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// There are currently no receivers for the Url struct
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeUrl is a Url constructor that takes in a string
// representation of a url and returns a Url struct and
// an error (or nil).
func MakeUrl(u string) (Url, error) {
var out Url
re := regexp.MustCompile(`^((?P<scheme>gopher|telnet|http|https|gemini):\/\/)?(?P<host>[\w\-\.\d]+)(?::(?P<port>\d+)?)?(?:/(?P<type>[01345679gIhisp])?)?(?P<resource>.*)?$`)
match := re.FindStringSubmatch(u)
if valid := re.MatchString(u); !valid {
return out, fmt.Errorf("Invalid url, unable to parse")
}
for i, name := range re.SubexpNames() {
switch name {
case "scheme":
out.Scheme = match[i]
case "host":
out.Host = match[i]
case "port":
out.Port = match[i]
case "type":
out.Mime = match[i]
case "resource":
out.Resource = match[i]
}
}
if out.Scheme == "" {
out.Scheme = "gopher"
}
if out.Host == "" {
return out, fmt.Errorf("no host")
}
if out.Scheme == "gopher" && out.Port == "" {
out.Port = "70"
} else if out.Scheme == "http" && out.Port == "" {
out.Port = "80"
} else if out.Scheme == "https" && out.Port == "" {
out.Port = "443"
} else if out.Scheme == "gemini" && out.Port == "" {
out.Port = "1965"
}
if out.Scheme == "gopher" && out.Mime == "" {
out.Mime = "1"
}
if out.Mime == "" && (out.Resource == "" || out.Resource == "/") && out.Scheme == "gopher" {
out.Mime = "1"
}
if out.Mime == "7" && strings.Contains(out.Resource, "\t") {
out.Mime = "1"
}
if out.Scheme == "gopher" {
switch out.Mime {
case "1", "0", "h", "7":
out.DownloadOnly = false
default:
out.DownloadOnly = true
}
} else {
out.Resource = fmt.Sprintf("%s%s", out.Mime, out.Resource)
out.Mime = ""
}
if out.Scheme == "http" || out.Scheme == "https" {
out.Mime = ""
}
out.Full = out.Scheme + "://" + out.Host + ":" + out.Port + "/" + out.Mime + out.Resource
return out, nil
}