1
0
mirror of https://github.com/makew0rld/amfora.git synced 2024-06-19 19:25:24 +00:00

🚧 Adding & displaying feeds/pages works

See NOTES.md for things still left
This commit is contained in:
makeworld 2020-11-17 20:56:15 -05:00
parent 781b89af61
commit cf5e65f75a
16 changed files with 293 additions and 68 deletions

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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())
}
}
}

View File

@ -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.

View File

@ -150,7 +150,6 @@ func modalInit() {
bkmkInit()
dlInit()
feedInit()
}
// Error displays an error on the screen in a modal.

View File

@ -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]

View File

@ -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)

View File

@ -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()

View File

@ -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.

View File

@ -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
}

View File

@ -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.