diff --git a/CHANGELOG.md b/CHANGELOG.md index 670863a..503e38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Downloading pages and any content** (#38) - Link and heading lines are wrapped just like regular text lines - Wrapped list items are indented to stay behind the bullet (#35) - Certificate expiry date is stored when the cert IDs match (#39) diff --git a/NOTES.md b/NOTES.md index 9e86002..f6cc2d1 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,9 +1,9 @@ # Notes -- URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL - ## Issues +- URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL - Can't go back or do other things while page is loading - need a way to stop `handleURL` +- dlChoiceModal doesn't go away when portal is selected, and freezes on Cancel ## Upstream Bugs - Wrapping messes up on brackets diff --git a/README.md b/README.md index 7b0b8d0..a09beeb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ curl -sSL https://raw.githubusercontent.com/makeworld-the-better-one/amfora/mast update-desktop-database ~/.local/share/applications ``` +### For developers +This section is for programmers who want to install from source. + +Install latest release: +``` +GO111MODULE=on go get -u github.com/makeworld-the-better-one/amfora +``` + +Install latest commit: +``` +GO111MODULE=on go get -u github.com/makeworld-the-better-one/amfora@master +``` + ## Usage Just call `amfora` or `amfora ` on the terminal. On Windows it might be `amfora.exe` instead. @@ -59,8 +72,8 @@ Features in *italics* are in the master branch, but not in the latest release. - [x] Multiple charset support (over 55) - [x] Built-in search (uses GUS by default) - [x] Bookmarks +- [x] *Download pages and arbitrary data* - [ ] Search in pages with Ctrl-F -- [ ] Download pages and arbitrary data - [ ] Emoji favicons - See `gemini://mozz.us/files/rfc_gemini_favicon.gmi` for details - [ ] Stream support diff --git a/display/display.go b/display/display.go index 4e1f03a..db5c1d5 100644 --- a/display/display.go +++ b/display/display.go @@ -225,6 +225,11 @@ func Init() { // An InputField is in focus, nothing should interrupt return event } + _, ok = App.GetFocus().(*cview.Modal) + if ok { + // It's focused on a modal right now, nothing should interrupt + return event + } if tabs[curTab].mode == tabModeDone { // All the keys and operations that can only work while NOT loading diff --git a/display/download.go b/display/download.go index 893089f..5de021b 100644 --- a/display/download.go +++ b/display/download.go @@ -1,6 +1,8 @@ package display import ( + "fmt" + "io" "io/ioutil" "net/url" "os" @@ -8,11 +10,217 @@ import ( "path/filepath" "strconv" "strings" + "time" + "github.com/gdamore/tcell" "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/structs" + "github.com/makeworld-the-better-one/go-gemini" + "github.com/makeworld-the-better-one/progressbar/v3" + "github.com/spf13/viper" + "gitlab.com/tslocum/cview" ) +// For choosing between download and the portal - copy of YesNo basically +var dlChoiceModal = cview.NewModal(). + SetTextColor(tcell.ColorWhite). + SetText("That file could not be displayed. What would you like to do?"). + AddButtons([]string{"Download", "Open in portal", "Cancel"}) + +// Channel to indicate what choice they made using the button text +var dlChoiceCh = make(chan string) + +var dlModal = cview.NewModal(). + SetTextColor(tcell.ColorWhite) + +func dlInit() { + if viper.GetBool("a-general.color") { + dlChoiceModal.SetButtonBackgroundColor(tcell.ColorNavy). + SetButtonTextColor(tcell.ColorWhite). + SetBackgroundColor(tcell.ColorPurple) + dlModal.SetButtonBackgroundColor(tcell.ColorNavy). + SetButtonTextColor(tcell.ColorWhite). + SetBackgroundColor(tcell.Color130) // DarkOrange3, #af5f00 + } else { + dlChoiceModal.SetButtonBackgroundColor(tcell.ColorWhite). + SetButtonTextColor(tcell.ColorBlack). + SetBackgroundColor(tcell.ColorBlack) + dlModal.SetButtonBackgroundColor(tcell.ColorWhite). + SetButtonTextColor(tcell.ColorBlack). + SetBackgroundColor(tcell.ColorBlack) + } + + dlChoiceModal.SetBorder(true) + dlChoiceModal.SetBorderColor(tcell.ColorWhite) + dlChoiceModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + dlChoiceCh <- buttonLabel + }) + dlChoiceModal.GetFrame().SetTitleColor(tcell.ColorWhite) + dlChoiceModal.GetFrame().SetTitleAlign(cview.AlignCenter) + + dlModal.SetBorder(true) + dlModal.SetBorderColor(tcell.ColorWhite) + dlModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Ok" { + tabPages.SwitchToPage(strconv.Itoa(curTab)) + } + }) + dlModal.GetFrame().SetTitleColor(tcell.ColorWhite) + dlModal.GetFrame().SetTitleAlign(cview.AlignCenter) + dlModal.GetFrame().SetTitle(" Download ") +} + +// dlChoice displays the download choice modal and acts on the user's choice. +// It should run in a goroutine. +func dlChoice(u string, resp *gemini.Response) { + defer resp.Body.Close() + + parsed, err := url.Parse(u) + if err != nil { + Error("URL Error", err.Error()) + return + } + + tabPages.ShowPage("dlChoice") + tabPages.SendToFront("dlChoice") + App.SetFocus(dlChoiceModal) + App.Draw() + + choice := <-dlChoiceCh + if choice == "Download" { + tabPages.HidePage("dlChoice") + App.Draw() + downloadURL(u, resp) + return + } + if choice == "Open in portal" { + // Open in mozz's proxy + portalURL := u + if parsed.RawQuery != "" { + // Remove query and add encoded version on the end + query := parsed.RawQuery + parsed.RawQuery = "" + portalURL = parsed.String() + "%3F" + query + } + portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1" + handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false) + tabPages.SwitchToPage(strconv.Itoa(curTab)) + App.Draw() + return + } + tabPages.SwitchToPage(strconv.Itoa(curTab)) + App.Draw() +} + +// downloadURL pulls up a modal to show download progress and saves the URL content. +// downloadPage should be used for Page content. +func downloadURL(u string, resp *gemini.Response) { + _, _, width, _ := dlModal.GetInnerRect() + // Copy of progressbar.DefaultBytesSilent with custom width + bar := progressbar.NewOptions64( + -1, + progressbar.OptionSetWidth(width), + progressbar.OptionSetWriter(ioutil.Discard), + progressbar.OptionShowBytes(true), + progressbar.OptionThrottle(65*time.Millisecond), + progressbar.OptionShowCount(), + progressbar.OptionSpinnerType(14), + ) + bar.RenderBlank() + + savePath, err := downloadNameFromURL(u, "") + if err != nil { + Error("Download Error", "Error deciding on file name: "+err.Error()) + return + } + f, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + Error("Download Error", "Error creating download file: "+err.Error()) + return + } + defer f.Close() + + done := false + + go func(isDone *bool) { + // Update the bar display + for !*isDone { + dlModal.SetText(bar.String()) + App.Draw() + time.Sleep(100 * time.Millisecond) + } + }(&done) + + // Display + dlModal.ClearButtons() + dlModal.AddButtons([]string{"Downloading..."}) + tabPages.ShowPage("dl") + tabPages.SendToFront("dl") + App.SetFocus(dlModal) + App.Draw() + + io.Copy(io.MultiWriter(f, bar), resp.Body) + done = true + dlModal.SetText(fmt.Sprintf("Download complete! File saved to %s.", savePath)) + dlModal.ClearButtons() + dlModal.AddButtons([]string{"Ok"}) + dlModal.GetForm().SetFocus(100) + App.SetFocus(dlModal) + App.Draw() +} + +// downloadPage saves the passed Page to a file. +// It returns the saved path and an error. +// It always cleans up, so if an error is returned there is no file saved +func downloadPage(p *structs.Page) (string, error) { + var savePath string + var err error + + if p.Mediatype == structs.TextGemini { + savePath, err = downloadNameFromURL(p.Url, ".gmi") + } else { + savePath, err = downloadNameFromURL(p.Url, ".txt") + } + if err != nil { + return "", err + } + err = ioutil.WriteFile(savePath, []byte(p.Raw), 0644) + if err != nil { + // Just in case + os.Remove(savePath) + return "", err + } + return savePath, err +} + +// downloadNameFromURL takes a URl and returns a safe download path that will not overwrite any existing file. +// ext is an extension that will be added if the file has no extension, and for domain only URLs. +// It should include the dot. +func downloadNameFromURL(u string, ext string) (string, error) { + var name string + var err error + parsed, _ := url.Parse(u) + if parsed.Path == "" || path.Base(parsed.Path) == "/" { + // No file, just the root domain + name, err = getSafeDownloadName(parsed.Hostname()+ext, true, 0) + if err != nil { + return "", err + } + } else { + // There's a specific file + name = path.Base(parsed.Path) + if !strings.Contains(name, ".") { + // No extension + name += ext + } + name, err = getSafeDownloadName(name, false, 0) + if err != nil { + return "", err + } + } + return filepath.Join(config.DownloadsDir, name), nil +} + // getSafeDownloadName is used by downloads.go only. // It returns a modified name that is unique for the downloads folder. // This way duplicate saved files will not overwrite each other. @@ -59,45 +267,3 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) { d.Close() return nn, nil // Name doesn't exist already } - -// downloadPage saves the passed Page to a file. -// It returns the saved path and an error. -// It always cleans up, so if an error is returned there is no file saved -func downloadPage(p *structs.Page) (string, error) { - // Figure out file name - var name string - var err error - parsed, _ := url.Parse(p.Url) - if parsed.Path == "" || path.Base(parsed.Path) == "/" { - // No file, just the root domain - if p.Mediatype == structs.TextGemini { - name, err = getSafeDownloadName(parsed.Hostname()+".gmi", true, 0) - if err != nil { - return "", err - } - } else { - name, err = getSafeDownloadName(parsed.Hostname()+".txt", true, 0) - if err != nil { - return "", err - } - } - } else { - // There's a specific file - name = path.Base(parsed.Path) - if p.Mediatype == structs.TextGemini && !strings.HasSuffix(name, ".gmi") && !strings.HasSuffix(name, ".gemini") { - name += ".gmi" - } - name, err = getSafeDownloadName(name, false, 0) - if err != nil { - return "", err - } - } - savePath := filepath.Join(config.DownloadsDir, name) - err = ioutil.WriteFile(savePath, []byte(p.Raw), 0644) - if err != nil { - // Just in case - os.Remove(savePath) - return "", err - } - return savePath, err -} diff --git a/display/modals.go b/display/modals.go index b6fa971..721add7 100644 --- a/display/modals.go +++ b/display/modals.go @@ -40,7 +40,9 @@ func modalInit() { AddPage("error", errorModal, false, false). AddPage("input", inputModal, false, false). AddPage("yesno", yesNoModal, false, false). - AddPage("bkmk", bkmkModal, false, false) + AddPage("bkmk", bkmkModal, false, false). + AddPage("dlChoice", dlChoiceModal, false, false). + AddPage("dl", dlModal, false, false) // Color setup if viper.GetBool("a-general.color") { @@ -125,6 +127,7 @@ func modalInit() { yesNoModal.GetFrame().SetTitleAlign(cview.AlignCenter) bkmkInit() + dlInit() } // Error displays an error on the screen in a modal. diff --git a/display/private.go b/display/private.go index d17b2fd..ad514cd 100644 --- a/display/private.go +++ b/display/private.go @@ -166,7 +166,7 @@ func setPage(t *tab, p *structs.Page) { // Setup display App.SetFocus(t.view) - // Save bottom bar for the tab - TODO: other funcs will apply/display it + // Save bottom bar for the tab - other funcs will apply/display it t.barLabel = "" t.barText = p.Url } @@ -359,20 +359,7 @@ func handleURL(t *tab, u string) (string, bool) { return ret("", false) } // Status code 20, but not a document that can be displayed - yes := YesNo("This type of file can't be displayed. Downloading will be implemented soon. Would like to open the file in a HTTPS proxy for now?") - if yes { - // Open in mozz's proxy - portalURL := u - if parsed.RawQuery != "" { - // Remove query and add encoded version on the end - query := parsed.RawQuery - parsed.RawQuery = "" - portalURL = parsed.String() + "%3F" + query - } - portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1" - - handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false) - } + go dlChoice(u, res) return ret("", false) } diff --git a/go.mod b/go.mod index 2e205a1..01eb443 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/gdamore/tcell v1.3.1-0.20200608133353-cb1e5d6fa606 github.com/makeworld-the-better-one/go-gemini v0.6.0 + github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.3.1 // indirect github.com/pelletier/go-toml v1.8.0 // indirect diff --git a/go.sum b/go.sum index 83702be..2abe036 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,7 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -123,6 +124,8 @@ github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzR github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/makeworld-the-better-one/go-gemini v0.6.0 h1:wZfeCa8UNRgJrdeNRFBQDBCiekXqA3SJe39I1mboE3E= github.com/makeworld-the-better-one/go-gemini v0.6.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f h1:YEUlTs5gb35UlBLTgqrub9axWTYB3d7/8TxrkJDZpRI= +github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f/go.mod h1:X6sxWNi9PBgQybpR4fpXPVD5fm7svLqZTQ5DJuERIoM= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= @@ -133,6 +136,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=