acme-misc/feed/acme.go

608 lines
14 KiB
Go

package main
import (
"fmt"
"strings"
"sync"
"9fans.net/go/acme"
)
// ---------------------------------------------------------------------------
// Main window
// ---------------------------------------------------------------------------
var mainDone = make(chan struct{})
var mainWin *acme.Win
var mainSnapshot []mainRow // guarded by mainSnapshotMu
var mainSnapshotMu sync.RWMutex
// openMainWindow opens the /+Feeds window and starts its event loop.
func openMainWindow(store *FeedStore) error {
w, err := acme.New()
if err != nil {
return err
}
mainWin = w
w.Name("/+Feeds")
w.Write("tag", []byte("Get Put Pins Refresh Unsub Sub "))
refreshMainWindow(w, store)
go handleMainWindow(w, store)
return nil
}
func handleMainWindow(w *acme.Win, store *FeedStore) {
defer w.CloseFiles()
defer close(mainDone)
// snapshot of entries for middle-click resolution
var snapshot []mainRow
snapshot = buildSnapshot(store)
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
cmd := strings.TrimSpace(string(e.Text))
arg := strings.TrimSpace(string(e.Arg))
// Accept "Cmd argument" typed inline in the tag bar as an
// alternative to chording, since chord e.Arg is unreliable
// across acme builds and mouse configurations.
if fields := strings.Fields(cmd); len(fields) > 1 {
cmd = fields[0]
if arg == "" {
arg = strings.Join(fields[1:], " ")
}
}
switch cmd {
case "Get":
snapshot = buildSnapshot(store)
refreshMainWindow(w, store)
case "Put":
body, _ := w.ReadAll("body")
applyReadMarks(w, store, &snapshot, string(body))
case "Pins":
openPinsWindow(store)
case "Refresh":
if err := doRefreshAll(store); err != nil {
w.Err(err.Error())
}
snapshot = buildSnapshot(store)
refreshMainWindow(w, store)
case "Sub":
if arg == "" {
w.Err("Sub: chord a URL first")
continue
}
if err := doSubscribe(store, arg, ""); err != nil {
w.Err(fmt.Sprintf("Sub: %v", err))
continue
}
snapshot = buildSnapshot(store)
refreshMainWindow(w, store)
case "Unsub":
if arg != "" {
if err := store.unsubscribe(arg); err != nil {
w.Err(err.Error())
continue
}
snapshot = buildSnapshot(store)
refreshMainWindow(w, store)
} else {
openUnsubWindow(store, func() {
snapshot = buildSnapshot(store)
refreshMainWindow(w, store)
})
}
default:
w.WriteEvent(e)
}
case 'l', 'L':
text := strings.TrimSpace(string(e.Text))
// Middle-click on a line number opens the entry.
if idx := parseLineIndex(text); idx >= 1 && idx <= len(snapshot) {
row := snapshot[idx-1]
openEntryWindow(store, row.slug, row.ef)
store.markRead(row.slug, row.ef.entry.GUID)
// Annotate the line in place rather than refreshing the
// whole window, so the user doesn't lose their place.
line := fmt.Sprintf("%4d %-20s %-20s %s [read]\n",
idx,
formatTS(row.ef.entry.Timestamp),
row.slug,
row.ef.entry.Title,
)
w.Addr("%d", idx)
w.Write("data", []byte(line))
w.Ctl("clean")
} else {
w.WriteEvent(e)
}
default:
w.WriteEvent(e)
}
}
}
// annotateReadInMainWindow finds the entry by slug+guid in the current
// mainSnapshot and annotates its line with "[read]" in place.
func annotateReadInMainWindow(slug, guid string) {
if mainWin == nil {
return
}
mainSnapshotMu.RLock()
snap := mainSnapshot
mainSnapshotMu.RUnlock()
for i, row := range snap {
if row.slug == slug && row.ef.entry.GUID == guid {
idx := i + 1
line := fmt.Sprintf("%4d %-20s %-20s %s [read]\n",
idx,
formatTS(row.ef.entry.Timestamp),
row.slug,
row.ef.entry.Title,
)
mainWin.Addr("%d", idx)
mainWin.Write("data", []byte(line))
mainWin.Ctl("clean")
return
}
}
}
func refreshMainWindow(w *acme.Win, store *FeedStore) {
rows := store.globalIndex(true) // unread only
var sb strings.Builder
for i, row := range rows {
e := row.ef.entry
fmt.Fprintf(&sb, "%4d %-20s %-20s %s\n",
i+1,
formatTS(e.Timestamp),
row.slug,
e.Title,
)
}
if len(rows) == 0 {
sb.WriteString("(no unread entries)\n")
}
w.Addr(",")
w.Write("data", []byte(sb.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
// mainRow maps display index → entry.
type mainRow struct {
slug string
ef entryFile
}
func buildSnapshot(store *FeedStore) []mainRow {
rows := store.globalIndex(true)
snap := make([]mainRow, len(rows))
for i, r := range rows {
snap[i] = mainRow{slug: r.slug, ef: r.ef}
}
mainSnapshotMu.Lock()
mainSnapshot = snap
mainSnapshotMu.Unlock()
return snap
}
// ---------------------------------------------------------------------------
// Pins window
// ---------------------------------------------------------------------------
func openPinsWindow(store *FeedStore) {
w, err := acme.New()
if err != nil {
return
}
w.Name("/+Pins")
w.Write("tag", []byte("Get Put "))
var snapshot []mainRow
snapshot = buildPinsSnapshot(store)
refreshPinsWindow(w, store, snapshot)
go func() {
defer w.CloseFiles()
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
cmd := strings.TrimSpace(string(e.Text))
switch cmd {
case "Get":
snapshot = buildPinsSnapshot(store)
refreshPinsWindow(w, store, snapshot)
case "Put":
body, _ := w.ReadAll("body")
applyPinsEdits(store, &snapshot, string(body))
refreshPinsWindow(w, store, snapshot)
default:
w.WriteEvent(e)
}
case 'l', 'L':
text := strings.TrimSpace(string(e.Text))
if idx := parseLineIndex(text); idx >= 1 && idx <= len(snapshot) {
row := snapshot[idx-1]
openEntryWindow(store, row.slug, row.ef)
} else {
w.WriteEvent(e)
}
default:
w.WriteEvent(e)
}
}
}()
}
func buildPinsSnapshot(store *FeedStore) []mainRow {
rows := store.globalPinned()
snap := make([]mainRow, len(rows))
for i, r := range rows {
snap[i] = mainRow{slug: r.slug, ef: r.ef}
}
return snap
}
func refreshPinsWindow(w *acme.Win, store *FeedStore, snapshot []mainRow) {
var sb strings.Builder
for i, row := range snapshot {
e := row.ef.entry
fmt.Fprintf(&sb, "%4d %-20s %-20s %s\n",
i+1,
formatTS(e.Timestamp),
row.slug,
e.Title,
)
}
if len(snapshot) == 0 {
sb.WriteString("(no pinned entries)\n")
}
w.Addr(",")
w.Write("data", []byte(sb.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
// ---------------------------------------------------------------------------
// Unsub window
// ---------------------------------------------------------------------------
func openUnsubWindow(store *FeedStore, onChanged func()) {
w, err := acme.New()
if err != nil {
return
}
w.Name("/+Unsub")
w.Write("tag", []byte("Get "))
refreshUnsubWindow(w, store)
go func() {
defer w.CloseFiles()
var lines []string
lines = buildUnsubLines(store)
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
if strings.TrimSpace(string(e.Text)) == "Get" {
lines = buildUnsubLines(store)
refreshUnsubWindow(w, store)
} else {
w.WriteEvent(e)
}
case 'l', 'L':
text := strings.TrimSpace(string(e.Text))
fields := strings.Fields(text)
if len(fields) == 0 {
w.WriteEvent(e)
continue
}
// Middle-click: text is the slug (first field of the line).
slug := fields[0]
// Verify it's a known slug.
found := false
for _, l := range lines {
if strings.Fields(l)[0] == slug {
found = true
break
}
}
if !found {
w.WriteEvent(e)
continue
}
if err := store.unsubscribe(slug); err != nil {
w.Err(err.Error())
continue
}
lines = buildUnsubLines(store)
refreshUnsubWindow(w, store)
if onChanged != nil {
onChanged()
}
default:
w.WriteEvent(e)
}
}
}()
}
func buildUnsubLines(store *FeedStore) []string {
store.mu.RLock()
defer store.mu.RUnlock()
lines := make([]string, len(store.subs))
for i, sub := range store.subs {
lines[i] = fmt.Sprintf("%-30s %s", sub.Slug, sub.URL)
}
return lines
}
func refreshUnsubWindow(w *acme.Win, store *FeedStore) {
lines := buildUnsubLines(store)
var sb strings.Builder
if len(lines) == 0 {
sb.WriteString("(no subscriptions)\n")
} else {
for _, l := range lines {
sb.WriteString(l + "\n")
}
}
w.Addr(",")
w.Write("data", []byte(sb.String()))
w.Ctl("clean")
}
// ---------------------------------------------------------------------------
// Entry window
// ---------------------------------------------------------------------------
// populateEntryWindow writes entry content into an existing window.
func populateEntryWindow(w *acme.Win, store *FeedStore, slug string, ef entryFile) {
e := ef.entry
w.Name("/+Feeds/%s/%s", slug, ef.filename)
store.mu.RLock()
sub := store.subscriptionBySlug(slug)
store.mu.RUnlock()
feedTitle := slug
if sub != nil {
feedTitle = sub.Title
}
var sb strings.Builder
fmt.Fprintf(&sb, "%s\n", e.Title)
fmt.Fprintf(&sb, "%s\n", e.URL)
fmt.Fprintf(&sb, "%s | %s\n", feedTitle, e.Timestamp)
if e.Summary != "" {
fmt.Fprintf(&sb, "\n%s\n", e.Summary)
}
w.Addr(",")
w.Write("data", []byte(sb.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
func openEntryWindow(store *FeedStore, slug string, ef entryFile) {
w, err := acme.New()
if err != nil {
return
}
w.Write("tag", []byte("Read Unread Pin Unpin Prev Next "))
populateEntryWindow(w, store, slug, ef)
curSlug := slug
curEF := ef
go func() {
defer w.CloseFiles()
for ev := range w.EventChan() {
switch ev.C2 {
case 'x', 'X':
switch strings.TrimSpace(string(ev.Text)) {
case "Read":
store.markRead(curSlug, curEF.entry.GUID)
case "Unread":
store.unmarkRead(curSlug, curEF.entry.GUID)
case "Pin":
store.pin(curSlug, curEF.entry.GUID)
case "Unpin":
store.unpin(curSlug, curEF.entry.GUID)
case "Prev", "Next":
files := store.feedFiles(curSlug)
idx := -1
for i, f := range files {
if f.filename == curEF.filename {
idx = i
break
}
}
cmd := strings.TrimSpace(string(ev.Text))
var target *entryFile
if cmd == "Prev" && idx >= 0 && idx+1 < len(files) {
ef2 := files[idx+1]
target = &ef2
} else if cmd == "Next" && idx > 0 {
ef2 := files[idx-1]
target = &ef2
}
if target != nil {
curEF = *target
store.markRead(curSlug, curEF.entry.GUID)
annotateReadInMainWindow(curSlug, curEF.entry.GUID)
populateEntryWindow(w, store, curSlug, curEF)
}
default:
w.WriteEvent(ev)
}
case 'l', 'L':
// Intercept right-click on URLs: fetch as Markdown instead
// of plumbing to the browser.
text := strings.TrimSpace(string(ev.Text))
if strings.HasPrefix(text, "http://") || strings.HasPrefix(text, "https://") {
go openContentWindow(store, text, curSlug, curEF.filename)
continue // consumed — do not plumb
}
w.WriteEvent(ev)
default:
w.WriteEvent(ev)
}
}
}()
}
// ---------------------------------------------------------------------------
// Content window
// ---------------------------------------------------------------------------
func openContentWindow(store *FeedStore, url, slug, entryFilename string) {
w, err := acme.New()
if err != nil {
return
}
w.Name("/+Feeds/%s/%s/content", slug, entryFilename)
w.Write("tag", []byte("Get "))
writeContent := func(md string) {
w.Addr(",")
w.Write("data", []byte(md))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
// Serve from cache if available; otherwise fetch and cache.
if md, ok := store.loadCachedContent(slug, entryFilename); ok {
writeContent(md)
} else {
w.Write("body", []byte("fetching…\n"))
w.Ctl("clean")
md, err := fetchMarkdown(url)
if err != nil {
w.Addr(",")
w.Write("data", []byte(fmt.Sprintf("error: %v\n", err)))
w.Ctl("clean")
} else {
store.saveCachedContent(slug, entryFilename, md)
writeContent(md)
}
}
go func() {
defer w.CloseFiles()
for ev := range w.EventChan() {
switch {
case (ev.C2 == 'x' || ev.C2 == 'X') && strings.TrimSpace(string(ev.Text)) == "Get":
w.Addr(",")
w.Write("data", []byte("fetching…\n"))
w.Ctl("clean")
md, err := fetchMarkdown(url)
if err != nil {
w.Addr(",")
w.Write("data", []byte(fmt.Sprintf("error: %v\n", err)))
w.Ctl("clean")
} else {
store.saveCachedContent(slug, entryFilename, md)
writeContent(md)
}
default:
w.WriteEvent(ev)
}
}
}()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// applyPinsEdits processes the Pins window body after Put.
// Lines prefixed with "- N" unpin entry N.
func applyPinsEdits(store *FeedStore, snapshot *[]mainRow, body string) {
for _, line := range strings.Split(body, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "- ") {
continue
}
fields := strings.Fields(trimmed[2:])
if len(fields) == 0 {
continue
}
idx := parseLineIndex(fields[0])
if idx < 1 || idx > len(*snapshot) {
continue
}
row := (*snapshot)[idx-1]
store.unpin(row.slug, row.ef.entry.GUID)
}
*snapshot = buildPinsSnapshot(store)
}
func parseLineIndex(s string) int {
s = strings.TrimSpace(s)
var n int
fmt.Sscanf(s, "%d", &n)
return n
}
// applyEdits processes the window body after Put.
// Recognised line prefixes:
//
// r N — mark entry N read
// u N — mark entry N unread
// + N — pin entry N
// - N — unpin entry N
func applyReadMarks(w *acme.Win, store *FeedStore, snapshot *[]mainRow, body string) {
for _, line := range strings.Split(body, "\n") {
trimmed := strings.TrimSpace(line)
if len(trimmed) < 2 || trimmed[1] != ' ' {
continue
}
verb := trimmed[0]
if verb != 'r' && verb != 'u' && verb != '+' && verb != '-' {
continue
}
fields := strings.Fields(trimmed[2:])
if len(fields) == 0 {
continue
}
idx := parseLineIndex(fields[0])
if idx < 1 || idx > len(*snapshot) {
continue
}
row := (*snapshot)[idx-1]
switch verb {
case 'r':
store.markRead(row.slug, row.ef.entry.GUID)
case 'u':
store.unmarkRead(row.slug, row.ef.entry.GUID)
case '+':
store.pin(row.slug, row.ef.entry.GUID)
case '-':
store.unpin(row.slug, row.ef.entry.GUID)
}
}
*snapshot = buildSnapshot(store)
refreshMainWindow(w, store)
}