~reesmichael1/roman

c7a24774e67ad0145f9255d2b3718ba38a6b7413 — Michael Rees 8 months ago b3cd187 + ccf1aca
Merge branch 'master' into threads

Lots of upstream changes have been made since I last worked on threads,
so let's make them match before applying the latest patch.
M README.md => README.md +4 -0
@@ 22,6 22,10 @@ Then, when you run `roman`, you will be able to select a feed and post to view. 

`roman` is still in very early development. Several improvements are planned!

## Configuration

You will need to copy the `config/config` file in this repository to `roman/config` within your platform's standard config directory. You can change the values in the file to your taste. 

## Platforms

Cross-platform support is fully intended, but for now, `roman` is only tested on Linux. (Most of the code should work just fine, but some filepaths that are generated are currently Linux-only. A patch would be happily accepted!)

A config/config => config/config +16 -0
@@ 0,0 1,16 @@
[Keyboard]
# The config values for keyboard navigation. 
# By default, we use vi keybindings.
down=j
up=k
next=n
previous=p
quit=q
top=g
bottom=G

[Posts]
# The maximum width for wrapping the posts
# If this is greater than the terminal width, 
# words will be wrapped to match the terminal isntead.
max-width=80

M roman.nimble => roman.nimble +1 -0
@@ 17,6 17,7 @@ requires "fab >= 0.4"
requires "feednim >= 0.2"
requires "nim >= 0.20.0"
requires "nimpy >= 0.1"
requires "pager >= 0.1"




A src/romanpkg/config.nim => src/romanpkg/config.nim +54 -0
@@ 0,0 1,54 @@
import parsecfg
import sequtils
import strutils

import errors
import paths

from types import RomanConfig


proc strToChar(config: Config, section: string, key: string): char {.
    raises: [RomanError].} =
  try:
    let s = config.getSectionValue(section, key)
    if s.len != 1:
      raise newException(RomanError, "expected single char for " & section &
          "." & key & ", got '" & s & "'")
    return toSeq(s.items)[0]
  except KeyError:
    raise newException(RomanError,
      "missing config value for " & section & "." & key)


proc strToInt(config: Config, section: string, key: string): int {.
    raises: [RomanError].} =
  try:
    let s = config.getSectionValue(section, key)
    result = parseInt(s)
  except KeyError:
    raise newException(RomanError,
      "missing config value for " & section & "." & key)
  except ValueError:
    raise newException(RomanError,
      "invalid value for " & section & "." & key & ", expected int")


proc mustLoadConfig*(): RomanConfig {.raises: [].} =
  try:
    let path = getConfigFilePath()
    let dict = loadConfig(path)
    result.down = strToChar(dict, "Keyboard", "down")
    result.up = strToChar(dict, "Keyboard", "up")
    result.next = strToChar(dict, "Keyboard", "next")
    result.previous = strToChar(dict, "Keyboard", "previous")
    result.quit = strToChar(dict, "Keyboard", "quit")
    result.goToTop = strToChar(dict, "Keyboard", "top")
    result.goToBottom = strToChar(dict, "Keyboard", "bottom")
    result.postWidth = strToInt(dict, "Posts", "max-width")
  except:
    echo "error loading config file: " & getCurrentExceptionMsg()
    quit(1)


var conf* = mustLoadConfig()

M src/romanpkg/errors.nim => src/romanpkg/errors.nim +6 -2
@@ 1,2 1,6 @@
type RomanError* =
  object of Exception
type
  RomanError* =
    object of Exception
  InterruptError* =
    object of Exception


M src/romanpkg/feeds.nim => src/romanpkg/feeds.nim +23 -26
@@ 1,3 1,4 @@
import options
import sequtils
import tables
import terminal


@@ 11,12 12,7 @@ import errors
import posts
import termask


type
  Feed* = object
    posts*: seq[Post]
    title*: string
    unreadPosts*: int
from types import Feed, Post, Subscription


proc updateUnread*(feed: var Feed) {.raises: [].} =


@@ 28,7 24,7 @@ proc formatTitle*(feed: Feed): string {.raises: [].} =
  feed.title & " [" & $feed.unreadPosts & "/" & $feed.posts.len & "]"


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



@@ 38,34 34,35 @@ proc displayFeed*(feed: var Feed) {.raises: [RomanError].} =
      display[p.title] = p.formatTitle()
      titles.add(p.title)

    let title = promptList("Select Post", titles, show = 10,
        displayNames = display)
    let post = filter(feed.posts, proc(p: Post): bool = p.title == title)[0]
    bold(post.title)
    echo post.content, "\n\n"
    post.markAsRead()
    feed.updateUnread()
    while true:
      let selectedTitle = promptList("Select Post", titles, show = 10,
          displayNames = display)
      if selectedTitle.isNone():
        raise newException(InterruptError, "no post selected")
      let title = selectedTitle.unsafeGet()
      let post = filter(feed.posts, proc(p: Post): bool = p.title == title)[0]
      displayPost(post)
      post.markAsRead()
      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)


proc getFeed*(url: string): Feed {.raises: [RomanError], thread.} =
proc getFeed*(sub: Subscription): Feed {.raises: [RomanError].} =
  try:
    let rssFeed = FeedNim.getRSS(url)
    result.title = rssFeed.title
    for item in rssFeed.items:
      result.posts.add(postFromRSSItem(item))
    # result.posts = map(rssFeed.items,
    #   proc (i: RSSItem): Post = postFromRSSItem(i))
    let rssFeed = FeedNim.getRSS(sub.url)
    if sub.name.len > 0:
      result.title = sub.name
    else:
      result.title = rssFeed.title
    result.posts = map(rssFeed.items,
      proc (i: RSSItem): Post = postFromRSSItem(i))
    result.updateUnread()
  except ValueError:
    raise newException(RomanError, url & " is not a valid URL")
    raise newException(RomanError, sub.url & " is not a valid URL")
  except:
    let msg = getCurrentExceptionMsg()
    raise newException(RomanError,
      "error while accessing " & url & ": " & msg)


# proc getFeedAsync*(url: string): Future[Feed] {.async, raises: [].} =
      "error while accessing " & sub.url & ": " & msg)

M src/romanpkg/htmlextractor.nim => src/romanpkg/htmlextractor.nim +5 -3
@@ 5,11 5,13 @@ import nimpy

import errors

from config import conf


proc extractBody*(body: string): string {.raises: [RomanError].} =
  try:
    result = body
    # let html2text = pyImport("html2text").HTML2Text()
    # result = html2text.handle(body).to(string)
    let width = min(conf.postWidth, terminalWidth())
    let html2text = pyImport("html2text").HTML2Text(bodywidth = width)
    result = html2text.handle(body).to(string)
  except:
    raise newException(RomanError, "could not use Python module html2text")

M src/romanpkg/main.nim => src/romanpkg/main.nim +32 -13
@@ 1,4 1,5 @@
import os
import options
import sequtils
import tables
import threadpool


@@ 8,37 9,52 @@ import feeds
import subscriptions
import termask

from types import Feed, Subscription

proc chooseFeed(feeds: seq[Feed]): Feed {.raises: [RomanError].} =

proc chooseFeed(feeds: seq[Feed]): Feed {.raises: [RomanError,
    InterruptError].} =
  var displayNames = initTable[string, string]()
  var titles: seq[string]
  for feed in feeds:
    titles.add(feed.title)
    displayNames[feed.title] = feed.formatTitle()
  try:
    let name = promptList("Select Feed", toSeq(displayNames.keys),
    let selectedName = promptList("Select Feed", titles,
        displayNames = displayNames, show = 10)
    if selectedName.isNone:
      raise newException(InterruptError, "no feed selected")
    let name = selectedName.unsafeGet()
    result = filter(feeds, proc(f: Feed): bool = f.title == name)[0]
  except ValueError as e:
    raise newException(RomanError, e.msg)
  except IOError as e:
    raise newException(RomanError, e.msg)
  except ValueError, IOError:
    raise newException(RomanError, getCurrentExceptionMsg())


proc runMainPath() {.raises: [RomanError].} =
proc runMainPath() {.raises: [RomanError, InterruptError].} =
  let subs = getSubscriptions()
  var feeds: seq[Feed]
  var feed: Feed
  if subs.len == 0:
    echo "You aren't subscribed to any feeds yet! ",
      "Use --subscribe [url] to add some."
    return
  elif subs.len == 1:
    feed = getFeed(subs[0].url)
    feed = getFeed(subs[0])
    feeds = @[feed]
  else:
    let feedResults = map(subs, proc(s: Subscription): FlowVar[
        Feed] = spawn getFeed(s.url))
    let feeds = map(feedResults, proc(fv: FlowVar[Feed]): Feed = ^fv)
    feed = chooseFeed(feeds)
    feeds = map(subs, getFeed)

  displayFeed(feed)
  while true:
    if feeds.len == 1:
      displayFeed(feed)
    else:
      feed = chooseFeed(feeds)
      try:
        displayFeed(feed)
      except InterruptError:
        # These errors are coming from declining to select a post
        # Instead of exiting, return to the feed selection
        continue


proc main*(subscribeURL: string = "") {.raises: [].} =


@@ 50,3 66,6 @@ proc main*(subscribeURL: string = "") {.raises: [].} =
  except RomanError as e:
    echo "error: ", e.msg
    quit(1)

  except InterruptError:
    quit(0)

M src/romanpkg/paths.nim => src/romanpkg/paths.nim +10 -6
@@ 3,6 3,10 @@ import os
import errors


proc getConfigFilePath*(): string {.raises: [].} =
  joinPath(getConfigDir(), "roman", "config")


proc getSubsFilePath*(): string {.raises: [].} =
  joinPath(getConfigDir(), "roman", "subscriptions")



@@ 11,12 15,12 @@ proc initConfigDir*() {.raises: [RomanError].} =
  let configDir = joinPath(getConfigDir(), "roman")
  try:
    if not existsOrCreateDir(configDir):
      let subsFile = joinPath(configDir, "subscriptions")
      writeFile(subsFile, "")
  except OSError as e:
    raise newException(RomanError, e.msg)
  except IOError as e:
    raise newException(RomanError, e.msg)
      let config = getConfigFilePath()
      let subs = getSubsFilePath()
      writeFile(config, "")
      writeFile(subs, "")
  except IOError, OSError:
    raise newException(RomanError, getCurrentExceptionMsg())


proc getShareDir*(): string {.raises: [].} =

M src/romanpkg/posts.nim => src/romanpkg/posts.nim +34 -12
@@ 1,28 1,35 @@
import options
import os
import strutils
import terminal

import FeedNim / rss
import pager

import errors
import htmlextractor
import paths

from config import conf
from types import Post

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


proc formatTitle*(p: Post): string {.raises: [].} =
proc formatTitle*(p: Post): string {.raises: [RomanError].} =
  var width: int
  try:
    width = terminalWidth()
  except ValueError:
    raise newException(RomanError, "could not get terminal width")
  if p.read:
    result = p.title
  else:
    result = "[*] " & p.title

  if result.len > width:
    # 3 for the ellipsis, 4 for the '> ' before and after the printing,
    # 2 for padding
    result = result[0..<width-9] & "..."


proc collectReadPosts(): seq[string] {.raises: [RomanError].} =
  try:


@@ 38,12 45,27 @@ proc isPostRead(itemGUID: string): bool {.raises: [RomanError].} =
  return itemGUID in collectReadPosts()


proc postFromRSSItem*(item: RSSItem): Post {.raises: [
    RomanError].} =
proc displayPost*(p: Post) {.raises: [RomanError].} =
  try:
    var content: string
    if p.author.isSome:
      content = p.title & "\n" & p.author.unsafeGet & "\n\n" & p.content
    else:
      content = p.title & "\n\n" & p.content
    page(content, goToBottom = conf.goToBottom, goToTop = conf.goToTop,
      upOne = conf.up, downOne = conf.down, quitChar = conf.quit)
  except IOError, ValueError:
    let msg = getCurrentExceptionMsg()
    raise newException(RomanError, "could not write to the terminal: " & msg)


proc postFromRSSItem*(item: RSSItem): Post {.raises: [RomanError].} =
  result.title = item.title
  result.content = extractBody(item.description)
  result.guid = item.guid
  result.read = isPostRead(item.guid)
  if item.author.len > 0:
    result.author = some(item.author)


proc markAsRead*(p: Post) {.raises: [RomanError].} =

M src/romanpkg/subscriptions.nim => src/romanpkg/subscriptions.nim +3 -6
@@ 2,15 2,12 @@ import algorithm
import os
import parsecsv

import config
import errors
import feeds
import paths


type
  Subscription* = object
    url*: string
    name*: string
from types import Subscription


proc getSubscriptions*(): seq[Subscription] {.raises: [RomanError].} =


@@ 34,7 31,7 @@ proc getSubscriptions*(): seq[Subscription] {.raises: [RomanError].} =

proc addSubscriptionToSubsFile*(url: string) {.raises: [RomanError].} =
  try:
    let feed = getFeed(url)
    let feed = getFeed(Subscription(url: url))
    let subscription = Subscription(name: feed.title, url: url)
    let subs = getSubscriptions()
    if subscription in subs:

M src/romanpkg/termask.nim => src/romanpkg/termask.nim +96 -31
@@ 1,3 1,4 @@
import options
import sequtils
import strutils
import terminal


@@ 7,6 8,9 @@ import fab

import errors

from config import conf
from types import RomanConfig


# This function was originally based on the promptListInteractive function
# in Nimble, and is therefore under the same license.


@@ 40,9 44,49 @@ import errors
# https://github.com/nim-lang/nimble/blob/
#   2243e3fbc2dd277ad81df5d795307bf8389b9240/src/nimblepkg/cli.nim#L177


proc goDown(selectedIx: var int, currentArgs: seq[string]) {.raises: [].} =
  selectedIx = (selectedIx + 1) mod currentArgs.len


proc goUp(selectedIx: var int, currentArgs: seq[string]) {.raises: [].} =
  if selectedIx == 0:
    selectedIx = currentArgs.len - 1
  else:
    selectedIx -= 1


proc advancePage(currentArgs: var seq[string], selectedIx: var int,
    sliceIx: var int, argSlices: seq[seq[string]]) {.raises: [].} =
  if argSlices.len == 1:
    return
  # Advance to the next set of results and reset
  sliceIx += 1
  if sliceIx >= argSlices.len:
    sliceIx = 0
  selectedIx = 0
  currentArgs = argSlices[sliceIx]


proc goBackPage(currentArgs: var seq[string], selectedIx: var int,
    sliceIx: var int, argSlices: seq[seq[string]]) {.raises: [].} =
  if argSlices.len == 1:
    return
  # Go back to the last set of results and reset
  sliceIx -= 1
  if sliceIx < 0:
    sliceIx = argSlices.len - 1
  selectedIx = 0
  currentArgs = argSlices[sliceIx]


proc showArgPages(sliceIx: int, argSlices: seq[seq[string]]) {.raises: [].} =
  echo "\n[", sliceIx + 1, "/", argSlices.len, "]"


proc promptList*(question: string, args: openarray[string],
    displayNames: Table[string, string] = initTable[string, string](),
        show: int = -1): string {.raises: [ValueError, IOError].} =
        show: int = -1): Option[string] {.raises: [ValueError, IOError].} =
  var
    selectedIx = 0
    selectionMade = false


@@ 55,18 99,26 @@ proc promptList*(question: string, args: openarray[string],
    if args.len <= show:
      argSlices = @[toSeq(args)]
    else:
      # Split the arguments into chunks of length show
      # Store those chunks in argSlices
      var counter = 0
      while counter < args.len:
        let top = min(counter + show, args.len - 1)
        # Subtract 2 because both counter and show are 1 indexed
        let top = min(counter + show - 1, args.len - 1)
        let nextArgs = args[counter..top]
        argSlices.add(nextArgs)
        counter += show

  que(question, fg = fgDefault)

  if argSlices.len > 1:
    showArgPages(sliceIx, argSlices)

  var currentArgs = argSlices[sliceIx]
  for arg in currentArgs:
    eraseLine()
    stdout.write "\n"
    # cursorDown(stdout)

  cursorUp(stdout, currentArgs.len)
  hideCursor(stdout)


@@ 74,8 126,8 @@ proc promptList*(question: string, args: openarray[string],
  while not selectionMade:
    setForegroundColor(fgDefault)
    if argSlices.len > 1:
      cursorUp(stdout)
      echo "[", sliceIx + 1, "/", argSlices.len, "] N to advance, P to go back"
      cursorUp(stdout, 2)
      showArgPages(sliceIx, argSlices)

    let width = terminalWidth()
    for ix, arg in currentArgs:


@@ 95,48 147,61 @@ proc promptList*(question: string, args: openarray[string],
        for s in 0..<(width):
          cursorBackward(stdout)
      cursorDown(stdout)
    for i in 0..<(currentArgs.len()):
    for i in 0..<currentArgs.len():
      cursorUp(stdout)

    resetAttributes(stdout)

    while true:
      case getch():
      of '\t':
        selectedIx = (selectedIx + 1) mod currentArgs.len
      let c = getch()
      # Use ifs instead of case because case requires known values at comptime
      if c == conf.down: # go down
        goDown(selectedIx, currentArgs)
        break
      of '\r':
        selectionMade = true
      elif c == conf.up: # go up
        goUp(selectedIx, currentArgs)
        break
      of 'N':
        if argSlices.len == 1:
      # Handle arrow keys
      elif c == chr(27):
        # Skip the useless [
        discard getch()
        case getch():
        of 'A': # up arrow
          goUp(selectedIx, currentArgs)
          break
        # Advance to the next set of results and reset
        sliceIx += 1
        if sliceIx >= argSlices.len:
          sliceIx = 0
        selectedIx = 0
        currentArgs = argSlices[sliceIx]
        break
      of 'P':
        if argSlices.len == 1:
        of 'B': # down arrow
          goDown(selectedIx, currentArgs)
          break
        of 'C': # right arrow
          advancePage(currentArgs, selectedIx, sliceIx, argSlices)
          break
        # Go back to the last set of results and reset
        sliceIx -= 1
        if sliceIx <= 0:
          sliceIx = argSlices.len - 1
        selectedIx = 0
        currentArgs = argSlices[sliceIx]
        of 'D': # left arrow
          goBackPage(currentArgs, selectedIx, sliceIx, argSlices)
          break
        else: break
      elif c == '\r':
        selectionMade = true
        break
      elif c == conf.next:
        advancePage(currentArgs, selectedIx, sliceIx, argSlices)
        break
      of '\3':
      elif c == conf.previous:
        goBackPage(currentArgs, selectedIx, sliceIx, argSlices)
        break
      elif c == conf.quit:
        for _ in (selectedIx mod currentArgs.len)..currentArgs.len:
          cursorDown(stdout)
        echo "\n"
        return none(string)
      elif c == '\3':
        showCursor(stdout)
        # Move the cursor down to the end of the arguments list
        # so that after the interrupt, the error message is displayed
        # on its own line
        for _ in (selectedIx mod currentArgs.len)..currentArgs.len:
          cursorDown(stdout)
        raise newException(ValueError, "no value selected")
      else: discard
        raise newException(ValueError, "keyboard interrupt")
      else: break

  for i in 0..<currentArgs.len:
    eraseLine(stdout)


@@ 144,4 209,4 @@ proc promptList*(question: string, args: openarray[string],
  for i in 0..<currentArgs.len():
    cursorUp(stdout)
  showCursor(stdout)
  return currentArgs[selectedIx]
  return some(currentArgs[selectedIx])

A src/romanpkg/types.nim => src/romanpkg/types.nim +31 -0
@@ 0,0 1,31 @@
import options


type
  RomanConfig* = object
    up*: char
    down*: char
    next*: char
    previous*: char
    quit*: char
    goToTop*: char
    goToBottom*: char
    postWidth*: int

  Subscription* = object
    url*: string
    name*: string

  # Use our own Post type instead of RSSItem
  # to show metadata we collect (e.g., read/unread)
  Post* = object
    title*: string
    content*: string
    guid*: string
    read*: bool
    author*: Option[string]

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