diff --git a/NOTES.md b/NOTES.md index d72eda9..974e1ad 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,5 +1,13 @@ # Notes +## Temp +- Recalculating `about:feeds` adds pages multiple times to the view +- Only options for feed files is the download modal - there should be a feed modal before that one +- Auto feed detection fails on `ebc.li/atom.xml` + +- TODO: remove all logger lines + + ## Issues - URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL diff --git a/amfora.go b/amfora.go index c63eeca..d99a94a 100644 --- a/amfora.go +++ b/amfora.go @@ -7,6 +7,7 @@ import ( "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/display" "github.com/makeworld-the-better-one/amfora/feeds" + "github.com/makeworld-the-better-one/amfora/logger" ) var ( @@ -16,10 +17,10 @@ var ( ) func main() { - // err := logger.Init() - // if err != nil { - // panic(err) - // } + err := logger.Init() + if err != nil { + panic(err) + } if len(os.Args) > 1 { if os.Args[1] == "--version" || os.Args[1] == "-v" { @@ -38,7 +39,7 @@ func main() { } } - err := config.Init() + err = config.Init() if err != nil { fmt.Fprintf(os.Stderr, "Config error: %v\n", err) os.Exit(1) diff --git a/config/config.go b/config/config.go index c68a62b..8f783f2 100644 --- a/config/config.go +++ b/config/config.go @@ -6,7 +6,6 @@ package config import ( "fmt" - "io" "os" "path/filepath" "runtime" @@ -41,7 +40,6 @@ var bkmkPath string var DownloadsDir string // Feeds -var FeedJSON io.ReadCloser var feedDir string var FeedPath string @@ -158,8 +156,6 @@ func Init() error { if err != nil { return err } - f, _ = os.OpenFile(FeedPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) - FeedJSON = f // *** Downloads paths, setup, and creation *** @@ -234,6 +230,7 @@ func Init() error { viper.SetDefault("a-general.page_max_size", 2097152) viper.SetDefault("a-general.page_max_time", 10) viper.SetDefault("a-general.emoji_favicons", false) + viper.SetDefault("a-general.feeds_popup", true) viper.SetDefault("keybindings.shift_numbers", "!@#$%^&*()") viper.SetDefault("url-handlers.other", "off") viper.SetDefault("cache.max_size", 0) diff --git a/config/default.go b/config/default.go index 6329539..556da67 100644 --- a/config/default.go +++ b/config/default.go @@ -68,6 +68,9 @@ page_max_time = 10 # Whether to replace tab numbers with emoji favicons, which are cached. emoji_favicons = false +# Whether a pop-up appears when viewing a potential feed +feed_popup = true + [auth] # Authentication settings @@ -189,6 +192,8 @@ max_pages = 30 # The maximum number of pages the cache will store # yesno_modal_text # tofu_modal_bg # tofu_modal_text +# feed_modal_bg +# feed_modal_text # input_modal_bg # input_modal_text diff --git a/config/theme.go b/config/theme.go index 6559864..8a34655 100644 --- a/config/theme.go +++ b/config/theme.go @@ -38,6 +38,8 @@ var theme = map[string]tcell.Color{ "yesno_modal_text": tcell.ColorWhite, "tofu_modal_bg": tcell.ColorMaroon, "tofu_modal_text": tcell.ColorWhite, + "feed_modal_bg": tcell.Color61, // xterm:SlateBlue3, #5f5faf + "feed_modal_text": tcell.ColorWhite, "input_modal_bg": tcell.ColorGreen, "input_modal_text": tcell.ColorWhite, diff --git a/default-config.toml b/default-config.toml index 9ac0d21..5855c02 100644 --- a/default-config.toml +++ b/default-config.toml @@ -65,6 +65,9 @@ page_max_time = 10 # Whether to replace tab numbers with emoji favicons, which are cached. emoji_favicons = false +# Whether a pop-up appears when viewing a potential feed +feed_popup = true + [auth] # Authentication settings @@ -186,6 +189,8 @@ max_pages = 30 # The maximum number of pages the cache will store # yesno_modal_text # tofu_modal_bg # tofu_modal_text +# feed_modal_bg +# feed_modal_text # input_modal_bg # input_modal_text diff --git a/display/display.go b/display/display.go index d2b6921..eaf48d3 100644 --- a/display/display.go +++ b/display/display.go @@ -207,6 +207,7 @@ func Init() { }) // Render the default new tab content ONCE and store it for later + // This code is repeated in Reload() newTabContent := getNewTabContent() renderedNewTabContent, newTabLinks := renderer.RenderGemini(newTabContent, textWidth(), leftMargin(), false) newTabPage = structs.Page{ @@ -292,6 +293,13 @@ func Init() { Info("The current page has no content, so it couldn't be downloaded.") } return nil + case tcell.KeyCtrlA: + Feeds(tabs[curTab]) + tabs[curTab].addToHistory("about:feeds") + return nil + case tcell.KeyCtrlX: + go addFeed() + return nil case tcell.KeyRune: // Regular key was sent switch string(event.Rune()) { @@ -575,6 +583,11 @@ func URL(u string) { tabs[curTab].addToHistory("about:bookmarks") return } + if u == "about:feeds" { //nolint:goconst + Feeds(tabs[curTab]) + tabs[curTab].addToHistory("about:feeds") + return + } if u == "about:newtab" { temp := newTabPage // Copy setPage(tabs[curTab], &temp) diff --git a/display/feeds.go b/display/feeds.go index e326c7a..0203746 100644 --- a/display/feeds.go +++ b/display/feeds.go @@ -2,17 +2,25 @@ package display import ( "fmt" + "net/url" + "path" + "strconv" "strings" "time" + "github.com/gdamore/tcell" "github.com/makeworld-the-better-one/amfora/cache" + "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/feeds" + "github.com/makeworld-the-better-one/amfora/logger" "github.com/makeworld-the-better-one/amfora/renderer" "github.com/makeworld-the-better-one/amfora/structs" + "github.com/mmcdole/gofeed" + "github.com/spf13/viper" ) var feedPageRaw = "# Feeds & Pages\n\nUpdates" + strings.Repeat(" ", 80-25) + "[Newest -> Oldest]\n" + - strings.Repeat("-", 80) + "\n\n" + strings.Repeat("-", 80) + "\nSee the help (by pressing ?) for details on how to use this page.\n\n" var feedPageUpdated time.Time @@ -25,6 +33,8 @@ func toLocalDay(t time.Time) time.Time { // Feeds displays the feeds page on the current tab. func Feeds(t *tab) { + logger.Log.Println("display.Feeds called") + // Retrieve cached version if there hasn't been any updates p, ok := cache.GetPage("about:feeds") if feedPageUpdated.After(feeds.LastUpdated) && ok { @@ -74,6 +84,108 @@ func Feeds(t *tab) { feedPageUpdated = time.Now() } -func feedInit() { - // TODO +// openFeedModal displays the "Add feed/page" modal +// It returns whether the user wanted to add the feed/page. +// The tracked arg specifies whether this feed/page is already +// being tracked. +func openFeedModal(validFeed, tracked bool) bool { + logger.Log.Println("display.openFeedModal called") + // Reuses yesNoModal + + if viper.GetBool("a-general.color") { + yesNoModal. + SetBackgroundColor(config.GetColor("feed_modal_bg")). + SetTextColor(config.GetColor("feed_modal_text")) + yesNoModal.GetFrame(). + SetBorderColor(config.GetColor("feed_modal_text")). + SetTitleColor(config.GetColor("feed_modal_text")) + } else { + yesNoModal. + SetBackgroundColor(tcell.ColorBlack). + SetTextColor(tcell.ColorWhite) + yesNoModal.GetFrame(). + SetBorderColor(tcell.ColorWhite). + SetTitleColor(tcell.ColorWhite) + } + if validFeed { + yesNoModal.GetFrame().SetTitle("Feed Tracking") + if tracked { + yesNoModal.SetText("This is already being tracked. Would you like to manually update it?") + } else { + yesNoModal.SetText("Would you like to start tracking this feed?") + } + } else { + yesNoModal.GetFrame().SetTitle("Page Tracking") + if tracked { + yesNoModal.SetText("This is already being tracked. Would you like to manually update it?") + } else { + yesNoModal.SetText("Would you like to start tracking this page?") + } + } + + tabPages.ShowPage("yesno") + tabPages.SendToFront("yesno") + App.SetFocus(yesNoModal) + App.Draw() + + resp := <-yesNoCh + tabPages.SwitchToPage(strconv.Itoa(curTab)) + App.SetFocus(tabs[curTab].view) + App.Draw() + return resp +} + +// getFeedFromPage is like feeds.GetFeed but takes a structs.Page as input. +func getFeedFromPage(p *structs.Page) (*gofeed.Feed, bool) { + parsed, _ := url.Parse(p.URL) + filename := path.Base(parsed.Path) + r := strings.NewReader(p.Raw) + return feeds.GetFeed(p.RawMediatype, filename, r) +} + +// addFeedDirect is only for adding feeds, not pages. +// It's for when you already have a feed and know if it's tracked. +// Use mainly by handleURL because it already did a lot of the work. +// +// Like addFeed, it should be called in a goroutine. +func addFeedDirect(u string, feed *gofeed.Feed, tracked bool) { + logger.Log.Println("display.addFeedDirect called") + + if openFeedModal(true, tracked) { + err := feeds.AddFeed(u, feed) + if err != nil { + Error("Feed Error", err.Error()) + } + } +} + +// addFeed goes through the process of adding a bookmark for the current page. +// It is the high-level way of doing it. It should be called in a goroutine. +func addFeed() { + logger.Log.Println("display.addFeed called") + + t := tabs[curTab] + p := t.page + + if !t.hasContent() { + // It's an about: page, or a malformed one + return + } + + feed, isFeed := getFeedFromPage(p) + tracked := feeds.IsTracked(p.URL) + + if openFeedModal(isFeed, tracked) { + var err error + + if isFeed { + err = feeds.AddFeed(p.URL, feed) + } else { + err = feeds.AddPage(p.URL, strings.NewReader(p.Raw)) + } + + if err != nil { + Error("Feed/Page Error", err.Error()) + } + } } diff --git a/display/help.go b/display/help.go index 1391a07..9a4c8de 100644 --- a/display/help.go +++ b/display/help.go @@ -42,6 +42,8 @@ Ctrl-R, R|Reload a page, discarding the cached version. Ctrl-B|View bookmarks Ctrl-D|Add, change, or remove a bookmark for the current page. Ctrl-S|Save the current page to your downloads. +Ctrl-A|View tracked feeds and pages. +Ctrl-X|Track or update the current feed/page. q, Ctrl-Q|Quit Ctrl-C|Hard quit. This can be used when in the middle of downloading, |for example. diff --git a/display/modals.go b/display/modals.go index fa8ddc3..5a472d9 100644 --- a/display/modals.go +++ b/display/modals.go @@ -150,7 +150,6 @@ func modalInit() { bkmkInit() dlInit() - feedInit() } // Error displays an error on the screen in a modal. diff --git a/display/newtab.go b/display/newtab.go index e1755c5..ff212d0 100644 --- a/display/newtab.go +++ b/display/newtab.go @@ -18,6 +18,7 @@ You can customize this page by creating a gemtext file called newtab.gmi, in Amf Happy browsing! => about:bookmarks Bookmarks +=> about:feeds Feed and Page Tracking => //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 8686fd7..ff41f91 100644 --- a/display/private.go +++ b/display/private.go @@ -14,6 +14,7 @@ import ( "github.com/makeworld-the-better-one/amfora/cache" "github.com/makeworld-the-better-one/amfora/client" "github.com/makeworld-the-better-one/amfora/config" + "github.com/makeworld-the-better-one/amfora/feeds" "github.com/makeworld-the-better-one/amfora/renderer" "github.com/makeworld-the-better-one/amfora/structs" "github.com/makeworld-the-better-one/amfora/webbrowser" @@ -36,6 +37,11 @@ func followLink(t *tab, prev, next string) { t.addToHistory("about:bookmarks") return } + if next == "about:feeds" { + Feeds(t) + t.addToHistory("about:feeds") + return + } if strings.HasPrefix(next, "about:") { Error("Error", "Not a valid 'about:' URL for linking") return @@ -328,6 +334,20 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { t.barText = oldText } t.mode = tabModeDone + + go func(p *structs.Page) { + if b && t.hasContent() && !feeds.IsTracked(s) && viper.GetBool("a-general.feed_popup") { + // The current page might be an untracked feed, and the user wants + // to be notified in such cases. + + feed, isFeed := getFeedFromPage(p) + if isFeed && isValidTab(t) && t.page == p { + // After parsing and track-checking time, the page is still being displayed + addFeedDirect(p.URL, feed, false) + } + } + }(t.page) + return s, b } @@ -341,6 +361,10 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { Bookmarks(t) return ret("about:bookmarks", true) } + if u == "about:feeds" { + Feeds(t) + return ret("about:feeds", true) + } u = normalizeURL(u) u = cache.Redirect(u) diff --git a/feeds/feeds.go b/feeds/feeds.go index 5ac320a..224a797 100644 --- a/feeds/feeds.go +++ b/feeds/feeds.go @@ -10,6 +10,7 @@ import ( urlPkg "net/url" "os" "path" + "reflect" "sort" "strings" "sync" @@ -17,6 +18,7 @@ import ( "github.com/makeworld-the-better-one/amfora/client" "github.com/makeworld-the-better-one/amfora/config" + "github.com/makeworld-the-better-one/amfora/logger" "github.com/makeworld-the-better-one/go-gemini" "github.com/mmcdole/gofeed" ) @@ -38,22 +40,32 @@ var LastUpdated time.Time // Init should be called after config.Init. func Init() error { - defer config.FeedJSON.Close() + f, err := os.Open(config.FeedPath) + if err == nil { + defer f.Close() - dec := json.NewDecoder(config.FeedJSON) - err := dec.Decode(&data) - if err != nil && err != io.EOF { - return fmt.Errorf("feeds json is corrupted: %v", err) //nolint:goerr113 + fi, err := f.Stat() + if err == nil && fi.Size() > 0 { + dec := json.NewDecoder(f) + err = dec.Decode(&data) + if err != nil && err != io.EOF { + return fmt.Errorf("feeds.json is corrupted: %w", err) //nolint:goerr113 + } + } + } 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 feeds.json error: %w", err) //nolint:goerr113 } LastUpdated = time.Now() - go updateAll() return nil } // IsTracked returns true if the feed/page URL is already being tracked. func IsTracked(url string) bool { + logger.Log.Println("feeds.IsTracked called") + data.feedMu.RLock() for u := range data.Feeds { if url == u { @@ -76,6 +88,8 @@ func IsTracked(url string) bool { // GetFeed returns a Feed object and a bool indicating whether the passed // content was actually recognized as a feed. func GetFeed(mediatype, filename string, r io.Reader) (*gofeed.Feed, bool) { + logger.Log.Println("feeds.GetFeed called") + if r == nil { return nil, false } @@ -95,11 +109,14 @@ func GetFeed(mediatype, filename string, r io.Reader) (*gofeed.Feed, bool) { } func writeJSON() error { + logger.Log.Println("feeds.writeJSON called") + writeMu.Lock() defer writeMu.Unlock() f, err := os.OpenFile(config.FeedPath, os.O_WRONLY|os.O_CREATE, 0666) if err != nil { + logger.Log.Println("feeds.writeJSON error", err) return err } defer f.Close() @@ -108,9 +125,14 @@ func writeJSON() error { enc.SetIndent("", " ") data.Lock() + logger.Log.Println("feeds.writeJSON acquired data lock") err = enc.Encode(&data) data.Unlock() + if err != nil { + logger.Log.Println("feeds.writeJSON error", err) + } + return err } @@ -118,6 +140,8 @@ func writeJSON() error { // It can be used to update a feed for a URL, although the package // will handle that on its own. func AddFeed(url string, feed *gofeed.Feed) error { + logger.Log.Println("feeds.AddFeed called") + if feed == nil { panic("feed is nil") } @@ -128,17 +152,20 @@ func AddFeed(url string, feed *gofeed.Feed) error { } data.feedMu.Lock() - data.Feeds[url] = feed - err := writeJSON() - if err != nil { - // Don't use in-memory if it couldn't be saved - delete(data.Feeds, url) - data.feedMu.Unlock() - return ErrSaving - } - data.feedMu.Unlock() + oldFeed, ok := data.Feeds[url] + if !ok || !reflect.DeepEqual(feed, oldFeed) { + // Feeds are different, or there was never an old one - LastUpdated = time.Now() + data.Feeds[url] = feed + data.feedMu.Unlock() + err := writeJSON() + if err != nil { + return ErrSaving + } + LastUpdated = time.Now() + } else { + data.feedMu.Unlock() + } return nil } @@ -146,6 +173,8 @@ func AddFeed(url string, feed *gofeed.Feed) error { // It can be used to update the page as well, although the package // will handle that on its own. func AddPage(url string, r io.Reader) error { + logger.Log.Println("feeds.AddPage called") + if r == nil { return nil } @@ -164,22 +193,23 @@ func AddPage(url string, r io.Reader) error { Hash: newHash, Changed: time.Now().UTC(), } - } - err := writeJSON() - if err != nil { - // Don't use in-memory if it couldn't be saved - delete(data.Pages, url) data.pageMu.Unlock() - return err + err := writeJSON() + if err != nil { + return ErrSaving + } + LastUpdated = time.Now() + } else { + data.pageMu.Unlock() } - data.pageMu.Unlock() - LastUpdated = time.Now() return nil } func updateFeed(url string) error { + logger.Log.Println("feeds.updateFeed called") + res, err := client.Fetch(url) if err != nil { if res != nil { @@ -205,6 +235,8 @@ func updateFeed(url string) error { } func updatePage(url string) error { + logger.Log.Println("feeds.updatePage called") + res, err := client.Fetch(url) if err != nil { if res != nil { @@ -224,6 +256,8 @@ func updatePage(url string) error { // updateAll updates all feeds and pages using workers. // It only returns once all the workers are done. func updateAll() { + logger.Log.Println("feeds.updateAll called") + // TODO: Is two goroutines the right amount? worker := func(jobs <-chan [2]string, wg *sync.WaitGroup) { @@ -246,10 +280,19 @@ func updateAll() { numJobs := len(data.Feeds) + len(data.Pages) jobs := make(chan [2]string, numJobs) + if numJobs == 0 { + data.RUnlock() + return + } + // Start 2 workers, waiting for jobs for w := 0; w < 2; w++ { wg.Add(1) - go worker(jobs, &wg) + go func(i int) { + logger.Log.Println("started worker", i) + worker(jobs, &wg) + logger.Log.Println("ended worker", i) + }(w) } // Get map keys in a slice @@ -277,6 +320,7 @@ func updateAll() { jobs <- [2]string{"page", pageKeys[j-len(feedKeys)]} } } + close(jobs) wg.Wait() } @@ -287,6 +331,8 @@ func updateAll() { // so this function needs to be called again to get updates. // It always returns sorted entries - by post time, from newest to oldest. func GetPageEntries() *PageEntries { + logger.Log.Println("feeds.GetPageEntries called") + var pe PageEntries data.RLock() diff --git a/feeds/structs.go b/feeds/structs.go index a5263a8..b21ae07 100644 --- a/feeds/structs.go +++ b/feeds/structs.go @@ -34,8 +34,8 @@ The time is in RFC 3339 format, preferably in the UTC timezone. // Decoded JSON type jsonData struct { - feedMu sync.RWMutex - pageMu sync.RWMutex + feedMu *sync.RWMutex + pageMu *sync.RWMutex Feeds map[string]*gofeed.Feed `json:"feeds,omitempty"` Pages map[string]*pageJSON `json:"pages,omitempty"` } @@ -69,7 +69,13 @@ type pageJSON struct { Changed time.Time `json:"changed"` // When the latest change happened } -var data jsonData // Global instance of jsonData - loaded from JSON and used +// Global instance of jsonData - loaded from JSON and used +var data = jsonData{ + feedMu: &sync.RWMutex{}, + pageMu: &sync.RWMutex{}, + Feeds: make(map[string]*gofeed.Feed), + Pages: make(map[string]*pageJSON), +} // PageEntry is a single item on a feed page. // It is used both for tracked feeds and pages. diff --git a/renderer/page.go b/renderer/page.go index d7afb80..1319d16 100644 --- a/renderer/page.go +++ b/renderer/page.go @@ -104,31 +104,34 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b if mediatype == "text/gemini" { rendered, links := RenderGemini(utfText, width, leftMargin, proxied) return &structs.Page{ - Mediatype: structs.TextGemini, - URL: url, - Raw: utfText, - Content: rendered, - Links: links, + Mediatype: structs.TextGemini, + RawMediatype: mediatype, + URL: url, + Raw: utfText, + Content: rendered, + Links: links, }, nil } else if strings.HasPrefix(mediatype, "text/") { if mediatype == "text/x-ansi" || strings.HasSuffix(url, ".ans") || strings.HasSuffix(url, ".ansi") { // ANSI return &structs.Page{ - Mediatype: structs.TextAnsi, - URL: url, - Raw: utfText, - Content: RenderANSI(utfText, leftMargin), - Links: []string{}, + Mediatype: structs.TextAnsi, + RawMediatype: mediatype, + URL: url, + Raw: utfText, + Content: RenderANSI(utfText, leftMargin), + Links: []string{}, }, nil } // Treated as plaintext return &structs.Page{ - Mediatype: structs.TextPlain, - URL: url, - Raw: utfText, - Content: RenderPlainText(utfText, leftMargin), - Links: []string{}, + Mediatype: structs.TextPlain, + RawMediatype: mediatype, + URL: url, + Raw: utfText, + Content: RenderPlainText(utfText, leftMargin), + Links: []string{}, }, nil } diff --git a/structs/structs.go b/structs/structs.go index 4dd194c..ce78fe0 100644 --- a/structs/structs.go +++ b/structs/structs.go @@ -18,18 +18,19 @@ const ( // Page is for storing UTF-8 text/gemini pages, as well as text/plain pages. type Page struct { - URL string - Mediatype Mediatype - Raw string // The raw response, as received over the network - Content string // The processed content, NOT raw. Uses cview color tags. It will also have a left margin. - Links []string // URLs, for each region in the content. - Row int // Scroll position - Column int // ditto - Width int // The terminal width when the Content was set, to know when reformatting should happen. - Selected string // The current text or link selected - SelectedID string // The cview region ID for the selected text/link - Mode PageMode - Favicon string + URL string + Mediatype Mediatype // Used for rendering purposes, generalized + RawMediatype string // The actual mediatype sent by the server + Raw string // The raw response, as received over the network + Content string // The processed content, NOT raw. Uses cview color tags. It will also have a left margin. + Links []string // URLs, for each region in the content. + Row int // Scroll position + Column int // ditto + Width int // The terminal width when the Content was set, to know when reformatting should happen. + Selected string // The current text or link selected + SelectedID string // The cview region ID for the selected text/link + Mode PageMode + Favicon string } // Size returns an approx. size of a Page in bytes.