diff --git a/NOTES.md b/NOTES.md index 57b0ee2..4cc6f77 100644 --- a/NOTES.md +++ b/NOTES.md @@ -20,4 +20,4 @@ - Bookmark keys aren't deleted, just set to `""` - Waiting on [this viper PR](https://github.com/spf13/viper/pull/519) to be merged - Help table cells aren't dynamically wrapped - - Filed [issue 29](https://gitlab.com/tslocum/cview/-/issues/29) \ No newline at end of file + - Filed [issue 29](https://gitlab.com/tslocum/cview/-/issues/29) diff --git a/README.md b/README.md index 28a3ad1..1cf6b42 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,9 @@ Amfora ❤️ open source! - It uses [tcell](https://github.com/gdamore/tcell) for low level terminal operations - [Viper](https://github.com/spf13/viper) for configuration and TOFU storing - [go-gemini](https://github.com/makeworld-the-better-one/go-gemini), my forked and updated Gemini client/server library -- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar) +- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar) - pull request [here](https://github.com/schollz/progressbar/pull/69) - [go-humanize](https://github.com/dustin/go-humanize) +- My [gofeed fork](https://github.com/makeworld-the-better-one/gofeed) - pull request [here](https://github.com/mmcdole/gofeed/pull/164) ## License This project is licensed under the GPL v3.0. See the [LICENSE](./LICENSE) file for details. diff --git a/display/display.go b/display/display.go index a2c5bdb..7bb147d 100644 --- a/display/display.go +++ b/display/display.go @@ -576,8 +576,11 @@ func Reload() { // URL loads and handles the provided URL for the current tab. // It should be an absolute URL. func URL(u string) { - if u[:6] == "about:" { - handleAbout(tabs[curTab], u) + t := tabs[curTab] + if strings.HasPrefix(u, "about:") { + if ok := handleAbout(t, u); ok { + t.addToHistory(u) + } return } @@ -585,7 +588,7 @@ func URL(u string) { // Assume it's a Gemini URL u = "gemini://" + u } - go goURL(tabs[curTab], u) + go goURL(t, u) } func NumTabs() int { diff --git a/display/handlers.go b/display/handlers.go index e4440de..5d791b4 100644 --- a/display/handlers.go +++ b/display/handlers.go @@ -167,27 +167,34 @@ func handleFavicon(t *tab, host, old string) { // 'about:'. It will display errors if the URL is not recognized, // but not display anything if an 'about:' URL is not passed. // +// It does not add the displayed page to history. +// // It returns a bool indicating if the provided URL could be handled. func handleAbout(t *tab, u string) bool { - if u[:6] != "about:" { + if !strings.HasPrefix(u, "about:") { return false } switch u { case "about:bookmarks": Bookmarks(t) - t.addToHistory("about:bookmarks") return true case "about:subscriptions": Subscriptions(t) - t.addToHistory("about:subscriptions") return true case "about:newtab": temp := newTabPage // Copy setPage(t, &temp) + t.applyBottomBar() return true } + if u == "about:manage-subscriptions" || (len(u) > 27 && u[:27] == "about:manage-subscriptions?") { + ManageSubscriptions(t, u) + // Don't count remove command in history + return u == "about:manage-subscriptions" + } + Error("Error", "Not a valid 'about:' URL.") return false } @@ -244,7 +251,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { App.SetFocus(t.view) - if u[:6] == "about:" { + if strings.HasPrefix(u, "about:") { return ret(u, handleAbout(t, u)) } diff --git a/display/newtab.go b/display/newtab.go index dde3c2f..fbc119b 100644 --- a/display/newtab.go +++ b/display/newtab.go @@ -18,7 +18,7 @@ You can customize this page by creating a gemtext file called newtab.gmi, in Amf Happy browsing! => about:bookmarks Bookmarks -=> about:subscriptions Feed and Page Tracking +=> about:subscriptions Subscriptions => //gemini.circumlunar.space Project Gemini => https://github.com/makeworld-the-better-one/amfora Amfora homepage [HTTPS] diff --git a/display/private.go b/display/private.go index 2758f0f..d718a9f 100644 --- a/display/private.go +++ b/display/private.go @@ -19,8 +19,10 @@ import ( // Not when a URL is opened on a new tab for the first time. // It will handle setting the bottomBar. func followLink(t *tab, prev, next string) { - if next[:6] == "about:" { - handleAbout(t, next) + if strings.HasPrefix(next, "about:") { + if ok := handleAbout(t, next); ok { + t.addToHistory(next) + } return } diff --git a/display/subscriptions.go b/display/subscriptions.go index 58bdd9a..bfed1b6 100644 --- a/display/subscriptions.go +++ b/display/subscriptions.go @@ -15,6 +15,7 @@ import ( "github.com/makeworld-the-better-one/amfora/renderer" "github.com/makeworld-the-better-one/amfora/structs" "github.com/makeworld-the-better-one/amfora/subscriptions" + "github.com/makeworld-the-better-one/go-gemini" "github.com/mmcdole/gofeed" "github.com/spf13/viper" ) @@ -28,7 +29,7 @@ func toLocalDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } -// Feeds displays the feeds page on the current tab. +// Subscriptions displays the subscriptions page on the current tab. func Subscriptions(t *tab) { logger.Log.Println("display.Subscriptions called") @@ -43,9 +44,10 @@ func Subscriptions(t *tab) { logger.Log.Println("started rendering subscriptions page") - subscriptionPageRaw := "# Subscriptions\n\n" + + rawPage := "# Subscriptions\n\n" + "See the help (by pressing ?) for details on how to use this page.\n\n" + - "If you just opened Amfora then updates will appear incrementally. Reload the page to see them.\n" + "If you just opened Amfora then updates will appear incrementally. Reload the page to see them.\n\n" + + "=> about:manage-subscriptions Manage subscriptions\n" // curDay represents what day of posts the loop is on. // It only goes backwards in time. @@ -67,21 +69,21 @@ func Subscriptions(t *tab) { if pub.Before(curDay) { // This post is on a new day, add a day header curDay = pub - subscriptionPageRaw += fmt.Sprintf("\n## %s\n\n", curDay.Format("Jan 02, 2006")) + rawPage += fmt.Sprintf("\n## %s\n\n", curDay.Format("Jan 02, 2006")) } if entry.Title == "" || entry.Title == "/" { // Just put author/title // Mainly used for when you're tracking the root domain of a site - subscriptionPageRaw += fmt.Sprintf("=>%s %s\n", entry.URL, entry.Prefix) + rawPage += fmt.Sprintf("=>%s %s\n", entry.URL, entry.Prefix) } else { // Include title and dash - subscriptionPageRaw += fmt.Sprintf("=>%s %s - %s\n", entry.URL, entry.Prefix, entry.Title) + rawPage += fmt.Sprintf("=>%s %s - %s\n", entry.URL, entry.Prefix, entry.Title) } } - content, links := renderer.RenderGemini(subscriptionPageRaw, textWidth(), leftMargin(), false) + content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false) page := structs.Page{ - Raw: subscriptionPageRaw, + Raw: rawPage, Content: content, Links: links, URL: "about:subscriptions", @@ -97,6 +99,57 @@ func Subscriptions(t *tab) { logger.Log.Println("done rendering subscriptions page") } +// ManageSubscriptions displays the subscription managing page in +// the current tab. `u` is the URL entered by the user. +func ManageSubscriptions(t *tab, u string) { + if len(u) > 27 && u[:27] == "about:manage-subscriptions?" { + // There's a query string, aka a URL to unsubscribe from + manageSubscriptionQuery(t, u) + return + } + + rawPage := "# Manage Subscriptions\n\n" + + "Below is list of URLs, both feeds and pages. Navigate to the link to unsubscribe from that feed or page.\n\n" + + for _, u2 := range subscriptions.AllURLS() { + rawPage += fmt.Sprintf( + "=>%s %s\n", + "about:manage-subscriptions?"+gemini.QueryEscape(u2), + u2, + ) + } + + content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false) + page := structs.Page{ + Raw: rawPage, + Content: content, + Links: links, + URL: "about:manage-subscriptions", + Width: termW, + Mediatype: structs.TextGemini, + } + go cache.AddPage(&page) + setPage(t, &page) + t.applyBottomBar() +} + +func manageSubscriptionQuery(t *tab, u string) { + sub, err := gemini.QueryUnescape(u[27:]) + if err != nil { + Error("URL Error", "Invalid query string: "+err.Error()) + return + } + + err = subscriptions.Remove(sub) + if err != nil { + ManageSubscriptions(t, "about:manage-subscriptions") // Reload + Error("Save Error", "Error saving the unsubscription to disk: "+err.Error()) + return + } + ManageSubscriptions(t, "about:manage-subscriptions") // Reload + Info("Unsubscribed from " + sub) +} + // openSubscriptionModal displays the "Add subscription" modal // It returns whether the user wanted to subscribe to feed/page. // The subscribed arg specifies whether this feed/page is already diff --git a/display/tab.go b/display/tab.go index f192720..8c3f805 100644 --- a/display/tab.go +++ b/display/tab.go @@ -146,9 +146,8 @@ func (t *tab) pageDown() { t.view.ScrollTo(row+(termH/4)*3, col) } -// hasContent returns true when the tab has a page that could be displayed. -// The most likely situation where false would be returned is when the default -// new tab content is being displayed. +// hasContent returns false when the tab's page is malformed, +// has no content or URL, or if it's an 'about:' page. func (t *tab) hasContent() bool { if t.page == nil || t.view == nil { return false diff --git a/subscriptions/structs.go b/subscriptions/structs.go index ba6fc16..facb809 100644 --- a/subscriptions/structs.go +++ b/subscriptions/structs.go @@ -73,8 +73,7 @@ type pageJSON struct { var data = jsonData{ feedMu: &sync.RWMutex{}, pageMu: &sync.RWMutex{}, - Feeds: make(map[string]*gofeed.Feed), - Pages: make(map[string]*pageJSON), + // Maps are created in Init() } // PageEntry is a single item on a subscriptions page. diff --git a/subscriptions/subscriptions.go b/subscriptions/subscriptions.go index bdf4f8c..16b4743 100644 --- a/subscriptions/subscriptions.go +++ b/subscriptions/subscriptions.go @@ -6,10 +6,12 @@ import ( "errors" "fmt" "io" + "io/ioutil" "mime" "os" "path" "reflect" + "sort" "strings" "sync" "time" @@ -42,20 +44,29 @@ func Init() error { f, err := os.Open(config.SubscriptionPath) if err == nil { // File exists and could be opened - defer f.Close() fi, err := f.Stat() if err == nil && fi.Size() > 0 { // File is not empty - dec := json.NewDecoder(f) - err = dec.Decode(&data) - if err != nil && err != io.EOF { + + jsonBytes, err := ioutil.ReadAll(f) + f.Close() + if err != nil { + return fmt.Errorf("read subscriptions.json error: %w", err) + } + err = json.Unmarshal(jsonBytes, &data) + if err != nil { return fmt.Errorf("subscriptions.json is corrupted: %w", err) //nolint:goerr113 } } + f.Close() } else if !os.IsNotExist(err) { // There's an error opening the file, but it's not bc is doesn't exist return fmt.Errorf("open subscriptions.json error: %w", err) //nolint:goerr113 + } else { + // File does not exist, initialize maps + data.Feeds = make(map[string]*gofeed.Feed) + data.Pages = make(map[string]*pageJSON) } LastUpdated = time.Now() @@ -130,26 +141,21 @@ func writeJSON() error { writeMu.Lock() defer writeMu.Unlock() - f, err := os.OpenFile(config.SubscriptionPath, os.O_WRONLY|os.O_CREATE, 0666) + data.Lock() + logger.Log.Println("subscriptions.writeJSON acquired data lock") + jsonBytes, err := json.MarshalIndent(&data, "", " ") + data.Unlock() if err != nil { logger.Log.Println("subscriptions.writeJSON error", err) return err } - defer f.Close() - enc := json.NewEncoder(f) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - - data.Lock() - logger.Log.Println("subscriptions.writeJSON acquired data lock") - err = enc.Encode(&data) - data.Unlock() - + err = ioutil.WriteFile(config.SubscriptionPath, jsonBytes, 0666) if err != nil { logger.Log.Println("subscriptions.writeJSON error", err) + return err } - return err + return nil } // AddFeed stores a feed. @@ -363,3 +369,37 @@ func updateAll() { wg.Wait() } + +// AllURLs returns all the subscribed-to URLS, sorted alphabetically. +func AllURLS() []string { + data.RLock() + defer data.RUnlock() + + urls := make([]string, len(data.Feeds)+len(data.Pages)) + i := 0 + for k := range data.Feeds { + urls[i] = k + i++ + } + for k := range data.Pages { + urls[i] = k + i++ + } + + sort.Strings(urls) + return urls +} + +// Remove removes a subscription from memory and from the disk. +// The URL must be provided. It will do nothing if the URL is +// not an actual subscription. +// +// It returns any errors that occured when saving to disk. +func Remove(u string) error { + data.Lock() + // Just delete from both instead of using a loop to find it + delete(data.Feeds, u) + delete(data.Pages, u) + data.Unlock() + return writeJSON() +}