~reesmichael1/roman

7a95a17d006e08e02f638b15caba2a56ce590741 — Michael Rees 4 months ago 3784df6
Allow toggling read status from post selection

This commit changes many of the main types to be ref objects. This
allows their state to be updated from afar. It was necessary here to
let a post be altered from within a termask promptList and have the
changes immediately shown in the list of choices.
M config/config => config/config +1 -0
@@ 8,6 8,7 @@ previous=p
quit=q
top=g
bottom=G
toggle-read=R

[Posts]
# The maximum width for wrapping the posts

M src/romanpkg/config.nim => src/romanpkg/config.nim +1 -0
@@ 49,6 49,7 @@ proc mustLoadConfig*(): RomanConfig {.raises: [].} =
    result.quit = strToChar(dict, "Keyboard", "quit")
    result.goToTop = strToChar(dict, "Keyboard", "top")
    result.goToBottom = strToChar(dict, "Keyboard", "bottom")
    result.toggleRead = strToChar(dict, "Keyboard", "toggle-read")
    result.postWidth = strToInt(dict, "Posts", "max-width")
    result.extractLInks = strToChar(dict, "Posts", "extract-links")
  except:

M src/romanpkg/feeds.nim => src/romanpkg/feeds.nim +37 -21
@@ 19,10 19,10 @@ import FeedNim / [atom, rss]

import errors
import posts
import seqreplace
import termask
import types

from types import Feed, FeedKind, Post, Subscription
from config import conf


const atomNames = ["index.atom", "feed.atom", "atom.xml"]


@@ 84,38 84,54 @@ proc displayFeed*(feed: var Feed) {.raises: [RomanError, InterruptError].} =
  try:
    under(feed.title & "\n", sty = {styleBright})

    var display = initTable[string, string]()
    var titles: seq[string]
    var display = initTable[ref string, ref string]()
    var titles: seq[ref string]

    proc toggleRead(posts: seq[Post]): proc(index: int) {.closure, gcSafe.} =
      return proc(index: int) {.closure, gcSafe.} =
        var post = posts[index]
        post.toggleRead()
        titles[index][] = post.formatTitle()
        display[titles[index]] = titles[index]

    var callbacks = newTable[char, proc(index: int) {.closure, gcSafe.}]()
    callbacks[conf.toggleRead] = toggleRead(feed.posts)

    while true:
      display = initTable[string, string]()
      display = initTable[ref string, ref string]()
      titles = @[]
      for p in feed.posts:
        display[p.title] = p.formatTitle()
        titles.add(p.title)
      let selectedTitle = promptList("Select Post", titles, show = 10,
          displayNames = display)
        var title = new string
        title[] = p.title
        var formatted = new string
        formatted[] = p.formatTitle()
        titles.add(title)
        display[title] = formatted
      let selectedTitle = promptList[ref string, ref string]("Select Post",
          titles, show = 10, displayNames = display, callbacks = callbacks)
      if selectedTitle.isNone():
        raise newException(InterruptError, "no post selected")
      let title = selectedTitle.unsafeGet()
      var post = feed.posts.filterIt(it.title == title)[0]
      var post = feed.posts.filterIt(it.title == title[])[0]
      displayPost(post)

      # Replace the copy of the post in feed.posts
      # with one that is marked as read
      let oldPost = post
      post.markAsRead()
      feed.posts.replace(oldPost, post)
      feed.updateUnread()

  except IOError as e:
    raise newException(RomanError, "could not write to the terminal: " & e.msg)
  except ValueError as e:
    raise newException(RomanError, "could not set terminal style: " & e.msg)
  except InterruptError as e:
    # Update the read/unread counts in the feed
    # before returning to feed selection
    feed.updateUnread()
    raise newException(InterruptError, e.msg)
  except Exception as e:
    raise newException(RomanError, "error loading callbacks table: " & e.msg)


proc buildFeedFromContentAndSub(content: string, sub: Subscription): ref Feed {.
proc buildFeedFromContentAndSub(content: string, sub: Subscription): Feed {.
    raises: [RomanError].} =
  result = new Feed
  result = new(Feed)
  try:
    var feedKind = sub.feedKind
    if feedKind == Unknown:


@@ 141,7 157,7 @@ proc buildFeedFromContentAndSub(content: string, sub: Subscription): ref Feed {.
      raise newException(RomanError,
        "could not identify feed as RSS or Atom, please use --type option")
    result.kind = feedKind
    result[].updateUnread()
    result.updateUnread()
  except ValueError:
    raise newException(RomanError, sub.url & " is not a valid URL")
  except:


@@ 154,7 170,7 @@ proc getFeed*(sub: Subscription): Feed {.raises: [RomanError].} =
  try:
    var client = newHttpClient()
    let content = client.getContent(sub.url)
    result = buildFeedFromContentAndSub(content, sub)[]
    result = buildFeedFromContentAndSub(content, sub)
  except Exception as e:
    raise newException(RomanError, e.msg)



@@ 186,7 202,7 @@ proc getFeeds*(subs: seq[Subscription]): seq[Feed] {.raises: [RomanError].} =

    else:
      for ix, content in contents:
        result[ix] = buildFeedFromContentAndSub(content, subs[ix])[]
        result[ix] = buildFeedFromContentAndSub(content, subs[ix])

  except:
    raise newException(RomanError, "error in loading feeds: " &

M src/romanpkg/main.nim => src/romanpkg/main.nim +1 -10
@@ 8,7 8,6 @@ import feeds
import subscriptions
import termask

import seqreplace
import types




@@ 82,19 81,11 @@ proc runMainPath() {.raises: [RomanError, InterruptError].} =
      displayFeed(feed)
    else:
      feed = chooseFeed(feeds)
      # Keep track of the originally selected feed
      # so that we can replace it with the updated unread counts later
      var oldFeed = feed
      try:
        displayFeed(feed)
      except InterruptError:
        # These errors are coming from declining to select a post
        # This error comes from declining to select a post
        # Instead of exiting, return to the feed selection
        try:
          feeds.replace(oldFeed, feed)
        except KeyError as e:
          raise newException(RomanError,
            "could not find feed in list of feeds: " & e.msg)
        continue



M src/romanpkg/posts.nim => src/romanpkg/posts.nim +31 -1
@@ 63,7 63,7 @@ proc displayLinks(p: Post) {.raises: [RomanError].} =
  except IOError, ValueError, Exception:
    let msg = getCurrentExceptionMsg()
    raise newException(RomanError, "could not parse post HTML: " & msg)
  var links = @[PostLink(text: "Original Post", url: p.link)]
  var links: seq[PostLink] = @[PostLink(text: "Original Post", url: p.link)]

  # Some sources use a single link as the post content
  if html.tag == "a":


@@ 124,6 124,7 @@ proc displayPost*(p: Post) {.raises: [RomanError].} =


proc postFromAtomEntry*(entry: AtomEntry): Post {.raises: [RomanError].} =
  result = new(Post)
  result.title = entry.title
  result.rendered = extractBody(entry.content)
  result.raw = entry.content


@@ 139,6 140,7 @@ proc postFromAtomEntry*(entry: AtomEntry): Post {.raises: [RomanError].} =


proc postFromRSSItem*(item: RSSItem): Post {.raises: [RomanError].} =
  result = new(Post)
  result.title = item.title
  result.rendered = extractBody(item.description)
  result.raw = item.description


@@ 165,3 167,31 @@ proc markAsRead*(p: var Post) {.raises: [RomanError].} =
  except IOError:
    raise newException(RomanError,
      "could not save " & p.guid & " in the read-posts file")


proc markAsUnread(p: var Post) {.raises: [RomanError].} =
  try:
    let filename = getPostReadFile()
    let content = filename.readFile()
    let postLines = content.splitLines()

    # TODO: make a backup of the contents to write in the event of an exception
    var f: File
    if f.open(filename, fmWrite):
      defer: f.close()
      for line in postLines:
        if p.guid != line:
          f.writeLine(line)
      p.read = false
  except IOError as e:
    raise newException(RomanError,
      "could not open posts read file: " & e.msg)



proc toggleRead*(p: var Post) {.raises: [RomanError].} =
  if p.read:
    p.markAsUnread()

  else:
    p.markAsRead()

D src/romanpkg/seqreplace.nim => src/romanpkg/seqreplace.nim +0 -13
@@ 1,13 0,0 @@
import sequtils


proc replace*[T](s: var seq[T], old: T, updated: T) =
  var elementIx = -1
  for ix, element in s:
    if element == old:
      elementIx = ix
  if elementIx == -1:
    raise newException(KeyError, "could not find post in feed post list")

  s.keepIf(proc (element: T): bool = element != old)
  s.insert(@[updated], elementIx)

M src/romanpkg/subscriptions.nim => src/romanpkg/subscriptions.nim +2 -1
@@ 79,6 79,7 @@ proc addSubscriptionToSubsFile*(url: string, feedKind: FeedKind) {.


proc subscriptionFromLine(line: string): Subscription {.raises: [RomanError].} =
  result = new(Subscription)
  let fields = try:
    line.split(",").mapIt(unescape(it))
  except ValueError:


@@ 109,7 110,7 @@ proc removeSubscriptionFromSubsFile*(sub: Subscription) {.
      for line in subsLines:
        if not line.isComment() and line.len > 0:
          let s = subscriptionFromLine(line)
          if s != sub:
          if s[] != sub[]:
            f.writeLine(line)
  except IOError as e:
    raise newException(RomanError,

M src/romanpkg/termask.nim => src/romanpkg/termask.nim +13 -4
@@ 103,9 103,11 @@ proc showArgPages[T](sliceIx: int, argSlices: seq[seq[T]]) {.raises: [].} =
  echo "\n[", sliceIx + 1, "/", argSlices.len, "]"


proc promptList*[T](question: string, args: openarray[T],
    displayNames: Table[T, string] = initTable[T, string](),
        show: int = -1): Option[T] {.raises: [ValueError, IOError].} =
proc promptList*[T, U](question: string, args: openarray[T],
    displayNames: Table[T, U] = initTable[T, U](),
    show: int = -1,
    callbacks: TableRef[char, proc(index: int) {.gcSafe, closure.}] = nil):
      Option[T] {.raises: [ValueError, IOError].} =
  var
    selectedIx = 0
    selectionMade = false


@@ 151,7 153,7 @@ proc promptList*[T](question: string, args: openarray[T],
    for ix, arg in currentArgs:
      var shown: string
      if arg in displayNames:
        shown = displayNames[arg]
        shown = $displayNames[arg]
      else:
        shown = $arg
      if ix == selectedIx:


@@ 232,6 234,13 @@ proc promptList*[T](question: string, args: openarray[T],
        for _ in (selectedIx mod currentArgs.len)..currentArgs.len:
          cursorDown(stdout)
        raise newException(ValueError, "keyboard interrupt")
      elif callbacks != nil and c in callbacks:
        try:
          callbacks[c](sliceIx * show + selectedIx)
          break
        except Exception as e:
          echo "error in callback: " & e.msg
          quit 1
      else: break

  for i in 0..<currentArgs.len:

M src/romanpkg/types.nim => src/romanpkg/types.nim +9 -4
@@ 11,12 11,13 @@ type
    quit*: char
    goToTop*: char
    goToBottom*: char
    toggleRead*: char
    postWidth*: int
    extractLinks*: char

  # Use our own Post type instead of RSSItem
  # to show metadata we collect (e.g., read/unread)
  Post* = object
  Post* = ref object
    title*: string
    link*: string
    rendered*: string


@@ 28,18 29,18 @@ type
  FeedKind* = enum
    RSS = "RSS", Atom = "Atom", Unknown

  Feed* = object
  Feed* = ref object
    kind*: FeedKind
    posts*: seq[Post]
    title*: string
    unreadPosts*: int

  Subscription* = object
  Subscription* = ref object
    url*: string
    name*: string
    feedKind*: FeedKind

  PostLink* = object
  PostLink* = ref object
    text*: string
    url*: string



@@ 61,3 62,7 @@ proc hash*(sub: Subscription): Hash =
proc hash*(feed: Feed): Hash =
  result = feed.kind.hash !& feed.title.hash !& feed.unreadPosts.hash
  result = !$result


proc `$`*[T](input: ref T): string = $(input[])
proc hash*[T](input: ref T): Hash = hash(input[])