commit 5062ab20feb901630dc69e8ed49d05d82b1756ba
parent 875f4880bc520ca25b718cb7c0ede141f13aeab5
Author: gearsix <gearsix@tuta.io>
Date: Wed, 23 Mar 2022 12:13:53 +0000
Merge branch 'master' into gearsix
Diffstat:
35 files changed, 3682 insertions(+), 163 deletions(-)
diff --git a/LICENSE b/LICENSE
@@ -1,6 +1,6 @@
ISC License
-Copyright (c) 2011-2021 Hiltjo Posthuma <hiltjo@codemadness.org>
+Copyright (c) 2011-2022 Hiltjo Posthuma <hiltjo@codemadness.org>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
diff --git a/Makefile b/Makefile
@@ -1,7 +1,10 @@
.POSIX:
NAME = sfeed
-VERSION = 1.0
+VERSION = 1.3
+
+# curses theme, see themes/ directory.
+SFEED_THEME = mono
# paths
PREFIX = /usr/local
@@ -19,11 +22,34 @@ SFEED_CPPFLAGS = -D_DEFAULT_SOURCE -D_XOPEN_SOURCE=700 -D_BSD_SOURCE
#SFEED_CPPFLAGS = -D_DEFAULT_SOURCE -D_XOPEN_SOURCE=700 -D_BSD_SOURCE \
# -DGETNEXT=getchar
+# set $SFEED_CURSES to empty to not build sfeed_curses.
+SFEED_CURSES = sfeed_curses
+SFEED_CURSES_CFLAGS = ${CFLAGS}
+SFEED_CURSES_CPPFLAGS = -D_DEFAULT_SOURCE -D_XOPEN_SOURCE=700 -D_BSD_SOURCE \
+ -DSFEED_THEME=\"themes/${SFEED_THEME}.h\" ${SFEED_CPPFLAGS}
+SFEED_CURSES_LDFLAGS = ${LDFLAGS} -lcurses
+
+# Linux: some distros use ncurses and require -lncurses.
+#SFEED_CURSES_LDFLAGS = ${LDFLAGS} -lncurses
+
+# Gentoo Linux: some distros might also require -ltinfo and -D_DEFAULT_SOURCE
+# to prevent warnings about feature test macros.
+#SFEED_CURSES_LDFLAGS = ${LDFLAGS} -lcurses -ltinfo
+
+# FreeBSD: unset feature test macros for SIGWINCH etc.
+#SFEED_CURSES_CPPFLAGS =
+
+# use minicurses with hardcoded escape sequences (not the system curses).
+#SFEED_CURSES_CPPFLAGS = -D_DEFAULT_SOURCE -D_XOPEN_SOURCE=700 -D_BSD_SOURCE \
+# -DSFEED_THEME=\"themes/${SFEED_THEME}.h\" -DSFEED_MINICURSES
+#SFEED_CURSES_LDFLAGS = ${LDFLAGS}
+
BIN = \
sfeed\
sfeed_atom\
+ ${SFEED_CURSES}\
sfeed_frames\
- sfeed_gopher \
+ sfeed_gopher\
sfeed_html\
sfeed_mbox\
sfeed_opml_import\
@@ -32,12 +58,15 @@ BIN = \
sfeed_web\
sfeed_xmlenc
SCRIPTS = \
+ sfeed_content\
+ sfeed_markread\
sfeed_opml_export\
sfeed_update\
sfeed_read
SRC = ${BIN:=.c}
HDR = \
+ minicurses.h\
util.h\
xml.h
@@ -79,25 +108,31 @@ OBJ = ${SRC:.c=.o} ${LIBXMLOBJ} ${LIBUTILOBJ} ${COMPATOBJ}
${OBJ}: ${HDR}
.o:
- ${CC} ${SFEED_LDFLAGS} -o $@ $< ${LIB}
+ ${CC} -o $@ $< ${LIB} ${SFEED_LDFLAGS}
.c.o:
- ${CC} ${SFEED_CFLAGS} ${SFEED_CPPFLAGS} -o $@ -c $<
+ ${CC} -o $@ -c $< ${SFEED_CFLAGS} ${SFEED_CPPFLAGS}
+
+sfeed_curses.o: sfeed_curses.c themes/${SFEED_THEME}.h
+ ${CC} -o $@ -c sfeed_curses.c ${SFEED_CURSES_CFLAGS} ${SFEED_CURSES_CPPFLAGS}
+
+sfeed_curses: ${LIB} sfeed_curses.o
+ ${CC} -o $@ sfeed_curses.o ${LIB} ${SFEED_CURSES_LDFLAGS}
${LIBUTIL}: ${LIBUTILOBJ}
${AR} -rc $@ $?
${RANLIB} $@
${LIBXML}: ${LIBXMLOBJ}
- ${AR} rc $@ $?
+ ${AR} -rc $@ $?
${RANLIB} $@
dist:
rm -rf "${NAME}-${VERSION}"
mkdir -p "${NAME}-${VERSION}"
- cp -f ${MAN1} ${MAN5} ${DOC} ${HDR} \
+ cp -fR ${MAN1} ${MAN5} ${DOC} ${HDR} \
${SRC} ${LIBXMLSRC} ${LIBUTILSRC} ${COMPATSRC} ${SCRIPTS} \
- Makefile \
+ themes Makefile \
sfeedrc.example style.css \
"${NAME}-${VERSION}"
# make tarball
diff --git a/README b/README
@@ -26,6 +26,19 @@ $ make
# make install
+To build sfeed without sfeed_curses set SFEED_CURSES to an empty string:
+
+$ make SFEED_CURSES=""
+# make SFEED_CURSES="" install
+
+
+To change the theme for sfeed_curses you can set SFEED_THEME. See the themes/
+directory for the theme names.
+
+$ make SFEED_THEME="templeos"
+# make SFEED_THEME="templeos" install
+
+
Usage
-----
@@ -89,9 +102,6 @@ Gopher, SSH, etc.
See the section "Usage and examples" below and the man-pages for more
information how to use sfeed(1) and the additional tools.
-A separate curses UI front-end called sfeed_curses is available at:
-https://codemadness.org/sfeed_curses.html
-
Dependencies
------------
@@ -103,11 +113,11 @@ Dependencies
Optional dependencies
---------------------
-- POSIX make(1) for Makefile.
+- POSIX make(1) for the Makefile.
- POSIX sh(1),
used by sfeed_update(1) and sfeed_opml_export(1).
- POSIX utilities such as awk(1) and sort(1),
- used by sfeed_update(1).
+ used by sfeed_content(1), sfeed_markread(1) and sfeed_update(1).
- curl(1) binary: https://curl.haxx.se/ ,
used by sfeed_update(1), but can be replaced with any tool like wget(1),
OpenBSD ftp(1) or hurl(1): https://git.codemadness.org/hurl/
@@ -116,27 +126,62 @@ Optional dependencies
encoded then you don't need this. For a minimal iconv implementation:
https://git.etalabs.net/cgit/noxcuse/tree/src/iconv.c
- mandoc for documentation: https://mdocml.bsd.lv/
+- curses (typically ncurses), otherwise see minicurses.h,
+ used by sfeed_curses(1).
+- a terminal (emulator) supporting UTF-8 and the used capabilities,
+ used by sfeed_curses(1).
+
+
+Optional run-time dependencies for sfeed_curses
+-----------------------------------------------
+
+- xclip for yanking the URL or enclosure. See $SFEED_YANKER to change it.
+- xdg-open, used as a plumber by default. See $SFEED_PLUMBER to change it.
+- awk, used by the sfeed_content and sfeed_markread script.
+ See the ENVIRONMENT VARIABLES section in the man page to change it.
+- lynx, used by the sfeed_content script to convert HTML content.
+ See the ENVIRONMENT VARIABLES section in the man page to change it.
+
+
+Formats supported
+-----------------
+
+sfeed supports a subset of XML 1.0 and a subset of:
+
+- Atom 1.0 (RFC 4287): https://datatracker.ietf.org/doc/html/rfc4287
+- Atom 0.3 (draft, historic).
+- RSS 0.91+.
+- RDF (when used with RSS).
+- MediaRSS extensions (media:).
+- Dublin Core extensions (dc:).
+
+Other formats like JSONfeed, twtxt or certain RSS/Atom extensions can be
+supported by converting them to RSS/Atom or to the sfeed(5) format directly.
OS tested
---------
-- Linux (compilers: clang, cproc, gcc, lacc, pcc, tcc, libc: glibc, musl).
+- Linux,
+ compilers: clang, gcc, chibicc, cproc, lacc, pcc, scc, tcc,
+ libc: glibc, musl.
- OpenBSD (clang, gcc).
-- NetBSD
+- NetBSD (with NetBSD curses).
- FreeBSD
- DragonFlyBSD
-- Windows (cygwin gcc, mingw).
+- GNU/Hurd
+- Illumos (OpenIndiana).
+- Windows (cygwin gcc + mintty, mingw).
- HaikuOS
- SerenityOS
- FreeDOS (djgpp).
-- FUZIX (sdcc -mz80).
+- FUZIX (sdcc -mz80, with the sfeed parser program).
Architectures tested
--------------------
-amd64, ARM, aarch64, HPPA, i386, MIPS32-BE, SPARC64, Z80.
+amd64, ARM, aarch64, HPPA, i386, MIPS32-BE, RISCV64, SPARC64, Z80.
Files
@@ -145,11 +190,14 @@ Files
sfeed - Read XML RSS or Atom feed data from stdin. Write feed data
in TAB-separated format to stdout.
sfeed_atom - Format feed data (TSV) to an Atom feed.
+sfeed_content - View item content, for use with sfeed_curses.
+sfeed_curses - Format feed data (TSV) to a curses interface.
sfeed_frames - Format feed data (TSV) to HTML file(s) with frames.
sfeed_gopher - Format feed data (TSV) to Gopher files.
sfeed_html - Format feed data (TSV) to HTML.
sfeed_opml_export - Generate an OPML XML file from a sfeedrc config file.
sfeed_opml_import - Generate a sfeedrc config file from an OPML XML file.
+sfeed_markread - Mark items as read/unread, for use with sfeed_curses.
sfeed_mbox - Format feed data (TSV) to mbox.
sfeed_plain - Format feed data (TSV) to a plain-text list.
sfeed_twtxt - Format feed data (TSV) to a twtxt feed.
@@ -207,8 +255,8 @@ Find RSS/Atom feed URLs from a webpage:
output example:
- https://codemadness.org/blog/rss.xml application/rss+xml
- https://codemadness.org/blog/atom.xml application/atom+xml
+ https://codemadness.org/atom.xml application/atom+xml
+ https://codemadness.org/atom_content.xml application/atom+xml
- - -
@@ -236,6 +284,33 @@ View formatted output in your editor:
- - -
+View formatted output in a curses interface. The interface has a look inspired
+by the mutt mail client. It has a sidebar panel for the feeds, a panel with a
+listing of the items and a small statusbar for the selected item/URL. Some
+functions like searching and scrolling are integrated in the interface itself.
+
+Just like the other format programs included in sfeed you can run it like this:
+
+ sfeed_curses ~/.sfeed/feeds/*
+
+... or by reading from stdin:
+
+ sfeed_curses < ~/.sfeed/feeds/xkcd
+
+By default sfeed_curses marks the items of the last day as new/bold. To manage
+read/unread items in a different way a plain-text file with a list of the read
+URLs can be used. To enable this behaviour the path to this file can be
+specified by setting the environment variable $SFEED_URL_FILE to the URL file:
+
+ export SFEED_URL_FILE="$HOME/.sfeed/urls"
+ [ -f "$SFEED_URL_FILE" ] || touch "$SFEED_URL_FILE"
+ sfeed_curses ~/.sfeed/feeds/*
+
+It then uses the shellscript "sfeed_markread" to process the read and unread
+items.
+
+- - -
+
Example script to view feed items in a vertical list/menu in dmenu(1). It opens
the selected URL in the browser set in $BROWSER:
@@ -677,12 +752,139 @@ sfeed_update_xargs shellscript:
# fetch feeds and store in temporary directory.
sfeedtmpdir="$(mktemp -d '/tmp/sfeed_XXXXXX')"
+ mkdir -p "${sfeedtmpdir}/feeds"
+ touch "${sfeedtmpdir}/ok"
# make sure path exists.
mkdir -p "${sfeedpath}"
# print feeds for parallel processing with xargs.
feeds | SFEED_UPDATE_CHILD="1" xargs -r -0 -P "${maxjobs}" -L 6 "$(readlink -f "$0")"
+ status=$?
+ # check error exit status indicator for parallel jobs.
+ test -f "${sfeedtmpdir}/ok" || status=1
# cleanup temporary files etc.
cleanup
+ exit ${status}
+
+- - -
+
+Shellscript to handle URLs and enclosures in parallel using xargs -P.
+
+This can be used to download and process URLs for downloading podcasts,
+webcomics, download and convert webpages, mirror videos, etc. It uses a
+plain-text cache file for remembering processed URLs. The match patterns are
+defined in the shellscript fetch() function and in the awk script and can be
+modified to handle items differently depending on their context.
+
+The arguments for the script are files in the sfeed(5) format. If no file
+arguments are specified then the data is read from stdin.
+
+ #!/bin/sh
+ # sfeed_download: downloader for URLs and enclosures in sfeed(5) files.
+ # Dependencies: awk, curl, flock, xargs (-P), youtube-dl.
+
+ cachefile="${SFEED_CACHEFILE:-$HOME/.sfeed/downloaded_urls}"
+ jobs="${SFEED_JOBS:-4}"
+ lockfile="${HOME}/.sfeed/sfeed_download.lock"
+
+ # log(feedname, s, status)
+ log() {
+ if [ "$1" != "-" ]; then
+ s="[$1] $2"
+ else
+ s="$2"
+ fi
+ printf '[%s]: %s: %s\n' "$(date +'%H:%M:%S')" "${s}" "$3"
+ }
+
+ # fetch(url, feedname)
+ fetch() {
+ case "$1" in
+ *youtube.com*)
+ youtube-dl "$1";;
+ *.flac|*.ogg|*.m3u|*.m3u8|*.m4a|*.mkv|*.mp3|*.mp4|*.wav|*.webm)
+ # allow 2 redirects, hide User-Agent, connect timeout is 15 seconds.
+ curl -O -L --max-redirs 2 -H "User-Agent:" -f -s --connect-timeout 15 "$1";;
+ esac
+ }
+
+ # downloader(url, title, feedname)
+ downloader() {
+ url="$1"
+ title="$2"
+ feedname="${3##*/}"
+
+ msg="${title}: ${url}"
+
+ # download directory.
+ if [ "${feedname}" != "-" ]; then
+ mkdir -p "${feedname}"
+ if ! cd "${feedname}"; then
+ log "${feedname}" "${msg}: ${feedname}" "DIR FAIL" >&2
+ return 1
+ fi
+ fi
+
+ log "${feedname}" "${msg}" "START"
+ if fetch "${url}" "${feedname}"; then
+ log "${feedname}" "${msg}" "OK"
+
+ # append it safely in parallel to the cachefile on a
+ # successful download.
+ (flock 9 || exit 1
+ printf '%s\n' "${url}" >> "${cachefile}"
+ ) 9>"${lockfile}"
+ else
+ log "${feedname}" "${msg}" "FAIL" >&2
+ return 1
+ fi
+ return 0
+ }
+
+ if [ "${SFEED_DOWNLOAD_CHILD}" = "1" ]; then
+ # Downloader helper for parallel downloading.
+ # Receives arguments: $1 = URL, $2 = title, $3 = feed filename or "-".
+ # It should write the URI to the cachefile if it is succesful.
+ downloader "$1" "$2" "$3"
+ exit $?
+ fi
+
+ # ...else parent mode:
+
+ tmp=$(mktemp)
+ trap "rm -f ${tmp}" EXIT
+
+ [ -f "${cachefile}" ] || touch "${cachefile}"
+ cat "${cachefile}" > "${tmp}"
+ echo >> "${tmp}" # force it to have one line for awk.
+
+ LC_ALL=C awk -F '\t' '
+ # fast prefilter what to download or not.
+ function filter(url, field, feedname) {
+ u = tolower(url);
+ return (match(u, "youtube\\.com") ||
+ match(u, "\\.(flac|ogg|m3u|m3u8|m4a|mkv|mp3|mp4|wav|webm)$"));
+ }
+ function download(url, field, title, filename) {
+ if (!length(url) || urls[url] || !filter(url, field, filename))
+ return;
+ # NUL-separated for xargs -0.
+ printf("%s%c%s%c%s%c", url, 0, title, 0, filename, 0);
+ urls[url] = 1; # print once
+ }
+ {
+ FILENR += (FNR == 1);
+ }
+ # lookup table from cachefile which contains downloaded URLs.
+ FILENR == 1 {
+ urls[$0] = 1;
+ }
+ # feed file(s).
+ FILENR != 1 {
+ download($3, 3, $2, FILENAME); # link
+ download($8, 8, $2, FILENAME); # enclosure
+ }
+ ' "${tmp}" "${@:--}" | \
+ SFEED_DOWNLOAD_CHILD="1" xargs -r -0 -L 3 -P "${jobs}" "$(readlink -f "$0")"
- - -
@@ -696,9 +898,7 @@ TSV format.
#
# Dependencies: sqlite3, awk.
#
- # Usage: create some directory to store the feeds, run this script.
- #
- # Assumes feednames are unique and a feed title is set.
+ # Usage: create some directory to store the feeds then run this script.
# newsboat cache.db file.
cachefile="$HOME/.newsboat/cache.db"
@@ -723,7 +923,7 @@ TSV format.
.quit
!EOF
# convert to sfeed(5) TSV format.
- awk '
+ LC_ALL=C awk '
BEGIN {
FS = "\x1f";
RS = "\x1e";
@@ -746,9 +946,13 @@ TSV format.
gsub("\t", "\\t", s);
return s;
}
- function feedname(url, title) {
- gsub("/", "_", title);
- return title;
+ function feedname(feedurl, feedtitle) {
+ if (feedtitle == "") {
+ gsub("/", "_", feedurl);
+ return feedurl;
+ }
+ gsub("/", "_", feedtitle);
+ return feedtitle;
}
{
fname = feedname($9, $10);
@@ -774,6 +978,130 @@ TSV format.
}
}'
+- - -
+
+Counting unread and total items
+-------------------------------
+
+It can be useful to show the counts of unread items, for example in a
+windowmanager or statusbar.
+
+The below example script counts the items of the last day in the same way the
+formatting tools do:
+
+ #!/bin/sh
+ # Count the new items of the last day.
+ LC_ALL=C awk -F '\t' -v "old=$(($(date +'%s') - 86400))" '
+ {
+ total++;
+ }
+ int($1) >= old {
+ totalnew++;
+ }
+ END {
+ print "New: " totalnew;
+ print "Total: " total;
+ }' ~/.sfeed/urls ~/.sfeed/feeds/*
+
+The below example script counts the unread items using the sfeed_curses URL
+file:
+
+ #!/bin/sh
+ # Count the unread and total items from feeds using the URL file.
+ LC_ALL=C awk -F '\t' '
+ # URL file: amount of fields is 1.
+ NF == 1 {
+ u[$0] = 1; # lookup table of URLs.
+ next;
+ }
+ # feed file: check by URL or id.
+ {
+ total++;
+ if (length($3)) {
+ if (u[$3])
+ read++;
+ } else if (length($6)) {
+ if (u[$6])
+ read++;
+ }
+ }
+ END {
+ print "Unread: " (total - read);
+ print "Total: " total;
+ }' ~/.sfeed/urls ~/.sfeed/feeds/*
+
+- - -
+
+Running custom commands inside the sfeed_curses program
+-------------------------------------------------------
+
+Running commands inside the sfeed_curses program can be useful for example to
+sync items or mark all items across all feeds as read. It can be comfortable to
+have a keybind for this inside the program to perform a scripted action and
+then reload the feeds by sending the signal SIGHUP.
+
+In the input handling code you can then add a case:
+
+ case 'M':
+ forkexec((char *[]) { "markallread.sh", NULL }, 0);
+ break;
+
+or
+
+ case 'S':
+ forkexec((char *[]) { "syncnews.sh", NULL }, 1);
+ break;
+
+The specified script should be in $PATH or be an absolute path.
+
+Example of a `markallread.sh` shellscript to mark all URLs as read:
+
+ #!/bin/sh
+ # mark all items/URLs as read.
+ tmp=$(mktemp)
+ (cat ~/.sfeed/urls; cut -f 3 ~/.sfeed/feeds/*) | \
+ awk '!x[$0]++' > "$tmp" &&
+ mv "$tmp" ~/.sfeed/urls &&
+ pkill -SIGHUP sfeed_curses # reload feeds.
+
+Example of a `syncnews.sh` shellscript to update the feeds and reload them:
+
+ #!/bin/sh
+ sfeed_update && pkill -SIGHUP sfeed_curses
+
+
+Open an URL directly in the same terminal
+-----------------------------------------
+
+To open an URL directly in the same terminal using the text-mode lynx browser:
+
+ SFEED_PLUMBER=lynx SFEED_PLUMBER_INTERACTIVE=1 sfeed_curses ~/.sfeed/feeds/*
+
+
+Yank to tmux buffer
+-------------------
+
+This changes the yank command to set the tmux buffer, instead of X11 xclip:
+
+ SFEED_YANKER="tmux set-buffer \`cat\`"
+
+
+Known terminal issues
+---------------------
+
+Below lists some bugs or missing features in terminals that are found while
+testing sfeed_curses. Some of them might be fixed already upstream:
+
+- cygwin + mintty: the xterm mouse-encoding of the mouse position is broken for
+ scrolling.
+- HaikuOS terminal: the xterm mouse-encoding of the mouse button number of the
+ middle-button, right-button is incorrect / reversed.
+- putty: the full reset attribute (ESC c, typically `rs1`) does not reset the
+ window title.
+- Mouse button encoding for extended buttons (like side-buttons) in some
+ terminals are unsupported or map to the same button: for example side-buttons 7
+ and 8 map to the scroll buttons 4 and 5 in urxvt.
+
License
-------
diff --git a/minicurses.h b/minicurses.h
@@ -0,0 +1,38 @@
+#include <sys/ioctl.h>
+
+#undef OK
+#define OK (0)
+
+const char *clr_eol = "\x1b[K";
+const char *clear_screen = "\x1b[H\x1b[2J";
+const char *cursor_address = "\x1b[%ld;%ldH";
+const char *cursor_normal = "\x1b[?25h"; /* DECTCEM (in)Visible cursor */
+const char *cursor_invisible = "\x1b[?25l"; /* DECTCEM (in)Visible cursor */
+const char *eat_newline_glitch = (void *)1;
+const char *enter_ca_mode = "\x1b[?1049h"; /* smcup */
+const char *exit_ca_mode = "\x1b[?1049l"; /* rmcup */
+const char *save_cursor = "\x1b""7";
+const char *restore_cursor = "\x1b""8";
+const char *exit_attribute_mode = "\x1b[0m";
+const char *enter_bold_mode = "\x1b[1m";
+const char *enter_dim_mode = "\x1b[2m";
+const char *enter_reverse_mode = "\x1b[7m";
+
+int
+setupterm(char *term, int fildes, int *errret)
+{
+ return OK;
+}
+
+char *
+tparm(char *s, long p1, long p2, ...)
+{
+ static char buf[32];
+
+ if (s == cursor_address) {
+ snprintf(buf, sizeof(buf), s, p1 + 1, p2 + 1);
+ return buf;
+ }
+
+ return s;
+}
diff --git a/sfeed.1 b/sfeed.1
@@ -1,4 +1,4 @@
-.Dd July 29, 2021
+.Dd November 26, 2021
.Dt SFEED 1
.Os
.Sh NAME
@@ -31,7 +31,7 @@ Control characters are removed.
The order and content of the fields are:
.Bl -tag -width 15n
.It 1. timestamp
-UNIX timestamp in UTC+0, empty if missing or on parse failure.
+UNIX timestamp in UTC+0, empty if missing or on a parse failure.
.It 2. title
Title text, HTML code in titles is ignored and is treated as plain-text.
.It 3. link
@@ -43,11 +43,11 @@ Content, can have plain-text or HTML code depending on the content-type field.
.It 6. id
RSS item GUID or Atom id.
.It 7. author
-Item author.
+Item, first author.
.It 8. enclosure
Item, first enclosure.
.It 9. category
-Item, categories, multiple values are separated by |.
+Item, categories, multiple values are separated by the '|' character.
.El
.Sh EXIT STATUS
.Ex -std
@@ -55,7 +55,45 @@ Item, categories, multiple values are separated by |.
.Bd -literal
curl -s 'https://codemadness.org/atom.xml' | sfeed
.Ed
+.Sh EXAMPLE SETUP
+1. Create a directory for the sfeedrc configuration and the feeds:
+.Bd -literal
+ mkdir -p ~/.sfeed/feeds
+.Ed
+.Pp
+2. Copy the example
+.Xr sfeedrc 5
+configuration:
+.Bd -literal
+ cp sfeedrc.example ~/.sfeed/sfeedrc
+ $EDITOR ~/.sfeed/sfeedrc
+.Ed
+.Pp
+Or import existing OPML subscriptions using
+.Xr sfeed_opml_import 1 :
+.Bd -literal
+ sfeed_opml_import < file.opml > ~/.sfeed/sfeedrc
+.Ed
+.Pp
+3. To update feeds and merge the new items with existing items:
+.Bd -literal
+ sfeed_update
+.Ed
+.Pp
+4. Format feeds to a plain-text list:
+.Bd -literal
+ sfeed_plain ~/.sfeed/feeds/*
+.Ed
+.Pp
+Or format feeds to a curses interface:
+.Bd -literal
+ sfeed_curses ~/.sfeed/feeds/*
+.Ed
+.Pp
+There are also other formatting programs included.
+The README file has more examples.
.Sh SEE ALSO
+.Xr sfeed_curses 1 ,
.Xr sfeed_plain 1 ,
.Xr sfeed_update 1 ,
.Xr sfeed 5 ,
diff --git a/sfeed.5 b/sfeed.5
@@ -1,4 +1,4 @@
-.Dd July 29, 2021
+.Dd November 23, 2021
.Dt SFEED 5
.Os
.Sh NAME
@@ -25,7 +25,7 @@ Control characters are removed.
The order and content of the fields are:
.Bl -tag -width 15n
.It 1. timestamp
-UNIX timestamp in UTC+0, empty if missing or on parse failure.
+UNIX timestamp in UTC+0, empty if missing or on a parse failure.
.It 2. title
Title text, HTML code in titles is ignored and is treated as plain-text.
.It 3. link
@@ -37,11 +37,11 @@ Content, can have plain-text or HTML code depending on the content-type field.
.It 6. id
RSS item GUID or Atom id.
.It 7. author
-Item author.
+Item, first author.
.It 8. enclosure
Item, first enclosure.
.It 9. category
-Item, categories, multiple values are separated by |.
+Item, categories, multiple values are separated by the '|' character.
.El
.Sh SEE ALSO
.Xr sfeed 1 ,
diff --git a/sfeed.c b/sfeed.c
@@ -1,5 +1,3 @@
-#include <sys/types.h>
-
#include <ctype.h>
#include <errno.h>
#include <stdint.h>
@@ -103,7 +101,7 @@ static FeedTag * gettag(enum FeedType, const char *, size_t);
static long gettzoffset(const char *);
static int isattr(const char *, size_t, const char *, size_t);
static int istag(const char *, size_t, const char *, size_t);
-static int parsetime(const char *, time_t *);
+static int parsetime(const char *, long long *);
static void printfields(void);
static void string_append(String *, const char *, size_t);
static void string_buffer_realloc(String *, size_t);
@@ -129,7 +127,7 @@ static void xmltagstartparsed(XMLParser *, const char *, size_t, int);
/* map tag name to TagId type */
/* RSS, must be alphabetical order */
-static FeedTag rsstags[] = {
+static const FeedTag rsstags[] = {
{ STRP("author"), RSSTagAuthor },
{ STRP("category"), RSSTagCategory },
{ STRP("content:encoded"), RSSTagContentEncoded },
@@ -146,7 +144,7 @@ static FeedTag rsstags[] = {
};
/* Atom, must be alphabetical order */
-static FeedTag atomtags[] = {
+static const FeedTag atomtags[] = {
{ STRP("author"), AtomTagAuthor },
{ STRP("category"), AtomTagCategory },
{ STRP("content"), AtomTagContent },
@@ -163,14 +161,14 @@ static FeedTag atomtags[] = {
};
/* special case: nested <author><name> */
-static FeedTag atomtagauthor = { STRP("author"), AtomTagAuthor };
-static FeedTag atomtagauthorname = { STRP("name"), AtomTagAuthorName };
+static const FeedTag atomtagauthor = { STRP("author"), AtomTagAuthor };
+static const FeedTag atomtagauthorname = { STRP("name"), AtomTagAuthorName };
/* reference to no / unknown tag */
-static FeedTag notag = { STRP(""), TagUnknown };
+static const FeedTag notag = { STRP(""), TagUnknown };
/* map TagId type to RSS/Atom field, all tags must be defined */
-static int fieldmap[TagLast] = {
+static const int fieldmap[TagLast] = {
[TagUnknown] = -1,
/* RSS */
[RSSTagDcdate] = FeedFieldTime,
@@ -180,8 +178,8 @@ static int fieldmap[TagLast] = {
[RSSTagDescription] = FeedFieldContent,
[RSSTagContentEncoded] = FeedFieldContent,
[RSSTagGuid] = -1,
- [RSSTagGuidPermalinkTrue] = FeedFieldId, /* special-case: both a link and an id */
[RSSTagGuidPermalinkFalse] = FeedFieldId,
+ [RSSTagGuidPermalinkTrue] = FeedFieldId, /* special-case: both a link and an id */
[RSSTagLink] = FeedFieldLink,
[RSSTagEnclosure] = FeedFieldEnclosure,
[RSSTagAuthor] = FeedFieldAuthor,
@@ -207,7 +205,7 @@ static int fieldmap[TagLast] = {
static const int FieldSeparator = '\t';
/* separator for multiple values in a field, separator should be 1 byte */
-static const char *FieldMultiSeparator = "|";
+static const char FieldMultiSeparator[] = "|";
static struct uri baseuri;
static const char *baseurl;
@@ -215,7 +213,7 @@ static FeedContext ctx;
static XMLParser parser; /* XML parser state */
static String attrispermalink, attrrel, attrtype, tmpstr;
-int
+static int
tagcmp(const void *v1, const void *v2)
{
return strcasecmp(((FeedTag *)v1)->name, ((FeedTag *)v2)->name);
@@ -288,6 +286,7 @@ string_buffer_realloc(String *s, size_t newlen)
s->bufsiz = alloclen;
}
+/* Append data to String, s->data and data may not overlap. */
static void
string_append(String *s, const char *data, size_t len)
{
@@ -299,8 +298,7 @@ string_append(String *s, const char *data, size_t len)
err(1, "realloc");
}
- /* check if allocation is necessary, don't shrink buffer,
- * should be more than bufsiz of course. */
+ /* check if allocation is necessary, never shrink the buffer. */
if (s->len + len >= s->bufsiz)
string_buffer_realloc(s, s->len + len + 1);
memcpy(s->data + s->len, data, len);
@@ -323,9 +321,9 @@ string_print_encoded(String *s)
for (; *p && p != e; p++) {
switch (*p) {
- case '\n': fputs("\\n", stdout); break;
- case '\\': fputs("\\\\", stdout); break;
- case '\t': fputs("\\t", stdout); break;
+ case '\n': putchar('\\'); putchar('n'); break;
+ case '\\': putchar('\\'); putchar('\\'); break;
+ case '\t': putchar('\\'); putchar('t'); break;
default:
/* ignore control chars */
if (!iscntrl((unsigned char)*p))
@@ -362,7 +360,8 @@ string_print_trimmed(String *s)
printtrimmed(s->data);
}
-void
+/* Print each field with trimmed whitespace, separated by '|'. */
+static void
string_print_trimmed_multi(String *s)
{
char *p, *e;
@@ -385,8 +384,8 @@ string_print_trimmed_multi(String *s)
}
}
-/* print URL, if it's a relative URL then it uses global baseurl */
-void
+/* Print URL, if it's a relative URL then it uses the global `baseurl`. */
+static void
printuri(char *s)
{
char link[4096], *p, *e;
@@ -411,8 +410,8 @@ printuri(char *s)
*e = c; /* restore NUL byte to original character */
}
-/* print URL, if it's a relative URL then it uses global baseurl */
-void
+/* Print URL, if it's a relative URL then it uses the global `baseurl`. */
+static void
string_print_uri(String *s)
{
if (!s->data || !s->len)
@@ -421,20 +420,21 @@ string_print_uri(String *s)
printuri(s->data);
}
-/* print as UNIX timestamp, print nothing if the parsed time is invalid */
-void
+/* Print as UNIX timestamp, print nothing if the time is empty or invalid. */
+static void
string_print_timestamp(String *s)
{
- time_t t;
+ long long t;
if (!s->data || !s->len)
return;
if (parsetime(s->data, &t) != -1)
- printf("%lld", (long long)t);
+ printf("%lld", t);
}
-long long
+/* Convert time fields. Returns a UNIX timestamp. */
+static long long
datetounix(long long year, int mon, int day, int hour, int min, int sec)
{
static const int secs_through_month[] = {
@@ -497,9 +497,9 @@ datetounix(long long year, int mon, int day, int hour, int min, int sec)
static long
gettzoffset(const char *s)
{
- static struct {
+ static const struct {
char *name;
- const int offhour;
+ int offhour;
} tzones[] = {
{ "CDT", -5 * 3600 },
{ "CST", -6 * 3600 },
@@ -540,10 +540,12 @@ gettzoffset(const char *s)
return 0;
}
+/* Parse time string `s` into the UNIX timestamp `tp`.
+ Returns 0 on success or -1 on failure. */
static int
-parsetime(const char *s, time_t *tp)
+parsetime(const char *s, long long *tp)
{
- static struct {
+ static const struct {
char *name;
int len;
} mons[] = {
@@ -647,12 +649,12 @@ parsetime(const char *s, time_t *tp)
va[2] < 1 || va[2] > 31 ||
va[3] < 0 || va[3] > 23 ||
va[4] < 0 || va[4] > 59 ||
- va[5] < 0 || va[5] > 59)
+ va[5] < 0 || va[5] > 60) /* allow leap second */
return -1;
- if (tp)
- *tp = datetounix(va[0] - 1900, va[1] - 1, va[2], va[3], va[4], va[5]) -
- gettzoffset(s);
+ *tp = datetounix(va[0] - 1900, va[1] - 1, va[2], va[3], va[4], va[5]) -
+ gettzoffset(s);
+
return 0;
}
@@ -677,6 +679,9 @@ printfields(void)
putchar(FieldSeparator);
string_print_trimmed_multi(&ctx.fields[FeedFieldCategory].str);
putchar('\n');
+
+ if (ferror(stdout)) /* check for errors but do not flush */
+ checkfileerror(stdout, "<stdout>", 'w');
}
static int
@@ -801,8 +806,6 @@ xmlattrstart(XMLParser *p, const char *t, size_t tl, const char *n, size_t nl)
string_clear(&tmpstr); /* use the last value for multiple attribute values */
}
-/* NOTE: this handler can be called multiple times if the data in this
- * block is bigger than the buffer. */
static void
xmldata(XMLParser *p, const char *s, size_t len)
{
@@ -835,7 +838,7 @@ xmldataentity(XMLParser *p, const char *data, size_t datalen)
static void
xmltagstart(XMLParser *p, const char *t, size_t tl)
{
- FeedTag *f;
+ const FeedTag *f;
if (ISINCONTENT(ctx)) {
if (ctx.contenttype == ContentTypeHTML) {
@@ -1059,5 +1062,8 @@ main(int argc, char *argv[])
/* NOTE: getnext is defined in xml.h for inline optimization */
xml_parse(&parser);
+ checkfileerror(stdin, "<stdin>", 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeed_atom.c b/sfeed_atom.c
@@ -37,12 +37,14 @@ printcontent(const char *s)
static void
printfeed(FILE *fp, const char *feedname)
{
- char *fields[FieldLast];
+ char *fields[FieldLast], *p, *tmp;
struct tm parsedtm, *tm;
time_t parsedtime;
ssize_t linelen;
+ int c;
- while ((linelen = getline(&line, &linesize, fp)) > 0) {
+ while ((linelen = getline(&line, &linesize, fp)) > 0 &&
+ !ferror(stdout)) {
if (line[linelen - 1] == '\n')
line[--linelen] = '\0';
parseline(line, fields);
@@ -98,6 +100,16 @@ printfeed(FILE *fp, const char *feedname)
printcontent(fields[FieldContent]);
fputs("</content>\n", stdout);
}
+ for (p = fields[FieldCategory]; (tmp = strchr(p, '|')); p = tmp + 1) {
+ c = *tmp;
+ *tmp = '\0'; /* temporary NUL-terminate */
+ if (*p) {
+ fputs("\t<category term=\"", stdout);
+ xmlencode(p, stdout);
+ fputs("\" />\n", stdout);
+ }
+ *tmp = c; /* restore */
+ }
fputs("</entry>\n", stdout);
}
}
@@ -113,8 +125,8 @@ main(int argc, char *argv[])
if (pledge(argc == 1 ? "stdio" : "stdio rpath", NULL) == -1)
err(1, "pledge");
- if ((now = time(NULL)) == -1)
- err(1, "time");
+ if ((now = time(NULL)) == (time_t)-1)
+ errx(1, "time");
if (!(tm = gmtime_r(&now, &tmnow)))
err(1, "gmtime_r");
@@ -130,19 +142,22 @@ main(int argc, char *argv[])
if (argc == 1) {
printfeed(stdin, "");
+ checkfileerror(stdin, "<stdin>", 'r');
} else {
for (i = 1; i < argc; i++) {
if (!(fp = fopen(argv[i], "r")))
err(1, "fopen: %s", argv[i]);
name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i];
printfeed(fp, name);
- if (ferror(fp))
- err(1, "ferror: %s", argv[i]);
+ checkfileerror(fp, argv[i], 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
fclose(fp);
}
}
fputs("</feed>\n", stdout);
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeed_content b/sfeed_content
@@ -0,0 +1,55 @@
+#!/bin/sh
+# Content viewer for sfeed(5) lines.
+
+# The locale is set to "C" for performance. The input is always UTF-8.
+LC_ALL=C awk -F '\t' '
+function unescape(s) {
+ # use the character "\x01" as a temporary replacement for "\".
+ gsub("\\\\\\\\", "\x01", s);
+ gsub("\\\\n", "\n", s);
+ gsub("\\\\t", "\t", s);
+ gsub("\x01", "\\", s); # restore "\x01" to "\".
+ return s;
+}
+BEGIN {
+ htmlconv = ENVIRON["SFEED_HTMLCONV"];
+ if (!length(htmlconv))
+ htmlconv = "lynx -stdin -dump " \
+ "-underline_links -image_links " \
+ "-display_charset=\"utf-8\" -assume_charset=\"utf-8\" ";
+}
+{
+ if (previtem)
+ print "\f";
+ previtem = 1;
+
+ print "Title: " $2;
+ if (length($7))
+ print "Author: " $7;
+ if (length($9)) {
+ categories = $9;
+ gsub("\\|", ", ", categories);
+ print "Category: " categories;
+ }
+ if (length($3))
+ print "Link: " $3;
+ if (length($8))
+ print "Enclosure: " $8;
+ if (!length($4))
+ next;
+ print "";
+ if ($5 == "html") {
+ # use the link of the item as the base URL for relative URLs in
+ # HTML content.
+ base = $3;
+ if (length(base)) {
+ gsub("\"", "%22", base); # encode quotes.
+ base = "<base href=\"" base "\"/>\n";
+ }
+ print base unescape($4) | htmlconv;
+ close(htmlconv);
+ } else {
+ print unescape($4);
+ }
+}' "$@" | \
+${PAGER:-less -R}
diff --git a/sfeed_content.1 b/sfeed_content.1
@@ -0,0 +1,60 @@
+.Dd December 22, 2021
+.Dt SFEED_CONTENT 1
+.Os
+.Sh NAME
+.Nm sfeed_content
+.Nd view RSS/Atom content
+.Sh SYNOPSIS
+.Nm
+.Op Ar
+.Sh DESCRIPTION
+.Nm
+formats feed data (TSV) from
+.Xr sfeed 1
+from stdin or for each
+.Ar file
+to stdout as plain-text content.
+For HTML content it uses
+.Xr lynx 1
+to convert it to plain-text.
+At the end it uses the pager to view the output.
+The
+.Nm
+script can be used by
+.Xr sfeed_curses 1
+to view content.
+.Sh ENVIRONMENT VARIABLES
+.Bl -tag -width Ds
+.It Ev PAGER
+The pager used to view the content.
+If it is not set it will use "less -R" by default.
+.It Ev SFEED_HTMLCONV
+The program used to convert HTML content to plain-text.
+If it is not set it will use lynx by default.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh EXAMPLES
+.Bd -literal
+curl -s 'https://codemadness.org/atom_content.xml' | sfeed | sfeed_content
+.Ed
+.Pp
+The output format looks like this:
+.Bd -literal
+Title: The title.
+Author: The line with the author if it is set.
+Category: The line with the categories if it is set.
+Link: The line with the link if it is set.
+Enclosure: The line with the enclosure if it is set.
+
+The content converted to plain-text.
+
+<form feed character> if there are multiple items.
+.Ed
+.Sh SEE ALSO
+.Xr awk 1 ,
+.Xr less 1 ,
+.Xr lynx 1 ,
+.Xr sfeed_curses 1
+.Sh AUTHORS
+.An Hiltjo Posthuma Aq Mt hiltjo@codemadness.org
diff --git a/sfeed_curses.1 b/sfeed_curses.1
@@ -0,0 +1,329 @@
+.Dd February 24, 2022
+.Dt SFEED_CURSES 1
+.Os
+.Sh NAME
+.Nm sfeed_curses
+.Nd curses UI for viewing feed data
+.Sh SYNOPSIS
+.Nm
+.Op Ar
+.Sh DESCRIPTION
+.Nm
+formats feed data (TSV) from
+.Xr sfeed 1
+from stdin or for each
+.Ar file
+into a curses UI.
+If one or more
+.Ar file
+arguments are specified then the basename of the
+.Ar file
+is used as the feed name in the output such as the feeds sidebar.
+The
+.Ar file
+arguments are processed and shown in the specified argument order in the feeds
+sidebar.
+If no
+.Ar file
+arguments are specified then the data is read from stdin and the feed name is
+"stdin" and no sidebar is visible by default in this case.
+.Pp
+Items with a timestamp from the last day compared to the system time at the
+time of loading the feed are marked as new and bold.
+There is also an alternative mode available to mark items as read by matching
+it against a list of URLs from a plain-text file.
+Items with an enclosure are marked with a @ symbol.
+.Pp
+.Nm
+aligns the output.
+Make sure the environment variable
+.Ev LC_CTYPE
+is set to a UTF-8 locale, so it can determine the proper column-width
+per rune, using
+.Xr mbtowc 3
+and
+.Xr wcwidth 3 .
+.Sh KEYBINDS
+.Bl -tag -width Ds
+.It k, ARROW UP
+Go one row up.
+.It j, ARROW DOWN
+Go one row down.
+.It K
+Go to the previous bold row.
+.It J
+Go to the next bold row.
+.It h, ARROW LEFT
+Focus feeds pane.
+.It l, ARROW RIGHT
+Focus items pane.
+.It TAB
+Cycle focused pane (between feeds and items).
+.It g
+Go to the first row.
+.It G
+Go to the last row.
+.It PAGE UP, CTRL-B
+Scroll one page up.
+.It PAGE DOWN, CTRL-F, SPACE
+Scroll one page down.
+.It /
+Prompt for a new search and search forward (case-insensitive).
+.It ?
+Prompt for a new search and search backward (case-insensitive).
+.It n
+Search forward with the previously set search term.
+.It N
+Search backward with the previously set search term.
+.It \&[
+Go to the previous feed in the feeds pane and open it.
+.It ]
+Go to the next feed in the feeds pane and open it.
+.It CTRL-L
+Redraw screen.
+.It R
+Reload all feed files which were specified as arguments on startup.
+If
+.Ev SFEED_URL_FILE
+is set, it will reload the URLs from this file also.
+.It m
+Toggle mouse-mode.
+It supports xterm X10 and extended SGR encoding.
+.It s
+Toggle between monocle layout and the previous non-monocle layout.
+.It <
+Use a fixed sidebar size for the current layout and decrease the fixed width or
+height by 1 column.
+.It >
+Use a fixed sidebar size for the current layout and increase the fixed width or
+height by 1 column.
+.It =
+Reset the sidebar size to automaticly adjust for the current layout.
+With the vertical layout the width is the longest feed name with the item
+counts right-aligned.
+With the horizontal layout the height is half of the window height (minus the
+statusbar) or otherwise the total amount of visible feeds, whichever fits the
+best.
+.It t
+Toggle showing only feeds with new items in the sidebar.
+.It a, e, @
+Plumb URL of the enclosure.
+The URL is passed as a parameter to the program specified in
+.Ev SFEED_PLUMBER .
+.It o, ENTER, RETURN
+Feeds pane: load feed and its items.
+In the monocle layout it will also switch to the items pane after loading the
+feed items.
+Items pane: plumb current item URL, the URL is passed as a parameter to
+the program specified in
+.Ev SFEED_PLUMBER .
+.It c, p, |
+Pipe the whole TAB-Separated Value line to a program.
+This program can be specified with
+.Ev SFEED_PIPER .
+.It y
+Pipe the TAB-Separated Value field for yanking the URL to a program.
+This program can be specified with
+.Ev SFEED_YANKER .
+.It E
+Pipe the TAB-Separated Value field for yanking the enclosure to a program.
+This program can be specified with
+.Ev SFEED_YANKER .
+.It r
+Mark item as read.
+This will only work when
+.Ev SFEED_URL_FILE
+is set.
+.It u
+Mark item as unread.
+This will only work when
+.Ev SFEED_URL_FILE
+is set.
+.It f
+Mark all items of the current loaded feed as read.
+This will only work when
+.Ev SFEED_URL_FILE
+is set.
+.It F
+Mark all items of the current loaded feed as unread.
+This will only work when
+.Ev SFEED_URL_FILE
+is set.
+.It 1
+Set the current layout to a vertical mode.
+Showing a feeds sidebar to the left and the feed items to the right.
+.It 2
+Set the current layout to a horizontal mode.
+Showing a feeds sidebar on the top and the feed items on the bottom.
+.It 3
+Set the current layout to a monocle mode.
+Showing either a feeds or a feed items pane.
+.It q, EOF
+Quit
+.El
+.Sh MOUSE ACTIONS
+When mouse-mode is enabled the below actions are available.
+.Bl -tag -width Ds
+.It LEFT-CLICK
+Feeds pane: select and load the feed and its items.
+In the monocle layout it will also switch to the items pane after loading the
+feed items.
+Items pane: select item, when already selected then plumb it.
+.It RIGHT-CLICK
+Feeds pane: select feed, but do not load it.
+Items pane: pipe the item.
+.It SCROLL UP
+Scroll one page up.
+.It SCROLL DOWN
+Scroll one page down.
+.It FORWARD
+Switch to the items pane.
+.It BACKWARD
+Switch to the feeds pane.
+.El
+.Sh SIGNALS
+.Bl -tag -width Ds
+.It SIGHUP
+Reload all feed files which were specified as arguments on startup.
+If
+.Ev SFEED_URL_FILE
+is set, it will reload the URLs from this file also.
+.It SIGINT
+Interrupt: when searching it cancels the line editor, otherwise it quits.
+.It SIGTERM
+Quit
+.It SIGWINCH
+Resize the pane dimensions relative to the terminal size.
+.El
+.Sh ENVIRONMENT VARIABLES
+.Bl -tag -width Ds
+.It Ev SFEED_AUTOCMD
+Read and process a sequence of keys as input commands from this environment
+variable first, afterwards it reads from the tty as usual.
+This can be useful to automate certain actions at the start.
+.It Ev SFEED_PIPER
+A program where the whole TAB-Separated Value line is piped to.
+By default this is "sfeed_content".
+.It Ev SFEED_PIPER_INTERACTIVE
+Handle the program interactively in the same terminal or not.
+If set to "1" then before execution it restores the terminal attributes and
+.Nm
+will wait until the program is finished.
+If set to "0" then it will suppress stdout and stderr output.
+By default this is set to "1".
+.It Ev SFEED_PLUMBER
+A program that receives the link URL or enclosure URL as a parameter.
+By default this is "xdg-open".
+.It Ev SFEED_PLUMBER_INTERACTIVE
+Handle the program interactively in the same terminal or not.
+If set to "1" then before execution it restores the terminal attributes and
+.Nm
+will wait until the program is finished.
+If set to "0" then it will suppress stdout and stderr output.
+For example this option is useful to open a text-mode browser in the same
+terminal.
+By default this is set to "0".
+.It Ev SFEED_YANKER
+A program where the URL or enclosure field is piped to, to copy it to a
+clipboard.
+By default this is "xclip -r".
+.It Ev SFEED_YANKER_INTERACTIVE
+Handle the program interactively in the same terminal or not.
+If set to "1" then before execution it restores the terminal attributes and
+.Nm
+will wait until the program is finished.
+If set to "0" then it will suppress stdout and stderr output.
+By default this is set to "0".
+.It Ev SFEED_URL_FILE
+If this variable is set then a different mode is used to mark items as read,
+instead of checking the timestamp, which is the default.
+The value specified is a plain-text file containing a list of read URLs, one
+URL per line.
+This URL is matched on the link field if it is set, otherwise it is matched on
+the id field.
+.It Ev SFEED_MARK_READ
+A program to mark items as read if
+.Ev SFEED_URL_FILE
+is also set, if unset the default program used is "sfeed_markread read".
+The marked items are piped to the program line by line.
+If the feed item has a link then this line is the link field, otherwise it is
+the id field.
+The program is expected to merge items in a safe/transactional manner.
+The program should return the exit status 0 on success or non-zero on failure.
+.It Ev SFEED_MARK_UNREAD
+A program to mark items as unread if
+.Ev SFEED_URL_FILE
+is also set, if unset the default program used is "sfeed_markread unread".
+The unmarked items are piped to the program line by line.
+If the feed item has a link then this line is the link field, otherwise it is
+the id field.
+The program is expected to merge items in a safe/transactional manner.
+The program should return the exit status 0 on success or non-zero on failure.
+.It Ev SFEED_LAZYLOAD
+Lazyload items when reading the feed data from files.
+This can reduce memory usage but increases latency when seeking items,
+especially on slower disk drives.
+It can also cause a race-condition issue if the feed data on disk is changed
+while having the UI open and offsets for the lines are different.
+A workaround for the race-condition issue is by sending the SIGHUP signal to
+.Nm
+directly after the data was updated.
+This forces
+.Nm
+to reload the latest feed data and update the correct line offsets.
+By default this is set to "0".
+.It Ev SFEED_FEED_PATH
+This variable is set by
+.Nm
+when a feed is loaded.
+If the data was read from stdin this variable is unset.
+It can be used by the plumb or pipe program for scripting purposes.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh EXAMPLES
+.Bd -literal
+sfeed_curses ~/.sfeed/feeds/*
+.Ed
+.Pp
+Another example which shows some of the features
+.Nm
+has:
+.Bd -literal
+export SFEED_AUTOCMD="2tgo"
+export SFEED_URL_FILE="$HOME/.sfeed/urls"
+[ -f "$SFEED_URL_FILE" ] || touch "$SFEED_URL_FILE"
+sfeed_curses ~/.sfeed/feeds/*
+.Ed
+.Pp
+Which does the following:
+.Bl -enum
+.It
+Set the current layout to a horizontal mode ('2' keybind').
+Showing a feeds sidebar on the top and the feed items on the bottom.
+.It
+Toggle showing only feeds with new items in the sidebar ('t' keybind).
+.It
+Go to the first row in the current panel ('g' keybind).
+.It
+Load the current selected feed ('o' keybind').
+.It
+Set a file to use for managing read and unread items.
+This file is a plain-text file containing a list of read URLs, one URL per
+line.
+.It
+Check if this file for managing the read and unread items exists.
+If it doesn't exist yet then create an empty file.
+.It
+Start
+.Nm .
+.El
+.Sh SEE ALSO
+.Xr sfeed 1 ,
+.Xr sfeed_content 1 ,
+.Xr sfeed_markread 1 ,
+.Xr sfeed_plain 1 ,
+.Xr xclip 1 ,
+.Xr sfeed 5
+.Sh AUTHORS
+.An Hiltjo Posthuma Aq Mt hiltjo@codemadness.org
diff --git a/sfeed_curses.c b/sfeed_curses.c
@@ -0,0 +1,2365 @@
+#include <sys/ioctl.h>
+#include <sys/select.h>
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <time.h>
+#include <unistd.h>
+#include <wchar.h>
+
+#include "util.h"
+
+/* curses */
+#ifndef SFEED_MINICURSES
+#include <curses.h>
+#include <term.h>
+#else
+#include "minicurses.h"
+#endif
+
+#define LEN(a) sizeof((a))/sizeof((a)[0])
+#define MAX(a,b) ((a) > (b) ? (a) : (b))
+#define MIN(a,b) ((a) < (b) ? (a) : (b))
+
+#ifndef SFEED_DUMBTERM
+#define SCROLLBAR_SYMBOL_BAR "\xe2\x94\x82" /* symbol: "light vertical" */
+#define SCROLLBAR_SYMBOL_TICK " "
+#define LINEBAR_SYMBOL_BAR "\xe2\x94\x80" /* symbol: "light horizontal" */
+#define LINEBAR_SYMBOL_RIGHT "\xe2\x94\xa4" /* symbol: "light vertical and left" */
+#else
+#define SCROLLBAR_SYMBOL_BAR "|" /* symbol: "light vertical" */
+#define SCROLLBAR_SYMBOL_TICK " "
+#define LINEBAR_SYMBOL_BAR "-" /* symbol: "light horizontal" */
+#define LINEBAR_SYMBOL_RIGHT "|" /* symbol: "light vertical and left" */
+#endif
+
+/* color-theme */
+#ifndef SFEED_THEME
+#define SFEED_THEME "themes/mono.h"
+#endif
+#include SFEED_THEME
+
+enum {
+ ATTR_RESET = 0, ATTR_BOLD_ON = 1, ATTR_FAINT_ON = 2, ATTR_REVERSE_ON = 7
+};
+
+enum Layout {
+ LayoutVertical = 0, LayoutHorizontal, LayoutMonocle, LayoutLast
+};
+
+enum Pane { PaneFeeds, PaneItems, PaneLast };
+
+struct win {
+ int width; /* absolute width of the window */
+ int height; /* absolute height of the window */
+ int dirty; /* needs draw update: clears screen */
+};
+
+struct row {
+ char *text; /* text string, optional if using row_format() callback */
+ int bold;
+ void *data; /* data binding */
+};
+
+struct pane {
+ int x; /* absolute x position on the screen */
+ int y; /* absolute y position on the screen */
+ int width; /* absolute width of the pane */
+ int height; /* absolute height of the pane, should be > 0 */
+ off_t pos; /* focused row position */
+ struct row *rows;
+ size_t nrows; /* total amount of rows */
+ int focused; /* has focus or not */
+ int hidden; /* is visible or not */
+ int dirty; /* needs draw update */
+ /* (optional) callback functions */
+ struct row *(*row_get)(struct pane *, off_t);
+ char *(*row_format)(struct pane *, struct row *);
+ int (*row_match)(struct pane *, struct row *, const char *);
+};
+
+struct scrollbar {
+ int tickpos;
+ int ticksize;
+ int x; /* absolute x position on the screen */
+ int y; /* absolute y position on the screen */
+ int size; /* absolute size of the bar, should be > 0 */
+ int focused; /* has focus or not */
+ int hidden; /* is visible or not */
+ int dirty; /* needs draw update */
+};
+
+struct statusbar {
+ int x; /* absolute x position on the screen */
+ int y; /* absolute y position on the screen */
+ int width; /* absolute width of the bar */
+ char *text; /* data */
+ int hidden; /* is visible or not */
+ int dirty; /* needs draw update */
+};
+
+struct linebar {
+ int x; /* absolute x position on the screen */
+ int y; /* absolute y position on the screen */
+ int width; /* absolute width of the line */
+ int hidden; /* is visible or not */
+ int dirty; /* needs draw update */
+};
+
+/* /UI */
+
+struct item {
+ char *fields[FieldLast];
+ char *line; /* allocated split line */
+ /* field to match new items, if link is set match on link, else on id */
+ char *matchnew;
+ time_t timestamp;
+ int timeok;
+ int isnew;
+ off_t offset; /* line offset in file for lazyload */
+};
+
+struct items {
+ struct item *items; /* array of items */
+ size_t len; /* amount of items */
+ size_t cap; /* available capacity */
+};
+
+void alldirty(void);
+void cleanup(void);
+void draw(void);
+int getsidebarsize(void);
+void markread(struct pane *, off_t, off_t, int);
+void pane_draw(struct pane *);
+void sighandler(int);
+void updategeom(void);
+void updatesidebar(void);
+void urls_free(void);
+int urls_isnew(const char *);
+void urls_read(void);
+
+static struct linebar linebar;
+static struct statusbar statusbar;
+static struct pane panes[PaneLast];
+static struct scrollbar scrollbars[PaneLast]; /* each pane has a scrollbar */
+static struct win win;
+static size_t selpane;
+/* fixed sidebar size, < 0 is automatic */
+static int fixedsidebarsizes[LayoutLast] = { -1, -1, -1 };
+static int layout = LayoutVertical, prevlayout = LayoutVertical;
+static int onlynew = 0; /* show only new in sidebar */
+static int usemouse = 1; /* use xterm mouse tracking */
+
+static struct termios tsave; /* terminal state at startup */
+static struct termios tcur;
+static int devnullfd;
+static int istermsetup, needcleanup;
+
+static struct feed *feeds;
+static struct feed *curfeed;
+static size_t nfeeds; /* amount of feeds */
+static time_t comparetime;
+static char *urlfile, **urls;
+static size_t nurls;
+
+volatile sig_atomic_t sigstate = 0;
+
+static char *plumbercmd = "xdg-open"; /* env variable: $SFEED_PLUMBER */
+static char *pipercmd = "sfeed_content"; /* env variable: $SFEED_PIPER */
+static char *yankercmd = "xclip -r"; /* env variable: $SFEED_YANKER */
+static char *markreadcmd = "sfeed_markread read"; /* env variable: $SFEED_MARK_READ */
+static char *markunreadcmd = "sfeed_markread unread"; /* env variable: $SFEED_MARK_UNREAD */
+static char *cmdenv; /* env variable: $SFEED_AUTOCMD */
+static int plumberia = 0; /* env variable: $SFEED_PLUMBER_INTERACTIVE */
+static int piperia = 1; /* env variable: $SFEED_PIPER_INTERACTIVE */
+static int yankeria = 0; /* env variable: $SFEED_YANKER_INTERACTIVE */
+static int lazyload = 0; /* env variable: $SFEED_LAZYLOAD */
+
+int
+ttywritef(const char *fmt, ...)
+{
+ va_list ap;
+ int n;
+
+ va_start(ap, fmt);
+ n = vfprintf(stdout, fmt, ap);
+ va_end(ap);
+ fflush(stdout);
+
+ return n;
+}
+
+int
+ttywrite(const char *s)
+{
+ if (!s)
+ return 0; /* for tparm() returning NULL */
+ return write(1, s, strlen(s));
+}
+
+/* Hint for compilers and static analyzers that a function exits. */
+#ifndef __dead
+#define __dead
+#endif
+
+/* Print to stderr, call cleanup() and _exit(). */
+__dead void
+die(const char *fmt, ...)
+{
+ va_list ap;
+ int saved_errno;
+
+ saved_errno = errno;
+ cleanup();
+
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+
+ if (saved_errno)
+ fprintf(stderr, ": %s", strerror(saved_errno));
+ putc('\n', stderr);
+ fflush(stderr);
+
+ _exit(1);
+}
+
+void *
+erealloc(void *ptr, size_t size)
+{
+ void *p;
+
+ if (!(p = realloc(ptr, size)))
+ die("realloc");
+ return p;
+}
+
+void *
+ecalloc(size_t nmemb, size_t size)
+{
+ void *p;
+
+ if (!(p = calloc(nmemb, size)))
+ die("calloc");
+ return p;
+}
+
+char *
+estrdup(const char *s)
+{
+ char *p;
+
+ if (!(p = strdup(s)))
+ die("strdup");
+ return p;
+}
+
+/* Wrapper for tparm() which allows NULL parameter for str. */
+char *
+tparmnull(const char *str, long p1, long p2, long p3, long p4, long p5, long p6,
+ long p7, long p8, long p9)
+{
+ if (!str)
+ return NULL;
+ /* some tparm() implementations have char *, some have const char * */
+ return tparm((char *)str, p1, p2, p3, p4, p5, p6, p7, p8, p9);
+}
+
+/* Counts column width of character string. */
+size_t
+colw(const char *s)
+{
+ wchar_t wc;
+ size_t col = 0, i, slen;
+ int inc, rl, w;
+
+ slen = strlen(s);
+ for (i = 0; i < slen; i += inc) {
+ inc = 1; /* next byte */
+ if ((unsigned char)s[i] < 32) {
+ continue;
+ } else if ((unsigned char)s[i] >= 127) {
+ rl = mbtowc(&wc, &s[i], slen - i < 4 ? slen - i : 4);
+ inc = rl;
+ if (rl < 0) {
+ mbtowc(NULL, NULL, 0); /* reset state */
+ inc = 1; /* invalid, seek next byte */
+ w = 1; /* replacement char is one width */
+ } else if ((w = wcwidth(wc)) == -1) {
+ continue;
+ }
+ col += w;
+ } else {
+ col++;
+ }
+ }
+ return col;
+}
+
+/* Format `len` columns of characters. If string is shorter pad the rest
+ with characters `pad`. */
+int
+utf8pad(char *buf, size_t bufsiz, const char *s, size_t len, int pad)
+{
+ wchar_t wc;
+ size_t col = 0, i, slen, siz = 0;
+ int inc, rl, w;
+
+ if (!bufsiz)
+ return -1;
+ if (!len) {
+ buf[0] = '\0';
+ return 0;
+ }
+
+ slen = strlen(s);
+ for (i = 0; i < slen; i += inc) {
+ inc = 1; /* next byte */
+ if ((unsigned char)s[i] < 32)
+ continue;
+
+ rl = mbtowc(&wc, &s[i], slen - i < 4 ? slen - i : 4);
+ inc = rl;
+ if (rl < 0) {
+ mbtowc(NULL, NULL, 0); /* reset state */
+ inc = 1; /* invalid, seek next byte */
+ w = 1; /* replacement char is one width */
+ } else if ((w = wcwidth(wc)) == -1) {
+ continue;
+ }
+
+ if (col + w > len || (col + w == len && s[i + inc])) {
+ if (siz + 4 >= bufsiz)
+ return -1;
+ memcpy(&buf[siz], PAD_TRUNCATE_SYMBOL, sizeof(PAD_TRUNCATE_SYMBOL) - 1);
+ siz += sizeof(PAD_TRUNCATE_SYMBOL) - 1;
+ buf[siz] = '\0';
+ col++;
+ break;
+ } else if (rl < 0) {
+ if (siz + 4 >= bufsiz)
+ return -1;
+ memcpy(&buf[siz], UTF_INVALID_SYMBOL, sizeof(UTF_INVALID_SYMBOL) - 1);
+ siz += sizeof(UTF_INVALID_SYMBOL) - 1;
+ buf[siz] = '\0';
+ col++;
+ continue;
+ }
+ if (siz + inc + 1 >= bufsiz)
+ return -1;
+ memcpy(&buf[siz], &s[i], inc);
+ siz += inc;
+ buf[siz] = '\0';
+ col += w;
+ }
+
+ len -= col;
+ if (siz + len + 1 >= bufsiz)
+ return -1;
+ memset(&buf[siz], pad, len);
+ siz += len;
+ buf[siz] = '\0';
+
+ return 0;
+}
+
+void
+resetstate(void)
+{
+ ttywrite("\x1b""c"); /* rs1: reset title and state */
+}
+
+void
+updatetitle(void)
+{
+ unsigned long totalnew = 0, total = 0;
+ size_t i;
+
+ for (i = 0; i < nfeeds; i++) {
+ totalnew += feeds[i].totalnew;
+ total += feeds[i].total;
+ }
+ ttywritef("\x1b]2;(%lu/%lu) - sfeed_curses\x1b\\", totalnew, total);
+}
+
+void
+appmode(int on)
+{
+ ttywrite(tparmnull(on ? enter_ca_mode : exit_ca_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+}
+
+void
+mousemode(int on)
+{
+ ttywrite(on ? "\x1b[?1000h" : "\x1b[?1000l"); /* xterm X10 mouse mode */
+ ttywrite(on ? "\x1b[?1006h" : "\x1b[?1006l"); /* extended SGR mouse mode */
+}
+
+void
+cursormode(int on)
+{
+ ttywrite(tparmnull(on ? cursor_normal : cursor_invisible, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+}
+
+void
+cursormove(int x, int y)
+{
+ ttywrite(tparmnull(cursor_address, y, x, 0, 0, 0, 0, 0, 0, 0));
+}
+
+void
+cursorsave(void)
+{
+ /* do not save the cursor if it won't be restored anyway */
+ if (cursor_invisible)
+ ttywrite(tparmnull(save_cursor, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+}
+
+void
+cursorrestore(void)
+{
+ /* if the cursor cannot be hidden then move to a consistent position */
+ if (cursor_invisible)
+ ttywrite(tparmnull(restore_cursor, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+ else
+ cursormove(0, 0);
+}
+
+void
+attrmode(int mode)
+{
+ switch (mode) {
+ case ATTR_RESET:
+ ttywrite(tparmnull(exit_attribute_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+ break;
+ case ATTR_BOLD_ON:
+ ttywrite(tparmnull(enter_bold_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+ break;
+ case ATTR_FAINT_ON:
+ ttywrite(tparmnull(enter_dim_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+ break;
+ case ATTR_REVERSE_ON:
+ ttywrite(tparmnull(enter_reverse_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+ break;
+ default:
+ break;
+ }
+}
+
+void
+cleareol(void)
+{
+ ttywrite(tparmnull(clr_eol, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+}
+
+void
+clearscreen(void)
+{
+ ttywrite(tparmnull(clear_screen, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+}
+
+void
+cleanup(void)
+{
+ struct sigaction sa;
+
+ if (!needcleanup)
+ return;
+ needcleanup = 0;
+
+ if (istermsetup) {
+ resetstate();
+ cursormode(1);
+ appmode(0);
+ clearscreen();
+
+ if (usemouse)
+ mousemode(0);
+ }
+
+ /* restore terminal settings */
+ tcsetattr(0, TCSANOW, &tsave);
+
+ memset(&sa, 0, sizeof(sa));
+ sigemptyset(&sa.sa_mask);
+ sa.sa_flags = SA_RESTART; /* require BSD signal semantics */
+ sa.sa_handler = SIG_DFL;
+ sigaction(SIGWINCH, &sa, NULL);
+}
+
+void
+win_update(struct win *w, int width, int height)
+{
+ if (width != w->width || height != w->height)
+ w->dirty = 1;
+ w->width = width;
+ w->height = height;
+}
+
+void
+resizewin(void)
+{
+ struct winsize winsz;
+ int width, height;
+
+ if (ioctl(1, TIOCGWINSZ, &winsz) != -1) {
+ width = winsz.ws_col > 0 ? winsz.ws_col : 80;
+ height = winsz.ws_row > 0 ? winsz.ws_row : 24;
+ win_update(&win, width, height);
+ }
+ if (win.dirty)
+ alldirty();
+}
+
+void
+init(void)
+{
+ struct sigaction sa;
+ int errret = 1;
+
+ needcleanup = 1;
+
+ tcgetattr(0, &tsave);
+ memcpy(&tcur, &tsave, sizeof(tcur));
+ tcur.c_lflag &= ~(ECHO|ICANON);
+ tcur.c_cc[VMIN] = 1;
+ tcur.c_cc[VTIME] = 0;
+ tcsetattr(0, TCSANOW, &tcur);
+
+ if (!istermsetup &&
+ (setupterm(NULL, 1, &errret) != OK || errret != 1)) {
+ errno = 0;
+ die("setupterm: terminfo database or entry for $TERM not found");
+ }
+ istermsetup = 1;
+ resizewin();
+
+ appmode(1);
+ cursormode(0);
+
+ if (usemouse)
+ mousemode(usemouse);
+
+ memset(&sa, 0, sizeof(sa));
+ sigemptyset(&sa.sa_mask);
+ sa.sa_flags = SA_RESTART; /* require BSD signal semantics */
+ sa.sa_handler = sighandler;
+ sigaction(SIGHUP, &sa, NULL);
+ sigaction(SIGINT, &sa, NULL);
+ sigaction(SIGTERM, &sa, NULL);
+ sigaction(SIGWINCH, &sa, NULL);
+}
+
+void
+processexit(pid_t pid, int interactive)
+{
+ pid_t wpid;
+ struct sigaction sa;
+
+ memset(&sa, 0, sizeof(sa));
+ sigemptyset(&sa.sa_mask);
+ sa.sa_flags = SA_RESTART; /* require BSD signal semantics */
+ sa.sa_handler = SIG_IGN;
+ sigaction(SIGINT, &sa, NULL);
+
+ if (interactive) {
+ while ((wpid = wait(NULL)) >= 0 && wpid != pid)
+ ;
+ init();
+ updatesidebar();
+ updategeom();
+ updatetitle();
+ } else {
+ sa.sa_handler = sighandler;
+ sigaction(SIGINT, &sa, NULL);
+ }
+}
+
+/* Pipe item line or item field to a program.
+ If `field` is -1 then pipe the TSV line, else a specified field.
+ if `interactive` is 1 then cleanup and restore the tty and wait on the
+ process.
+ if 0 then don't do that and also write stdout and stderr to /dev/null. */
+void
+pipeitem(const char *cmd, struct item *item, int field, int interactive)
+{
+ FILE *fp;
+ pid_t pid;
+ int i, status;
+
+ if (interactive)
+ cleanup();
+
+ switch ((pid = fork())) {
+ case -1:
+ die("fork");
+ case 0:
+ if (!interactive) {
+ dup2(devnullfd, 1);
+ dup2(devnullfd, 2);
+ }
+
+ errno = 0;
+ if (!(fp = popen(cmd, "w")))
+ die("popen: %s", cmd);
+ if (field == -1) {
+ for (i = 0; i < FieldLast; i++) {
+ if (i)
+ putc('\t', fp);
+ fputs(item->fields[i], fp);
+ }
+ } else {
+ fputs(item->fields[field], fp);
+ }
+ putc('\n', fp);
+ status = pclose(fp);
+ status = WIFEXITED(status) ? WEXITSTATUS(status) : 127;
+ _exit(status);
+ default:
+ processexit(pid, interactive);
+ }
+}
+
+void
+forkexec(char *argv[], int interactive)
+{
+ pid_t pid;
+
+ if (interactive)
+ cleanup();
+
+ switch ((pid = fork())) {
+ case -1:
+ die("fork");
+ case 0:
+ if (!interactive) {
+ dup2(devnullfd, 1);
+ dup2(devnullfd, 2);
+ }
+ if (execvp(argv[0], argv) == -1)
+ _exit(1);
+ default:
+ processexit(pid, interactive);
+ }
+}
+
+struct row *
+pane_row_get(struct pane *p, off_t pos)
+{
+ if (pos < 0 || pos >= p->nrows)
+ return NULL;
+
+ if (p->row_get)
+ return p->row_get(p, pos);
+ return p->rows + pos;
+}
+
+char *
+pane_row_text(struct pane *p, struct row *row)
+{
+ /* custom formatter */
+ if (p->row_format)
+ return p->row_format(p, row);
+ return row->text;
+}
+
+int
+pane_row_match(struct pane *p, struct row *row, const char *s)
+{
+ if (p->row_match)
+ return p->row_match(p, row, s);
+ return (strcasestr(pane_row_text(p, row), s) != NULL);
+}
+
+void
+pane_row_draw(struct pane *p, off_t pos, int selected)
+{
+ struct row *row;
+
+ if (p->hidden || !p->width || !p->height ||
+ p->x >= win.width || p->y + (pos % p->height) >= win.height)
+ return;
+
+ row = pane_row_get(p, pos);
+
+ cursorsave();
+ cursormove(p->x, p->y + (pos % p->height));
+
+ if (p->focused)
+ THEME_ITEM_FOCUS();
+ else
+ THEME_ITEM_NORMAL();
+ if (row && row->bold)
+ THEME_ITEM_BOLD();
+ if (selected)
+ THEME_ITEM_SELECTED();
+ if (row) {
+ printutf8pad(stdout, pane_row_text(p, row), p->width, ' ');
+ fflush(stdout);
+ } else {
+ ttywritef("%-*.*s", p->width, p->width, "");
+ }
+
+ attrmode(ATTR_RESET);
+ cursorrestore();
+}
+
+void
+pane_setpos(struct pane *p, off_t pos)
+{
+ if (pos < 0)
+ pos = 0; /* clamp */
+ if (!p->nrows)
+ return; /* invalid */
+ if (pos >= p->nrows)
+ pos = p->nrows - 1; /* clamp */
+ if (pos == p->pos)
+ return; /* no change */
+
+ /* is on different scroll region? mark whole pane dirty */
+ if (((p->pos - (p->pos % p->height)) / p->height) !=
+ ((pos - (pos % p->height)) / p->height)) {
+ p->dirty = 1;
+ } else {
+ /* only redraw the 2 dirty rows */
+ pane_row_draw(p, p->pos, 0);
+ pane_row_draw(p, pos, 1);
+ }
+ p->pos = pos;
+}
+
+void
+pane_scrollpage(struct pane *p, int pages)
+{
+ off_t pos;
+
+ if (pages < 0) {
+ pos = p->pos - (-pages * p->height);
+ pos -= (p->pos % p->height);
+ pos += p->height - 1;
+ pane_setpos(p, pos);
+ } else if (pages > 0) {
+ pos = p->pos + (pages * p->height);
+ if ((p->pos % p->height))
+ pos -= (p->pos % p->height);
+ pane_setpos(p, pos);
+ }
+}
+
+void
+pane_scrolln(struct pane *p, int n)
+{
+ pane_setpos(p, p->pos + n);
+}
+
+void
+pane_setfocus(struct pane *p, int on)
+{
+ if (p->focused != on) {
+ p->focused = on;
+ p->dirty = 1;
+ }
+}
+
+void
+pane_draw(struct pane *p)
+{
+ off_t pos, y;
+
+ if (!p->dirty)
+ return;
+ p->dirty = 0;
+ if (p->hidden || !p->width || !p->height)
+ return;
+
+ /* draw visible rows */
+ pos = p->pos - (p->pos % p->height);
+ for (y = 0; y < p->height; y++)
+ pane_row_draw(p, y + pos, (y + pos) == p->pos);
+}
+
+void
+setlayout(int n)
+{
+ if (layout != LayoutMonocle)
+ prevlayout = layout; /* previous non-monocle layout */
+ layout = n;
+}
+
+void
+updategeom(void)
+{
+ int h, w, x = 0, y = 0;
+
+ panes[PaneFeeds].hidden = layout == LayoutMonocle && (selpane != PaneFeeds);
+ panes[PaneItems].hidden = layout == LayoutMonocle && (selpane != PaneItems);
+ linebar.hidden = layout != LayoutHorizontal;
+
+ w = win.width;
+ /* always reserve space for statusbar */
+ h = MAX(win.height - 1, 1);
+
+ panes[PaneFeeds].x = x;
+ panes[PaneFeeds].y = y;
+
+ switch (layout) {
+ case LayoutVertical:
+ panes[PaneFeeds].width = getsidebarsize();
+
+ x += panes[PaneFeeds].width;
+ w -= panes[PaneFeeds].width;
+
+ /* space for scrollbar if sidebar is visible */
+ w--;
+ x++;
+
+ panes[PaneFeeds].height = MAX(h, 1);
+ break;
+ case LayoutHorizontal:
+ panes[PaneFeeds].height = getsidebarsize();
+
+ h -= panes[PaneFeeds].height;
+ y += panes[PaneFeeds].height;
+
+ linebar.x = 0;
+ linebar.y = y;
+ linebar.width = win.width;
+
+ h--;
+ y++;
+
+ panes[PaneFeeds].width = MAX(w - 1, 0);
+ break;
+ case LayoutMonocle:
+ panes[PaneFeeds].height = MAX(h, 1);
+ panes[PaneFeeds].width = MAX(w - 1, 0);
+ break;
+ }
+
+ panes[PaneItems].x = x;
+ panes[PaneItems].y = y;
+ panes[PaneItems].width = MAX(w - 1, 0);
+ panes[PaneItems].height = MAX(h, 1);
+ if (x >= win.width || y + 1 >= win.height)
+ panes[PaneItems].hidden = 1;
+
+ scrollbars[PaneFeeds].x = panes[PaneFeeds].x + panes[PaneFeeds].width;
+ scrollbars[PaneFeeds].y = panes[PaneFeeds].y;
+ scrollbars[PaneFeeds].size = panes[PaneFeeds].height;
+ scrollbars[PaneFeeds].hidden = panes[PaneFeeds].hidden;
+
+ scrollbars[PaneItems].x = panes[PaneItems].x + panes[PaneItems].width;
+ scrollbars[PaneItems].y = panes[PaneItems].y;
+ scrollbars[PaneItems].size = panes[PaneItems].height;
+ scrollbars[PaneItems].hidden = panes[PaneItems].hidden;
+
+ statusbar.width = win.width;
+ statusbar.x = 0;
+ statusbar.y = MAX(win.height - 1, 0);
+
+ alldirty();
+}
+
+void
+scrollbar_setfocus(struct scrollbar *s, int on)
+{
+ if (s->focused != on) {
+ s->focused = on;
+ s->dirty = 1;
+ }
+}
+
+void
+scrollbar_update(struct scrollbar *s, off_t pos, off_t nrows, int pageheight)
+{
+ int tickpos = 0, ticksize = 0;
+
+ /* do not show a scrollbar if all items fit on the page */
+ if (nrows > pageheight) {
+ ticksize = s->size / ((double)nrows / (double)pageheight);
+ if (ticksize == 0)
+ ticksize = 1;
+
+ tickpos = (pos / (double)nrows) * (double)s->size;
+
+ /* fixup due to cell precision */
+ if (pos + pageheight >= nrows ||
+ tickpos + ticksize >= s->size)
+ tickpos = s->size - ticksize;
+ }
+
+ if (s->tickpos != tickpos || s->ticksize != ticksize)
+ s->dirty = 1;
+ s->tickpos = tickpos;
+ s->ticksize = ticksize;
+}
+
+void
+scrollbar_draw(struct scrollbar *s)
+{
+ off_t y;
+
+ if (!s->dirty)
+ return;
+ s->dirty = 0;
+ if (s->hidden || !s->size || s->x >= win.width || s->y >= win.height)
+ return;
+
+ cursorsave();
+
+ /* draw bar (not tick) */
+ if (s->focused)
+ THEME_SCROLLBAR_FOCUS();
+ else
+ THEME_SCROLLBAR_NORMAL();
+ for (y = 0; y < s->size; y++) {
+ if (y >= s->tickpos && y < s->tickpos + s->ticksize)
+ continue; /* skip tick */
+ cursormove(s->x, s->y + y);
+ ttywrite(SCROLLBAR_SYMBOL_BAR);
+ }
+
+ /* draw tick */
+ if (s->focused)
+ THEME_SCROLLBAR_TICK_FOCUS();
+ else
+ THEME_SCROLLBAR_TICK_NORMAL();
+ for (y = s->tickpos; y < s->size && y < s->tickpos + s->ticksize; y++) {
+ cursormove(s->x, s->y + y);
+ ttywrite(SCROLLBAR_SYMBOL_TICK);
+ }
+
+ attrmode(ATTR_RESET);
+ cursorrestore();
+}
+
+int
+readch(void)
+{
+ unsigned char b;
+ fd_set readfds;
+ struct timeval tv;
+
+ if (cmdenv && *cmdenv) {
+ b = *(cmdenv++); /* $SFEED_AUTOCMD */
+ return (int)b;
+ }
+
+ for (;;) {
+ FD_ZERO(&readfds);
+ FD_SET(0, &readfds);
+ tv.tv_sec = 0;
+ tv.tv_usec = 250000; /* 250ms */
+ switch (select(1, &readfds, NULL, NULL, &tv)) {
+ case -1:
+ if (errno != EINTR)
+ die("select");
+ return -2; /* EINTR: like a signal */
+ case 0:
+ return -3; /* time-out */
+ }
+
+ switch (read(0, &b, 1)) {
+ case -1: die("read");
+ case 0: return EOF;
+ default: return (int)b;
+ }
+ }
+}
+
+char *
+lineeditor(void)
+{
+ char *input = NULL;
+ size_t cap = 0, nchars = 0;
+ int ch;
+
+ for (;;) {
+ if (nchars + 2 >= cap) {
+ cap = cap ? cap * 2 : 32;
+ input = erealloc(input, cap);
+ }
+
+ ch = readch();
+ if (ch == EOF || ch == '\r' || ch == '\n') {
+ input[nchars] = '\0';
+ break;
+ } else if (ch == '\b' || ch == 0x7f) {
+ if (!nchars)
+ continue;
+ input[--nchars] = '\0';
+ ttywrite("\b \b"); /* back, blank, back */
+ } else if (ch >= ' ') {
+ input[nchars] = ch;
+ input[nchars + 1] = '\0';
+ ttywrite(&input[nchars]);
+ nchars++;
+ } else if (ch < 0) {
+ switch (sigstate) {
+ case 0:
+ case SIGWINCH:
+ /* continue editing: process signal later */
+ continue;
+ case SIGINT:
+ /* cancel prompt, but do not quit */
+ sigstate = 0; /* reset: do not handle it */
+ break;
+ default: /* other: SIGHUP, SIGTERM */
+ /* cancel prompt and handle signal after */
+ break;
+ }
+ free(input);
+ return NULL;
+ }
+ }
+ return input;
+}
+
+char *
+uiprompt(int x, int y, char *fmt, ...)
+{
+ va_list ap;
+ char *input, buf[32];
+
+ va_start(ap, fmt);
+ vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+
+ cursorsave();
+ cursormove(x, y);
+ THEME_INPUT_LABEL();
+ ttywrite(buf);
+ attrmode(ATTR_RESET);
+
+ THEME_INPUT_NORMAL();
+ cleareol();
+ cursormode(1);
+ cursormove(x + colw(buf) + 1, y);
+
+ input = lineeditor();
+ attrmode(ATTR_RESET);
+
+ cursormode(0);
+ cursorrestore();
+
+ return input;
+}
+
+void
+linebar_draw(struct linebar *b)
+{
+ int i;
+
+ if (!b->dirty)
+ return;
+ b->dirty = 0;
+ if (b->hidden || !b->width)
+ return;
+
+ cursorsave();
+ cursormove(b->x, b->y);
+ THEME_LINEBAR();
+ for (i = 0; i < b->width - 1; i++)
+ ttywrite(LINEBAR_SYMBOL_BAR);
+ ttywrite(LINEBAR_SYMBOL_RIGHT);
+ attrmode(ATTR_RESET);
+ cursorrestore();
+}
+
+void
+statusbar_draw(struct statusbar *s)
+{
+ if (!s->dirty)
+ return;
+ s->dirty = 0;
+ if (s->hidden || !s->width || s->x >= win.width || s->y >= win.height)
+ return;
+
+ cursorsave();
+ cursormove(s->x, s->y);
+ THEME_STATUSBAR();
+ /* terminals without xenl (eat newline glitch) mess up scrolling when
+ using the last cell on the last line on the screen. */
+ printutf8pad(stdout, s->text, s->width - (!eat_newline_glitch), ' ');
+ fflush(stdout);
+ attrmode(ATTR_RESET);
+ cursorrestore();
+}
+
+void
+statusbar_update(struct statusbar *s, const char *text)
+{
+ if (s->text && !strcmp(s->text, text))
+ return;
+
+ free(s->text);
+ s->text = estrdup(text);
+ s->dirty = 1;
+}
+
+/* Line to item, modifies and splits line in-place. */
+int
+linetoitem(char *line, struct item *item)
+{
+ char *fields[FieldLast];
+ time_t parsedtime;
+
+ item->line = line;
+ parseline(line, fields);
+ memcpy(item->fields, fields, sizeof(fields));
+ if (urlfile)
+ item->matchnew = estrdup(fields[fields[FieldLink][0] ? FieldLink : FieldId]);
+ else
+ item->matchnew = NULL;
+
+ parsedtime = 0;
+ if (!strtotime(fields[FieldUnixTimestamp], &parsedtime)) {
+ item->timestamp = parsedtime;
+ item->timeok = 1;
+ } else {
+ item->timestamp = 0;
+ item->timeok = 0;
+ }
+
+ return 0;
+}
+
+void
+feed_items_free(struct items *items)
+{
+ size_t i;
+
+ for (i = 0; i < items->len; i++) {
+ free(items->items[i].line);
+ free(items->items[i].matchnew);
+ }
+ free(items->items);
+ items->items = NULL;
+ items->len = 0;
+ items->cap = 0;
+}
+
+void
+feed_items_get(struct feed *f, FILE *fp, struct items *itemsret)
+{
+ struct item *item, *items = NULL;
+ char *line = NULL;
+ size_t cap, i, linesize = 0, nitems;
+ ssize_t linelen, n;
+ off_t offset;
+
+ cap = nitems = 0;
+ offset = 0;
+ for (i = 0; ; i++) {
+ if (i + 1 >= cap) {
+ cap = cap ? cap * 2 : 16;
+ items = erealloc(items, cap * sizeof(struct item));
+ }
+ if ((n = linelen = getline(&line, &linesize, fp)) > 0) {
+ item = &items[i];
+
+ item->offset = offset;
+ offset += linelen;
+
+ if (line[linelen - 1] == '\n')
+ line[--linelen] = '\0';
+
+ if (lazyload && f->path) {
+ linetoitem(line, item);
+
+ /* data is ignored here, will be lazy-loaded later. */
+ item->line = NULL;
+ memset(item->fields, 0, sizeof(item->fields));
+ } else {
+ linetoitem(estrdup(line), item);
+ }
+
+ nitems++;
+ }
+ if (ferror(fp))
+ die("getline: %s", f->name);
+ if (n <= 0 || feof(fp))
+ break;
+ }
+ itemsret->cap = cap;
+ itemsret->items = items;
+ itemsret->len = nitems;
+ free(line);
+}
+
+void
+updatenewitems(struct feed *f)
+{
+ struct pane *p;
+ struct row *row;
+ struct item *item;
+ size_t i;
+
+ p = &panes[PaneItems];
+ p->dirty = 1;
+ f->totalnew = 0;
+ for (i = 0; i < p->nrows; i++) {
+ row = &(p->rows[i]); /* do not use pane_row_get() */
+ item = row->data;
+ if (urlfile)
+ item->isnew = urls_isnew(item->matchnew);
+ else
+ item->isnew = (item->timeok && item->timestamp >= comparetime);
+ row->bold = item->isnew;
+ f->totalnew += item->isnew;
+ }
+ f->total = p->nrows;
+}
+
+void
+feed_load(struct feed *f, FILE *fp)
+{
+ /* static, reuse local buffers */
+ static struct items items;
+ struct pane *p;
+ size_t i;
+
+ feed_items_free(&items);
+ feed_items_get(f, fp, &items);
+ p = &panes[PaneItems];
+ p->pos = 0;
+ p->nrows = items.len;
+ free(p->rows);
+ p->rows = ecalloc(sizeof(p->rows[0]), items.len + 1);
+ for (i = 0; i < items.len; i++)
+ p->rows[i].data = &(items.items[i]); /* do not use pane_row_get() */
+
+ updatenewitems(f);
+}
+
+void
+feed_count(struct feed *f, FILE *fp)
+{
+ char *fields[FieldLast];
+ char *line = NULL;
+ size_t linesize = 0;
+ ssize_t linelen;
+ time_t parsedtime;
+
+ f->totalnew = f->total = 0;
+ while ((linelen = getline(&line, &linesize, fp)) > 0) {
+ if (line[linelen - 1] == '\n')
+ line[--linelen] = '\0';
+ parseline(line, fields);
+
+ if (urlfile) {
+ f->totalnew += urls_isnew(fields[fields[FieldLink][0] ? FieldLink : FieldId]);
+ } else {
+ parsedtime = 0;
+ if (!strtotime(fields[FieldUnixTimestamp], &parsedtime))
+ f->totalnew += (parsedtime >= comparetime);
+ }
+ f->total++;
+ }
+ if (ferror(fp))
+ die("getline: %s", f->name);
+ free(line);
+}
+
+void
+feed_setenv(struct feed *f)
+{
+ if (f && f->path)
+ setenv("SFEED_FEED_PATH", f->path, 1);
+ else
+ unsetenv("SFEED_FEED_PATH");
+}
+
+/* Change feed, have one file open, reopen file if needed. */
+void
+feeds_set(struct feed *f)
+{
+ if (curfeed) {
+ if (curfeed->path && curfeed->fp) {
+ fclose(curfeed->fp);
+ curfeed->fp = NULL;
+ }
+ }
+
+ if (f && f->path) {
+ if (!f->fp && !(f->fp = fopen(f->path, "rb")))
+ die("fopen: %s", f->path);
+ }
+
+ feed_setenv(f);
+
+ curfeed = f;
+}
+
+void
+feeds_load(struct feed *feeds, size_t nfeeds)
+{
+ struct feed *f;
+ size_t i;
+
+ errno = 0;
+ if ((comparetime = time(NULL)) == (time_t)-1)
+ die("time");
+ /* 1 day is old news */
+ comparetime -= 86400;
+
+ for (i = 0; i < nfeeds; i++) {
+ f = &feeds[i];
+
+ if (f->path) {
+ if (f->fp) {
+ if (fseek(f->fp, 0, SEEK_SET))
+ die("fseek: %s", f->path);
+ } else {
+ if (!(f->fp = fopen(f->path, "rb")))
+ die("fopen: %s", f->path);
+ }
+ }
+ if (!f->fp) {
+ /* reading from stdin, just recount new */
+ if (f == curfeed)
+ updatenewitems(f);
+ continue;
+ }
+
+ /* load first items, because of first selection or stdin. */
+ if (f == curfeed) {
+ feed_load(f, f->fp);
+ } else {
+ feed_count(f, f->fp);
+ if (f->path && f->fp) {
+ fclose(f->fp);
+ f->fp = NULL;
+ }
+ }
+ }
+}
+
+/* find row position of the feed if visible, else return -1 */
+off_t
+feeds_row_get(struct pane *p, struct feed *f)
+{
+ struct row *row;
+ struct feed *fr;
+ off_t pos;
+
+ for (pos = 0; pos < p->nrows; pos++) {
+ if (!(row = pane_row_get(p, pos)))
+ continue;
+ fr = row->data;
+ if (!strcmp(fr->name, f->name))
+ return pos;
+ }
+ return -1;
+}
+
+void
+feeds_reloadall(void)
+{
+ struct pane *p;
+ struct feed *f = NULL;
+ struct row *row;
+ off_t pos;
+
+ p = &panes[PaneFeeds];
+ if ((row = pane_row_get(p, p->pos)))
+ f = row->data;
+
+ pos = panes[PaneItems].pos; /* store numeric item position */
+ feeds_set(curfeed); /* close and reopen feed if possible */
+ urls_read();
+ feeds_load(feeds, nfeeds);
+ urls_free();
+ /* restore numeric item position */
+ pane_setpos(&panes[PaneItems], pos);
+ updatesidebar();
+ updatetitle();
+
+ /* try to find the same feed in the pane */
+ if (f && (pos = feeds_row_get(p, f)) != -1)
+ pane_setpos(p, pos);
+ else
+ pane_setpos(p, 0);
+}
+
+void
+feed_open_selected(struct pane *p)
+{
+ struct feed *f;
+ struct row *row;
+
+ if (!(row = pane_row_get(p, p->pos)))
+ return;
+ f = row->data;
+ feeds_set(f);
+ urls_read();
+ if (f->fp)
+ feed_load(f, f->fp);
+ urls_free();
+ /* redraw row: counts could be changed */
+ updatesidebar();
+ updatetitle();
+
+ if (layout == LayoutMonocle) {
+ selpane = PaneItems;
+ updategeom();
+ }
+}
+
+void
+feed_plumb_selected_item(struct pane *p, int field)
+{
+ struct row *row;
+ struct item *item;
+ char *cmd[] = { plumbercmd, NULL, NULL };
+
+ if (!(row = pane_row_get(p, p->pos)))
+ return;
+ markread(p, p->pos, p->pos, 1);
+ item = row->data;
+ cmd[1] = item->fields[field]; /* set first argument for plumber */
+ forkexec(cmd, plumberia);
+}
+
+void
+feed_pipe_selected_item(struct pane *p)
+{
+ struct row *row;
+ struct item *item;
+
+ if (!(row = pane_row_get(p, p->pos)))
+ return;
+ item = row->data;
+ markread(p, p->pos, p->pos, 1);
+ pipeitem(pipercmd, item, -1, piperia);
+}
+
+void
+feed_yank_selected_item(struct pane *p, int field)
+{
+ struct row *row;
+ struct item *item;
+
+ if (!(row = pane_row_get(p, p->pos)))
+ return;
+ item = row->data;
+ pipeitem(yankercmd, item, field, yankeria);
+}
+
+/* calculate optimal (default) size */
+int
+getsidebarsizedefault(void)
+{
+ struct feed *feed;
+ size_t i;
+ int len, size;
+
+ switch (layout) {
+ case LayoutVertical:
+ for (i = 0, size = 0; i < nfeeds; i++) {
+ feed = &feeds[i];
+ len = snprintf(NULL, 0, " (%lu/%lu)",
+ feed->totalnew, feed->total) +
+ colw(feed->name);
+ if (len > size)
+ size = len;
+
+ if (onlynew && feed->totalnew == 0)
+ continue;
+ }
+ return MAX(MIN(win.width - 1, size), 0);
+ case LayoutHorizontal:
+ for (i = 0, size = 0; i < nfeeds; i++) {
+ feed = &feeds[i];
+ if (onlynew && feed->totalnew == 0)
+ continue;
+ size++;
+ }
+ return MAX(MIN((win.height - 1) / 2, size), 1);
+ }
+ return 0;
+}
+
+int
+getsidebarsize(void)
+{
+ int size;
+
+ if ((size = fixedsidebarsizes[layout]) < 0)
+ size = getsidebarsizedefault();
+ return size;
+}
+
+void
+adjustsidebarsize(int n)
+{
+ int size;
+
+ if ((size = fixedsidebarsizes[layout]) < 0)
+ size = getsidebarsizedefault();
+ if (n > 0) {
+ if ((layout == LayoutVertical && size + 1 < win.width) ||
+ (layout == LayoutHorizontal && size + 1 < win.height))
+ size++;
+ } else if (n < 0) {
+ if ((layout == LayoutVertical && size > 0) ||
+ (layout == LayoutHorizontal && size > 1))
+ size--;
+ }
+
+ if (size != fixedsidebarsizes[layout]) {
+ fixedsidebarsizes[layout] = size;
+ updategeom();
+ }
+}
+
+void
+updatesidebar(void)
+{
+ struct pane *p;
+ struct row *row;
+ struct feed *feed;
+ size_t i, nrows;
+ int oldvalue = 0, newvalue = 0;
+
+ p = &panes[PaneFeeds];
+ if (!p->rows)
+ p->rows = ecalloc(sizeof(p->rows[0]), nfeeds + 1);
+
+ switch (layout) {
+ case LayoutVertical:
+ oldvalue = p->width;
+ newvalue = getsidebarsize();
+ p->width = newvalue;
+ break;
+ case LayoutHorizontal:
+ oldvalue = p->height;
+ newvalue = getsidebarsize();
+ p->height = newvalue;
+ break;
+ }
+
+ nrows = 0;
+ for (i = 0; i < nfeeds; i++) {
+ feed = &feeds[i];
+
+ row = &(p->rows[nrows]);
+ row->bold = (feed->totalnew > 0);
+ row->data = feed;
+
+ if (onlynew && feed->totalnew == 0)
+ continue;
+
+ nrows++;
+ }
+ p->nrows = nrows;
+
+ if (oldvalue != newvalue)
+ updategeom();
+ else
+ p->dirty = 1;
+
+ if (!p->nrows)
+ p->pos = 0;
+ else if (p->pos >= p->nrows)
+ p->pos = p->nrows - 1;
+}
+
+void
+sighandler(int signo)
+{
+ switch (signo) {
+ case SIGHUP:
+ case SIGINT:
+ case SIGTERM:
+ case SIGWINCH:
+ /* SIGTERM is more important, do not override it */
+ if (sigstate != SIGTERM)
+ sigstate = signo;
+ break;
+ }
+}
+
+void
+alldirty(void)
+{
+ win.dirty = 1;
+ panes[PaneFeeds].dirty = 1;
+ panes[PaneItems].dirty = 1;
+ scrollbars[PaneFeeds].dirty = 1;
+ scrollbars[PaneItems].dirty = 1;
+ linebar.dirty = 1;
+ statusbar.dirty = 1;
+}
+
+void
+draw(void)
+{
+ struct row *row;
+ struct item *item;
+ size_t i;
+
+ if (win.dirty)
+ win.dirty = 0;
+
+ for (i = 0; i < LEN(panes); i++) {
+ pane_setfocus(&panes[i], i == selpane);
+ pane_draw(&panes[i]);
+
+ /* each pane has a scrollbar */
+ scrollbar_setfocus(&scrollbars[i], i == selpane);
+ scrollbar_update(&scrollbars[i],
+ panes[i].pos - (panes[i].pos % panes[i].height),
+ panes[i].nrows, panes[i].height);
+ scrollbar_draw(&scrollbars[i]);
+ }
+
+ linebar_draw(&linebar);
+
+ /* if item selection text changed then update the status text */
+ if ((row = pane_row_get(&panes[PaneItems], panes[PaneItems].pos))) {
+ item = row->data;
+ statusbar_update(&statusbar, item->fields[FieldLink]);
+ } else {
+ statusbar_update(&statusbar, "");
+ }
+ statusbar_draw(&statusbar);
+}
+
+void
+mousereport(int button, int release, int keymask, int x, int y)
+{
+ struct pane *p;
+ size_t i;
+ off_t pos;
+ int changedpane, dblclick;
+
+ if (!usemouse || release || button == -1)
+ return;
+
+ for (i = 0; i < LEN(panes); i++) {
+ p = &panes[i];
+ if (p->hidden || !p->width || !p->height)
+ continue;
+
+ /* these button actions are done regardless of the position */
+ switch (button) {
+ case 7: /* side-button: backward */
+ if (selpane == PaneFeeds)
+ return;
+ selpane = PaneFeeds;
+ if (layout == LayoutMonocle)
+ updategeom();
+ return;
+ case 8: /* side-button: forward */
+ if (selpane == PaneItems)
+ return;
+ selpane = PaneItems;
+ if (layout == LayoutMonocle)
+ updategeom();
+ return;
+ }
+
+ /* check if mouse position is in pane or in its scrollbar */
+ if (!(x >= p->x && x < p->x + p->width + (!scrollbars[i].hidden) &&
+ y >= p->y && y < p->y + p->height))
+ continue;
+
+ changedpane = (selpane != i);
+ selpane = i;
+ /* relative position on screen */
+ pos = y - p->y + p->pos - (p->pos % p->height);
+ dblclick = (pos == p->pos); /* clicking the already selected row */
+
+ switch (button) {
+ case 0: /* left-click */
+ if (!p->nrows || pos >= p->nrows)
+ break;
+ pane_setpos(p, pos);
+ if (i == PaneFeeds)
+ feed_open_selected(&panes[PaneFeeds]);
+ else if (i == PaneItems && dblclick && !changedpane)
+ feed_plumb_selected_item(&panes[PaneItems], FieldLink);
+ break;
+ case 2: /* right-click */
+ if (!p->nrows || pos >= p->nrows)
+ break;
+ pane_setpos(p, pos);
+ if (i == PaneItems)
+ feed_pipe_selected_item(&panes[PaneItems]);
+ break;
+ case 3: /* scroll up */
+ case 4: /* scroll down */
+ pane_scrollpage(p, button == 3 ? -1 : +1);
+ break;
+ }
+ return; /* do not bubble events */
+ }
+}
+
+/* Custom formatter for feed row. */
+char *
+feed_row_format(struct pane *p, struct row *row)
+{
+ /* static, reuse local buffers */
+ static char *bufw, *text;
+ static size_t bufwsize, textsize;
+ struct feed *feed;
+ size_t needsize;
+ char counts[128];
+ int len, w;
+
+ feed = row->data;
+
+ /* align counts to the right and pad the rest with spaces */
+ len = snprintf(counts, sizeof(counts), "(%lu/%lu)",
+ feed->totalnew, feed->total);
+ if (len > p->width)
+ w = p->width;
+ else
+ w = p->width - len;
+
+ needsize = (w + 1) * 4;
+ if (needsize > bufwsize) {
+ bufw = erealloc(bufw, needsize);
+ bufwsize = needsize;
+ }
+
+ needsize = bufwsize + sizeof(counts) + 1;
+ if (needsize > textsize) {
+ text = erealloc(text, needsize);
+ textsize = needsize;
+ }
+
+ if (utf8pad(bufw, bufwsize, feed->name, w, ' ') != -1)
+ snprintf(text, textsize, "%s%s", bufw, counts);
+ else
+ text[0] = '\0';
+
+ return text;
+}
+
+int
+feed_row_match(struct pane *p, struct row *row, const char *s)
+{
+ struct feed *feed;
+
+ feed = row->data;
+
+ return (strcasestr(feed->name, s) != NULL);
+}
+
+struct row *
+item_row_get(struct pane *p, off_t pos)
+{
+ struct row *itemrow;
+ struct item *item;
+ struct feed *f;
+ char *line = NULL;
+ size_t linesize = 0;
+ ssize_t linelen;
+
+ itemrow = p->rows + pos;
+ item = itemrow->data;
+
+ f = curfeed;
+ if (f && f->path && f->fp && !item->line) {
+ if (fseek(f->fp, item->offset, SEEK_SET))
+ die("fseek: %s", f->path);
+
+ if ((linelen = getline(&line, &linesize, f->fp)) <= 0) {
+ if (ferror(f->fp))
+ die("getline: %s", f->path);
+ return NULL;
+ }
+
+ if (line[linelen - 1] == '\n')
+ line[--linelen] = '\0';
+
+ linetoitem(estrdup(line), item);
+ free(line);
+
+ itemrow->data = item;
+ }
+ return itemrow;
+}
+
+/* Custom formatter for item row. */
+char *
+item_row_format(struct pane *p, struct row *row)
+{
+ /* static, reuse local buffers */
+ static char *text;
+ static size_t textsize;
+ struct item *item;
+ struct tm tm;
+ size_t needsize;
+
+ item = row->data;
+
+ needsize = strlen(item->fields[FieldTitle]) + 21;
+ if (needsize > textsize) {
+ text = erealloc(text, needsize);
+ textsize = needsize;
+ }
+
+ if (item->timeok && localtime_r(&(item->timestamp), &tm)) {
+ snprintf(text, textsize, "%c %04d-%02d-%02d %02d:%02d %s",
+ item->fields[FieldEnclosure][0] ? '@' : ' ',
+ tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
+ tm.tm_hour, tm.tm_min, item->fields[FieldTitle]);
+ } else {
+ snprintf(text, textsize, "%c %s",
+ item->fields[FieldEnclosure][0] ? '@' : ' ',
+ item->fields[FieldTitle]);
+ }
+
+ return text;
+}
+
+void
+markread(struct pane *p, off_t from, off_t to, int isread)
+{
+ struct row *row;
+ struct item *item;
+ FILE *fp;
+ off_t i;
+ const char *cmd;
+ int isnew = !isread, pid, wpid, status, visstart;
+
+ if (!urlfile || !p->nrows)
+ return;
+
+ cmd = isread ? markreadcmd : markunreadcmd;
+
+ switch ((pid = fork())) {
+ case -1:
+ die("fork");
+ case 0:
+ dup2(devnullfd, 1);
+ dup2(devnullfd, 2);
+
+ errno = 0;
+ if (!(fp = popen(cmd, "w")))
+ die("popen: %s", cmd);
+
+ for (i = from; i <= to && i < p->nrows; i++) {
+ /* do not use pane_row_get(): no need for lazyload */
+ row = &(p->rows[i]);
+ item = row->data;
+ if (item->isnew != isnew) {
+ fputs(item->matchnew, fp);
+ putc('\n', fp);
+ }
+ }
+ status = pclose(fp);
+ status = WIFEXITED(status) ? WEXITSTATUS(status) : 127;
+ _exit(status);
+ default:
+ while ((wpid = wait(&status)) >= 0 && wpid != pid)
+ ;
+
+ /* fail: exit statuscode was non-zero */
+ if (status)
+ break;
+
+ visstart = p->pos - (p->pos % p->height); /* visible start */
+ for (i = from; i <= to && i < p->nrows; i++) {
+ row = &(p->rows[i]);
+ item = row->data;
+ if (item->isnew == isnew)
+ continue;
+
+ row->bold = item->isnew = isnew;
+ curfeed->totalnew += isnew ? 1 : -1;
+
+ /* draw if visible on screen */
+ if (i >= visstart && i < visstart + p->height)
+ pane_row_draw(p, i, i == p->pos);
+ }
+ updatesidebar();
+ updatetitle();
+ }
+}
+
+int
+urls_cmp(const void *v1, const void *v2)
+{
+ return strcmp(*((char **)v1), *((char **)v2));
+}
+
+int
+urls_isnew(const char *url)
+{
+ return (!nurls ||
+ bsearch(&url, urls, nurls, sizeof(char *), urls_cmp) == NULL);
+}
+
+void
+urls_free(void)
+{
+ while (nurls > 0)
+ free(urls[--nurls]);
+ free(urls);
+ urls = NULL;
+ nurls = 0;
+}
+
+void
+urls_read(void)
+{
+ FILE *fp;
+ char *line = NULL;
+ size_t linesiz = 0, cap = 0;
+ ssize_t n;
+
+ urls_free();
+
+ if (!urlfile)
+ return;
+ if (!(fp = fopen(urlfile, "rb")))
+ die("fopen: %s", urlfile);
+
+ while ((n = getline(&line, &linesiz, fp)) > 0) {
+ if (line[n - 1] == '\n')
+ line[--n] = '\0';
+ if (nurls + 1 >= cap) {
+ cap = cap ? cap * 2 : 16;
+ urls = erealloc(urls, cap * sizeof(char *));
+ }
+ urls[nurls++] = estrdup(line);
+ }
+ if (ferror(fp))
+ die("getline: %s", urlfile);
+ fclose(fp);
+ free(line);
+
+ if (nurls > 0)
+ qsort(urls, nurls, sizeof(char *), urls_cmp);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct pane *p;
+ struct feed *f;
+ struct row *row;
+ char *name, *tmp;
+ char *search = NULL; /* search text */
+ int button, ch, fd, i, keymask, release, x, y;
+ off_t pos;
+
+#ifdef __OpenBSD__
+ if (pledge("stdio rpath tty proc exec", NULL) == -1)
+ die("pledge");
+#endif
+
+ setlocale(LC_CTYPE, "");
+
+ if ((tmp = getenv("SFEED_PLUMBER")))
+ plumbercmd = tmp;
+ if ((tmp = getenv("SFEED_PIPER")))
+ pipercmd = tmp;
+ if ((tmp = getenv("SFEED_YANKER")))
+ yankercmd = tmp;
+ if ((tmp = getenv("SFEED_PLUMBER_INTERACTIVE")))
+ plumberia = !strcmp(tmp, "1");
+ if ((tmp = getenv("SFEED_PIPER_INTERACTIVE")))
+ piperia = !strcmp(tmp, "1");
+ if ((tmp = getenv("SFEED_YANKER_INTERACTIVE")))
+ yankeria = !strcmp(tmp, "1");
+ if ((tmp = getenv("SFEED_MARK_READ")))
+ markreadcmd = tmp;
+ if ((tmp = getenv("SFEED_MARK_UNREAD")))
+ markunreadcmd = tmp;
+ if ((tmp = getenv("SFEED_LAZYLOAD")))
+ lazyload = !strcmp(tmp, "1");
+ urlfile = getenv("SFEED_URL_FILE"); /* can be NULL */
+ cmdenv = getenv("SFEED_AUTOCMD"); /* can be NULL */
+
+ setlayout(argc <= 1 ? LayoutMonocle : LayoutVertical);
+ selpane = layout == LayoutMonocle ? PaneItems : PaneFeeds;
+
+ panes[PaneFeeds].row_format = feed_row_format;
+ panes[PaneFeeds].row_match = feed_row_match;
+ panes[PaneItems].row_format = item_row_format;
+ if (lazyload)
+ panes[PaneItems].row_get = item_row_get;
+
+ feeds = ecalloc(argc, sizeof(struct feed));
+ if (argc == 1) {
+ nfeeds = 1;
+ f = &feeds[0];
+ f->name = "stdin";
+ if (!(f->fp = fdopen(0, "rb")))
+ die("fdopen");
+ } else {
+ for (i = 1; i < argc; i++) {
+ f = &feeds[i - 1];
+ f->path = argv[i];
+ name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i];
+ f->name = name;
+ }
+ nfeeds = argc - 1;
+ }
+ feeds_set(&feeds[0]);
+ urls_read();
+ feeds_load(feeds, nfeeds);
+ urls_free();
+
+ if (!isatty(0)) {
+ if ((fd = open("/dev/tty", O_RDONLY)) == -1)
+ die("open: /dev/tty");
+ if (dup2(fd, 0) == -1)
+ die("dup2(%d, 0): /dev/tty -> stdin", fd);
+ close(fd);
+ }
+ if (argc == 1)
+ feeds[0].fp = NULL;
+
+ if ((devnullfd = open("/dev/null", O_WRONLY)) == -1)
+ die("open: /dev/null");
+
+ init();
+ updatesidebar();
+ updategeom();
+ updatetitle();
+ draw();
+
+ while (1) {
+ if ((ch = readch()) < 0)
+ goto event;
+ switch (ch) {
+ case '\x1b':
+ if ((ch = readch()) < 0)
+ goto event;
+ if (ch != '[' && ch != 'O')
+ continue; /* unhandled */
+ if ((ch = readch()) < 0)
+ goto event;
+ switch (ch) {
+ case 'M': /* mouse: X10 encoding */
+ if ((ch = readch()) < 0)
+ goto event;
+ button = ch - 32;
+ if ((ch = readch()) < 0)
+ goto event;
+ x = ch - 32;
+ if ((ch = readch()) < 0)
+ goto event;
+ y = ch - 32;
+
+ keymask = button & (4 | 8 | 16); /* shift, meta, ctrl */
+ button &= ~keymask; /* unset key mask */
+
+ /* button numbers (0 - 2) encoded in lowest 2 bits
+ release does not indicate which button (so set to 0).
+ Handle extended buttons like scrollwheels
+ and side-buttons by each range. */
+ release = 0;
+ if (button == 3) {
+ button = -1;
+ release = 1;
+ } else if (button >= 128) {
+ button -= 121;
+ } else if (button >= 64) {
+ button -= 61;
+ }
+ mousereport(button, release, keymask, x - 1, y - 1);
+ break;
+ case '<': /* mouse: SGR encoding */
+ for (button = 0; ; button *= 10, button += ch - '0') {
+ if ((ch = readch()) < 0)
+ goto event;
+ else if (ch == ';')
+ break;
+ }
+ for (x = 0; ; x *= 10, x += ch - '0') {
+ if ((ch = readch()) < 0)
+ goto event;
+ else if (ch == ';')
+ break;
+ }
+ for (y = 0; ; y *= 10, y += ch - '0') {
+ if ((ch = readch()) < 0)
+ goto event;
+ else if (ch == 'm' || ch == 'M')
+ break; /* release or press */
+ }
+ release = ch == 'm';
+ keymask = button & (4 | 8 | 16); /* shift, meta, ctrl */
+ button &= ~keymask; /* unset key mask */
+
+ if (button >= 128)
+ button -= 121;
+ else if (button >= 64)
+ button -= 61;
+
+ mousereport(button, release, keymask, x - 1, y - 1);
+ break;
+ case 'A': goto keyup; /* arrow up */
+ case 'B': goto keydown; /* arrow down */
+ case 'C': goto keyright; /* arrow right */
+ case 'D': goto keyleft; /* arrow left */
+ case 'F': goto endpos; /* end */
+ case 'H': goto startpos; /* home */
+ default:
+ if (!(ch >= '0' && ch <= '9'))
+ break;
+ for (i = ch - '0'; ;) {
+ if ((ch = readch()) < 0) {
+ goto event;
+ } else if (ch >= '0' && ch <= '9') {
+ i = (i * 10) + (ch - '0');
+ continue;
+ } else if (ch == '~') { /* DEC: ESC [ num ~ */
+ switch (i) {
+ case 1: goto startpos; /* home */
+ case 4: goto endpos; /* end */
+ case 5: goto prevpage; /* page up */
+ case 6: goto nextpage; /* page down */
+ case 7: goto startpos; /* home: urxvt */
+ case 8: goto endpos; /* end: urxvt */
+ }
+ }
+ break;
+ }
+ }
+ break;
+keyup:
+ case 'k':
+ pane_scrolln(&panes[selpane], -1);
+ break;
+keydown:
+ case 'j':
+ pane_scrolln(&panes[selpane], +1);
+ break;
+keyleft:
+ case 'h':
+ if (selpane == PaneFeeds)
+ break;
+ selpane = PaneFeeds;
+ if (layout == LayoutMonocle)
+ updategeom();
+ break;
+keyright:
+ case 'l':
+ if (selpane == PaneItems)
+ break;
+ selpane = PaneItems;
+ if (layout == LayoutMonocle)
+ updategeom();
+ break;
+ case 'K':
+ p = &panes[selpane];
+ if (!p->nrows)
+ break;
+ for (pos = p->pos - 1; pos >= 0; pos--) {
+ if ((row = pane_row_get(p, pos)) && row->bold) {
+ pane_setpos(p, pos);
+ break;
+ }
+ }
+ break;
+ case 'J':
+ p = &panes[selpane];
+ if (!p->nrows)
+ break;
+ for (pos = p->pos + 1; pos < p->nrows; pos++) {
+ if ((row = pane_row_get(p, pos)) && row->bold) {
+ pane_setpos(p, pos);
+ break;
+ }
+ }
+ break;
+ case '\t':
+ selpane = selpane == PaneFeeds ? PaneItems : PaneFeeds;
+ if (layout == LayoutMonocle)
+ updategeom();
+ break;
+startpos:
+ case 'g':
+ pane_setpos(&panes[selpane], 0);
+ break;
+endpos:
+ case 'G':
+ p = &panes[selpane];
+ if (p->nrows)
+ pane_setpos(p, p->nrows - 1);
+ break;
+prevpage:
+ case 2: /* ^B */
+ pane_scrollpage(&panes[selpane], -1);
+ break;
+nextpage:
+ case ' ':
+ case 6: /* ^F */
+ pane_scrollpage(&panes[selpane], +1);
+ break;
+ case '[':
+ case ']':
+ pane_scrolln(&panes[PaneFeeds], ch == '[' ? -1 : +1);
+ feed_open_selected(&panes[PaneFeeds]);
+ break;
+ case '/': /* new search (forward) */
+ case '?': /* new search (backward) */
+ case 'n': /* search again (forward) */
+ case 'N': /* search again (backward) */
+ p = &panes[selpane];
+
+ /* prompt for new input */
+ if (ch == '?' || ch == '/') {
+ tmp = ch == '?' ? "backward" : "forward";
+ free(search);
+ search = uiprompt(statusbar.x, statusbar.y,
+ "Search (%s):", tmp);
+ statusbar.dirty = 1;
+ }
+ if (!search || !p->nrows)
+ break;
+
+ if (ch == '/' || ch == 'n') {
+ /* forward */
+ for (pos = p->pos + 1; pos < p->nrows; pos++) {
+ if (pane_row_match(p, pane_row_get(p, pos), search)) {
+ pane_setpos(p, pos);
+ break;
+ }
+ }
+ } else {
+ /* backward */
+ for (pos = p->pos - 1; pos >= 0; pos--) {
+ if (pane_row_match(p, pane_row_get(p, pos), search)) {
+ pane_setpos(p, pos);
+ break;
+ }
+ }
+ }
+ break;
+ case 12: /* ^L, redraw */
+ alldirty();
+ break;
+ case 'R': /* reload all files */
+ feeds_reloadall();
+ break;
+ case 'a': /* attachment */
+ case 'e': /* enclosure */
+ case '@':
+ if (selpane == PaneItems)
+ feed_plumb_selected_item(&panes[selpane], FieldEnclosure);
+ break;
+ case 'm': /* toggle mouse mode */
+ usemouse = !usemouse;
+ mousemode(usemouse);
+ break;
+ case '<': /* decrease fixed sidebar width */
+ case '>': /* increase fixed sidebar width */
+ adjustsidebarsize(ch == '<' ? -1 : +1);
+ break;
+ case '=': /* reset fixed sidebar to automatic size */
+ fixedsidebarsizes[layout] = -1;
+ updategeom();
+ break;
+ case 't': /* toggle showing only new in sidebar */
+ p = &panes[PaneFeeds];
+ if ((row = pane_row_get(p, p->pos)))
+ f = row->data;
+ else
+ f = NULL;
+
+ onlynew = !onlynew;
+ updatesidebar();
+
+ /* try to find the same feed in the pane */
+ if (f && f->totalnew &&
+ (pos = feeds_row_get(p, f)) != -1)
+ pane_setpos(p, pos);
+ else
+ pane_setpos(p, 0);
+ break;
+ case 'o': /* feeds: load, items: plumb URL */
+ case '\n':
+ if (selpane == PaneFeeds && panes[selpane].nrows)
+ feed_open_selected(&panes[selpane]);
+ else if (selpane == PaneItems && panes[selpane].nrows)
+ feed_plumb_selected_item(&panes[selpane], FieldLink);
+ break;
+ case 'c': /* items: pipe TSV line to program */
+ case 'p':
+ case '|':
+ if (selpane == PaneItems)
+ feed_pipe_selected_item(&panes[selpane]);
+ break;
+ case 'y': /* yank: pipe TSV field to yank URL to clipboard */
+ case 'E': /* yank: pipe TSV field to yank enclosure to clipboard */
+ if (selpane == PaneItems)
+ feed_yank_selected_item(&panes[selpane],
+ ch == 'y' ? FieldLink : FieldEnclosure);
+ break;
+ case 'f': /* mark all read */
+ case 'F': /* mark all unread */
+ if (panes[PaneItems].nrows) {
+ p = &panes[PaneItems];
+ markread(p, 0, p->nrows - 1, ch == 'f');
+ }
+ break;
+ case 'r': /* mark item as read */
+ case 'u': /* mark item as unread */
+ if (selpane == PaneItems && panes[selpane].nrows) {
+ p = &panes[selpane];
+ markread(p, p->pos, p->pos, ch == 'r');
+ }
+ break;
+ case 's': /* toggle layout between monocle or non-monocle */
+ setlayout(layout == LayoutMonocle ? prevlayout : LayoutMonocle);
+ updategeom();
+ break;
+ case '1': /* vertical layout */
+ case '2': /* horizontal layout */
+ case '3': /* monocle layout */
+ setlayout(ch - '1');
+ updategeom();
+ break;
+ case 4: /* EOT */
+ case 'q': goto end;
+ }
+event:
+ if (ch == EOF)
+ goto end;
+ else if (ch == -3 && sigstate == 0)
+ continue; /* just a time-out, nothing to do */
+
+ switch (sigstate) {
+ case SIGHUP:
+ feeds_reloadall();
+ sigstate = 0;
+ break;
+ case SIGINT:
+ case SIGTERM:
+ cleanup();
+ _exit(128 + sigstate);
+ case SIGWINCH:
+ resizewin();
+ updategeom();
+ sigstate = 0;
+ break;
+ }
+
+ draw();
+ }
+end:
+ cleanup();
+
+ return 0;
+}
diff --git a/sfeed_frames.c b/sfeed_frames.c
@@ -34,7 +34,8 @@ printfeed(FILE *fpitems, FILE *fpin, struct feed *f)
}
fputs("<pre>\n", fpitems);
- while ((linelen = getline(&line, &linesize, fpin)) > 0) {
+ while ((linelen = getline(&line, &linesize, fpin)) > 0 &&
+ !ferror(fpitems)) {
if (line[linelen - 1] == '\n')
line[--linelen] = '\0';
parseline(line, fields);
@@ -86,8 +87,8 @@ main(int argc, char *argv[])
if (!(feeds = calloc(argc, sizeof(struct feed))))
err(1, "calloc");
- if ((comparetime = time(NULL)) == -1)
- err(1, "time");
+ if ((comparetime = time(NULL)) == (time_t)-1)
+ errx(1, "time");
/* 1 day is old news */
comparetime -= 86400;
@@ -114,6 +115,7 @@ main(int argc, char *argv[])
if (argc == 1) {
feeds[0].name = "";
printfeed(fpitems, stdin, &feeds[0]);
+ checkfileerror(stdin, "<stdin>", 'r');
} else {
for (i = 1; i < argc; i++) {
name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i];
@@ -122,8 +124,8 @@ main(int argc, char *argv[])
if (!(fp = fopen(argv[i], "r")))
err(1, "fopen: %s", argv[i]);
printfeed(fpitems, fp, &feeds[i - 1]);
- if (ferror(fp))
- err(1, "ferror: %s", argv[i]);
+ checkfileerror(fp, argv[i], 'r');
+ checkfileerror(fpitems, "items.html", 'w');
fclose(fp);
}
}
@@ -174,10 +176,15 @@ main(int argc, char *argv[])
"</frameset>\n"
"</html>\n", fpindex);
+ checkfileerror(fpindex, "index.html", 'w');
+ checkfileerror(fpitems, "items.html", 'w');
+
fclose(fpindex);
fclose(fpitems);
- if (fpmenu)
+ if (fpmenu) {
+ checkfileerror(fpmenu, "menu.html", 'w');
fclose(fpmenu);
+ }
return 0;
}
diff --git a/sfeed_gopher.c b/sfeed_gopher.c
@@ -50,7 +50,8 @@ printfeed(FILE *fpitems, FILE *fpin, struct feed *f)
fprintf(fpitems, "i\t\t%s\t%s\r\n", host, port);
}
- while ((linelen = getline(&line, &linesize, fpin)) > 0) {
+ while ((linelen = getline(&line, &linesize, fpin)) > 0 &&
+ !ferror(fpitems)) {
if (line[linelen - 1] == '\n')
line[--linelen] = '\0';
parseline(line, fields);
@@ -137,8 +138,8 @@ main(int argc, char *argv[])
err(1, "pledge");
}
- if ((comparetime = time(NULL)) == -1)
- err(1, "time");
+ if ((comparetime = time(NULL)) == (time_t)-1)
+ errx(1, "time");
/* 1 day is old news */
comparetime -= 86400;
@@ -150,6 +151,8 @@ main(int argc, char *argv[])
if (argc == 1) {
f.name = "";
printfeed(stdout, stdin, &f);
+ checkfileerror(stdin, "<stdin>", 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
} else {
if ((p = getenv("SFEED_GOPHER_PATH")))
prefixpath = p;
@@ -172,8 +175,8 @@ main(int argc, char *argv[])
if (!(fpitems = fopen(path, "wb")))
err(1, "fopen");
printfeed(fpitems, fp, &f);
- if (ferror(fp))
- err(1, "ferror: %s", argv[i]);
+ checkfileerror(fp, argv[i], 'r');
+ checkfileerror(fpitems, path, 'w');
fclose(fp);
fclose(fpitems);
@@ -186,6 +189,7 @@ main(int argc, char *argv[])
fprintf(fpindex, "\t%s\t%s\r\n", host, port);
}
fputs(".\r\n", fpindex);
+ checkfileerror(fpindex, "index", 'w');
fclose(fpindex);
}
diff --git a/sfeed_html.c b/sfeed_html.c
@@ -34,7 +34,8 @@ printfeed(FILE *fp, struct feed *f)
}
fputs("<pre>\n", stdout);
- while ((linelen = getline(&line, &linesize, fp)) > 0) {
+ while ((linelen = getline(&line, &linesize, fp)) > 0 &&
+ !ferror(stdout)) {
if (line[linelen - 1] == '\n')
line[--linelen] = '\0';
parseline(line, fields);
@@ -86,8 +87,8 @@ main(int argc, char *argv[])
if (!(feeds = calloc(argc, sizeof(struct feed))))
err(1, "calloc");
- if ((comparetime = time(NULL)) == -1)
- err(1, "time");
+ if ((comparetime = time(NULL)) == (time_t)-1)
+ errx(1, "time");
/* 1 day is old news */
comparetime -= 86400;
@@ -109,8 +110,7 @@ main(int argc, char *argv[])
if (argc == 1) {
feeds[0].name = "";
printfeed(stdin, &feeds[0]);
- if (ferror(stdin))
- err(1, "ferror: <stdin>:");
+ checkfileerror(stdin, "<stdin>", 'r');
} else {
for (i = 1; i < argc; i++) {
name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i];
@@ -118,8 +118,8 @@ main(int argc, char *argv[])
if (!(fp = fopen(argv[i], "r")))
err(1, "fopen: %s", argv[i]);
printfeed(fp, &feeds[i - 1]);
- if (ferror(fp))
- err(1, "ferror: %s", argv[i]);
+ checkfileerror(fp, argv[i], 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
fclose(fp);
}
}
@@ -150,5 +150,7 @@ main(int argc, char *argv[])
fprintf(stdout, "\t</body>\n\t<title>(%lu/%lu) - Newsfeed</title>\n</html>\n",
totalnew, total);
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeed_markread b/sfeed_markread
@@ -0,0 +1,35 @@
+#!/bin/sh
+# Mark items as read/unread: the input is the read / unread URL per line.
+
+usage() {
+ echo "usage: $0 <read|unread> [urlfile]" >&2
+ echo "" >&2
+ echo "An urlfile must be specified as an argument or with the environment variable \$SFEED_URL_FILE" >&2
+ exit 1
+}
+
+urlfile="${2:-${SFEED_URL_FILE}}"
+if test -z "${urlfile}"; then
+ usage
+fi
+
+case "$1" in
+read)
+ cat >> "${urlfile}"
+ ;;
+unread)
+ tmp=$(mktemp)
+ trap "rm -f ${tmp}" EXIT
+ test -f "${urlfile}" || touch "${urlfile}" 2>/dev/null
+ LC_ALL=C awk -F '\t' '
+ { FILENR += (FNR == 1) }
+ FILENR == 1 { urls[$0] = 1 }
+ FILENR == 2 { if (!urls[$0]) { print $0 } }
+ END { exit(FILENR != 2) }' \
+ "-" "${urlfile}" > "${tmp}" && \
+ cp "${tmp}" "${urlfile}"
+ ;;
+*)
+ usage
+ ;;
+esac
diff --git a/sfeed_markread.1 b/sfeed_markread.1
@@ -0,0 +1,47 @@
+.Dd July 25, 2021
+.Dt SFEED_MARKREAD 1
+.Os
+.Sh NAME
+.Nm sfeed_markread
+.Nd mark items as read/unread
+.Sh SYNOPSIS
+.Nm
+.Ar read | Ar unread
+.Op Ar urlfile
+.Sh DESCRIPTION
+.Nm
+reads a plain-text list of URLs from stdin.
+The file format for the list of URLs is one URL per line.
+.Nm
+will write to the file specified as
+.Ar urlfile
+or with the environment variable
+.Ev SFEED_URL_FILE .
+The
+.Nm
+script can be used by
+.Xr sfeed_curses 1
+to mark items as read and unread.
+.Sh ENVIRONMENT VARIABLES
+.Bl -tag -width Ds
+.It Ev SFEED_URL_FILE
+This variable can be set to use as the path to the file containing a
+plain-text list of read URLs.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh EXAMPLES
+.Bd -literal
+export SFEED_URL_FILE="$HOME/.sfeed/urls"
+echo 'https://codemadness.org/sfeed.html' | sfeed_markread read
+.Ed
+.Pp
+or
+.Bd -literal
+echo 'https://codemadness.org/sfeed.html' | sfeed_markread read ~/.sfeed/urls
+.Ed
+.Sh SEE ALSO
+.Xr awk 1 ,
+.Xr sfeed_curses 1
+.Sh AUTHORS
+.An Hiltjo Posthuma Aq Mt hiltjo@codemadness.org
diff --git a/sfeed_mbox.c b/sfeed_mbox.c
@@ -11,8 +11,8 @@ static size_t linesize;
static char host[256], *user, dtimebuf[32], mtimebuf[32];
static int usecontent = 0; /* env variable: $SFEED_MBOX_CONTENT */
-static unsigned long
-djb2(unsigned char *s, unsigned long hash)
+static unsigned long long
+djb2(unsigned char *s, unsigned long long hash)
{
int c;
@@ -29,10 +29,10 @@ printcontent(const char *s, FILE *fp)
{
escapefrom:
for (; *s == '>'; s++)
- fputc('>', fp);
+ putc('>', fp);
/* escape "From ", mboxrd-style. */
if (!strncmp(s, "From ", 5))
- fputc('>', fp);
+ putc('>', fp);
for (; *s; s++) {
switch (*s) {
@@ -40,15 +40,15 @@ escapefrom:
s++;
switch (*s) {
case 'n':
- fputc('\n', fp);
+ putc('\n', fp);
s++;
goto escapefrom;
- case '\\': fputc('\\', fp); break;
- case 't': fputc('\t', fp); break;
+ case '\\': putc('\\', fp); break;
+ case 't': putc('\t', fp); break;
}
break;
default:
- fputc(*s, fp); break;
+ putc(*s, fp); break;
}
}
}
@@ -59,14 +59,15 @@ printfeed(FILE *fp, const char *feedname)
char *fields[FieldLast], timebuf[32];
struct tm parsedtm, *tm;
time_t parsedtime;
- unsigned long hash;
+ unsigned long long hash;
ssize_t linelen;
int ishtml;
- while ((linelen = getline(&line, &linesize, fp)) > 0) {
+ while ((linelen = getline(&line, &linesize, fp)) > 0 &&
+ !ferror(stdout)) {
if (line[linelen - 1] == '\n')
line[--linelen] = '\0';
- hash = djb2((unsigned char *)line, 5381UL);
+ hash = djb2((unsigned char *)line, 5381ULL);
parseline(line, fields);
/* mbox + mail header */
@@ -84,7 +85,7 @@ printfeed(FILE *fp, const char *feedname)
printf("From: %s <sfeed@>\n", fields[FieldAuthor][0] ? fields[FieldAuthor] : feedname);
printf("To: %s <%s@%s>\n", user, user, host);
printf("Subject: %s\n", fields[FieldTitle]);
- printf("Message-ID: <%s%s%lu@%s>\n",
+ printf("Message-ID: <%s%s%llu@%s>\n",
fields[FieldUnixTimestamp],
fields[FieldUnixTimestamp][0] ? "." : "",
hash, feedname);
@@ -104,14 +105,14 @@ printfeed(FILE *fp, const char *feedname)
fputs("Link: <a href=\"", stdout);
xmlencode(fields[FieldLink], stdout);
fputs("\">", stdout);
- fputs(fields[FieldLink], stdout);
+ xmlencode(fields[FieldLink], stdout);
fputs("</a><br/>\n", stdout);
}
if (fields[FieldEnclosure][0]) {
fputs("Enclosure: <a href=\"", stdout);
xmlencode(fields[FieldEnclosure], stdout);
fputs("\">", stdout);
- fputs(fields[FieldEnclosure], stdout);
+ xmlencode(fields[FieldEnclosure], stdout);
fputs("</a><br/>\n", stdout);
}
fputs("</p>\n", stdout);
@@ -123,6 +124,11 @@ printfeed(FILE *fp, const char *feedname)
}
if (usecontent) {
fputs("\n", stdout);
+ if (ishtml && fields[FieldLink][0]) {
+ fputs("<base href=\"", stdout);
+ xmlencode(fields[FieldLink], stdout);
+ fputs("\"/>\n", stdout);
+ }
printcontent(fields[FieldContent], stdout);
}
fputs("\n\n", stdout);
@@ -147,8 +153,8 @@ main(int argc, char *argv[])
user = "you";
if (gethostname(host, sizeof(host)) == -1)
err(1, "gethostname");
- if ((now = time(NULL)) == -1)
- err(1, "time");
+ if ((now = time(NULL)) == (time_t)-1)
+ errx(1, "time");
if (!gmtime_r(&now, &tmnow))
err(1, "gmtime_r: can't get current time");
if (!strftime(mtimebuf, sizeof(mtimebuf), "%a %b %d %H:%M:%S %Y", &tmnow))
@@ -158,17 +164,20 @@ main(int argc, char *argv[])
if (argc == 1) {
printfeed(stdin, "");
+ checkfileerror(stdin, "<stdin>", 'r');
} else {
for (i = 1; i < argc; i++) {
if (!(fp = fopen(argv[i], "r")))
err(1, "fopen: %s", argv[i]);
name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i];
printfeed(fp, name);
- if (ferror(fp))
- err(1, "ferror: %s", argv[i]);
+ checkfileerror(fp, argv[i], 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
fclose(fp);
}
}
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeed_opml_export b/sfeed_opml_export
@@ -27,7 +27,7 @@ loadconfig() {
# override feed function to output OPML XML.
# feed(name, feedurl, [basesiteurl], [encoding])
feed() {
- # NOTE: TABs in field values are unsupported, be sane.
+ # TABs, newlines and echo options in field values are not checked.
echo "$1 $2"
}
diff --git a/sfeed_opml_import.c b/sfeed_opml_import.c
@@ -101,5 +101,8 @@ main(void)
xml_parse(&parser);
fputs("}\n", stdout);
+ checkfileerror(stdin, "<stdin>", 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeed_plain.c b/sfeed_plain.c
@@ -19,7 +19,8 @@ printfeed(FILE *fp, const char *feedname)
time_t parsedtime;
ssize_t linelen;
- while ((linelen = getline(&line, &linesize, fp)) > 0) {
+ while ((linelen = getline(&line, &linesize, fp)) > 0 &&
+ !ferror(stdout)) {
if (line[linelen - 1] == '\n')
line[--linelen] = '\0';
parseline(line, fields);
@@ -62,24 +63,26 @@ main(int argc, char *argv[])
if (pledge(argc == 1 ? "stdio" : "stdio rpath", NULL) == -1)
err(1, "pledge");
- if ((comparetime = time(NULL)) == -1)
- err(1, "time");
+ if ((comparetime = time(NULL)) == (time_t)-1)
+ errx(1, "time");
/* 1 day is old news */
comparetime -= 86400;
if (argc == 1) {
printfeed(stdin, "");
+ checkfileerror(stdin, "<stdin>", 'r');
} else {
for (i = 1; i < argc; i++) {
if (!(fp = fopen(argv[i], "r")))
err(1, "fopen: %s", argv[i]);
name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i];
printfeed(fp, name);
- if (ferror(fp))
- err(1, "ferror: %s", argv[i]);
+ checkfileerror(fp, argv[i], 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
fclose(fp);
}
}
+ checkfileerror(stdout, "<stdout>", 'w');
return 0;
}
diff --git a/sfeed_twtxt.c b/sfeed_twtxt.c
@@ -17,7 +17,8 @@ printfeed(FILE *fp, const char *feedname)
time_t parsedtime;
ssize_t linelen;
- while ((linelen = getline(&line, &linesize, fp)) > 0) {
+ while ((linelen = getline(&line, &linesize, fp)) > 0 &&
+ !ferror(stdout)) {
if (line[linelen - 1] == '\n')
line[--linelen] = '\0';
parseline(line, fields);
@@ -54,17 +55,20 @@ main(int argc, char *argv[])
if (argc == 1) {
printfeed(stdin, "");
+ checkfileerror(stdin, "<stdin>", 'r');
} else {
for (i = 1; i < argc; i++) {
if (!(fp = fopen(argv[i], "r")))
err(1, "fopen: %s", argv[i]);
name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i];
printfeed(fp, name);
- if (ferror(fp))
- err(1, "ferror: %s", argv[i]);
+ checkfileerror(fp, argv[i], 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
fclose(fp);
}
}
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeed_update b/sfeed_update
@@ -35,7 +35,14 @@ loadconfig() {
# log(name, s)
log() {
+ printf '[%s] %-50.50s %s\n' "$(date +'%H:%M:%S')" "$1" "$2"
+}
+
+# log_error(name, s)
+log_error() {
printf '[%s] %-50.50s %s\n' "$(date +'%H:%M:%S')" "$1" "$2" >&2
+ # set error exit status indicator for parallel jobs.
+ rm -f "${sfeedtmpdir}/ok"
}
# fetch a feed via HTTP/HTTPS etc.
@@ -91,65 +98,66 @@ _feed() {
filename="$(printf '%s' "${name}" | tr '/' '_')"
sfeedfile="${sfeedpath}/${filename}"
- tmpfeedfile="${sfeedtmpdir}/${filename}"
+ tmpfeedfile="${sfeedtmpdir}/feeds/${filename}"
# if file does not exist yet create it.
[ -e "${sfeedfile}" ] || touch "${sfeedfile}" 2>/dev/null
if ! fetch "${name}" "${feedurl}" "${sfeedfile}" > "${tmpfeedfile}.fetch"; then
- log "${name}" "FAIL (FETCH)"
- return
+ log_error "${name}" "FAIL (FETCH)"
+ return 1
fi
# try to detect encoding (if not specified). if detecting the encoding fails assume utf-8.
[ "${encoding}" = "" ] && encoding=$(sfeed_xmlenc < "${tmpfeedfile}.fetch")
if ! convertencoding "${name}" "${encoding}" "utf-8" < "${tmpfeedfile}.fetch" > "${tmpfeedfile}.utf8"; then
- log "${name}" "FAIL (ENCODING)"
- return
+ log_error "${name}" "FAIL (ENCODING)"
+ return 1
fi
rm -f "${tmpfeedfile}.fetch"
# if baseurl is empty then use feedurl.
if ! parse "${name}" "${feedurl}" "${basesiteurl:-${feedurl}}" < "${tmpfeedfile}.utf8" > "${tmpfeedfile}.tsv"; then
- log "${name}" "FAIL (PARSE)"
- return
+ log_error "${name}" "FAIL (PARSE)"
+ return 1
fi
rm -f "${tmpfeedfile}.utf8"
if ! filter "${name}" < "${tmpfeedfile}.tsv" > "${tmpfeedfile}.filter"; then
- log "${name}" "FAIL (FILTER)"
- return
+ log_error "${name}" "FAIL (FILTER)"
+ return 1
fi
rm -f "${tmpfeedfile}.tsv"
# new feed data is empty: no need for below stages.
if [ ! -s "${tmpfeedfile}.filter" ]; then
log "${name}" "OK"
- return
+ return 0
fi
if ! merge "${name}" "${sfeedfile}" "${tmpfeedfile}.filter" > "${tmpfeedfile}.merge"; then
- log "${name}" "FAIL (MERGE)"
- return
+ log_error "${name}" "FAIL (MERGE)"
+ return 1
fi
rm -f "${tmpfeedfile}.filter"
if ! order "${name}" < "${tmpfeedfile}.merge" > "${tmpfeedfile}.order"; then
- log "${name}" "FAIL (ORDER)"
- return
+ log_error "${name}" "FAIL (ORDER)"
+ return 1
fi
rm -f "${tmpfeedfile}.merge"
# copy
if ! cp "${tmpfeedfile}.order" "${sfeedfile}"; then
- log "${name}" "FAIL (COPY)"
- return
+ log_error "${name}" "FAIL (COPY)"
+ return 1
fi
rm -f "${tmpfeedfile}.order"
# OK
log "${name}" "OK"
+ return 0
}
# fetch and process a feed in parallel.
@@ -196,17 +204,22 @@ main() {
loadconfig "$1"
# fetch feeds and store in temporary directory.
sfeedtmpdir="$(mktemp -d '/tmp/sfeed_XXXXXX')"
+ mkdir -p "${sfeedtmpdir}/feeds"
+ touch "${sfeedtmpdir}/ok"
# make sure path exists.
mkdir -p "${sfeedpath}"
# fetch feeds specified in config file.
feeds
# wait till all feeds are fetched (concurrently).
[ ${signo} -eq 0 ] && wait
+ # check error exit status indicator for parallel jobs.
+ test -f "${sfeedtmpdir}/ok"
+ status=$?
# cleanup temporary files etc.
cleanup
# on signal SIGINT and SIGTERM exit with signal number + 128.
[ ${signo} -ne 0 ] && exit $((signo+128))
- return 0
+ return ${status}
}
[ "${SFEED_UPDATE_INCLUDE}" = "1" ] || main "$@"
diff --git a/sfeed_update.1 b/sfeed_update.1
@@ -1,4 +1,4 @@
-.Dd August 3, 2021
+.Dd March 21, 2022
.Dt SFEED_UPDATE 1
.Os
.Sh NAME
@@ -54,15 +54,12 @@ can be sourced as a script, but it won't run the
.Fn main
entry-point.
.El
-.Sh ENVIRONMENT VARIABLES
-.Bl -tag -width Ds
-.It SFEED_UPDATE_INCLUDE
-When set to "1"
-.Nm
-can be sourced as a script, but it won't run the
-.Fn main
-entry-point.
-.El
+.Sh LOGGING
+When processing a feed it will log failures to stderr and non-failures to
+stdout in the format:
+.Bd -literal
+[HH:MM:SS] feedname message
+.Ed
.Sh EXIT STATUS
.Ex -std
.Sh EXAMPLES
diff --git a/sfeed_web.c b/sfeed_web.c
@@ -136,5 +136,8 @@ main(int argc, char *argv[])
/* NOTE: getnext is defined in xml.h for inline optimization */
xml_parse(&parser);
+ checkfileerror(stdin, "<stdin>", 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeed_xmlenc.c b/sfeed_xmlenc.c
@@ -56,5 +56,8 @@ main(void)
/* NOTE: getnext is defined in xml.h for inline optimization */
xml_parse(&parser);
+ checkfileerror(stdin, "<stdin>", 'r');
+ checkfileerror(stdout, "<stdout>", 'w');
+
return 0;
}
diff --git a/sfeedrc.5 b/sfeedrc.5
@@ -1,4 +1,4 @@
-.Dd August 5, 2021
+.Dd March 7, 2022
.Dt SFEEDRC 5
.Os
.Sh NAME
@@ -136,7 +136,7 @@ shown below:
# list of feeds to fetch:
feeds() {
# feed <name> <feedurl> [basesiteurl] [encoding]
- feed "codemadness" "https://www.codemadness.nl/atom.xml"
+ feed "codemadness" "https://www.codemadness.org/atom_content.xml"
feed "explosm" "http://feeds.feedburner.com/Explosm"
feed "golang github releases" "https://github.com/golang/go/releases.atom"
feed "linux kernel" "https://www.kernel.org/feeds/kdist.xml" "https://www.kernel.org"
diff --git a/sfeedrc.example b/sfeedrc.example
@@ -3,7 +3,7 @@
# list of feeds to fetch:
feeds() {
# feed <name> <feedurl> [basesiteurl] [encoding]
- feed "codemadness" "https://www.codemadness.nl/atom.xml"
+ feed "codemadness" "https://www.codemadness.org/atom_content.xml"
feed "explosm" "http://feeds.feedburner.com/Explosm"
feed "golang github releases" "https://github.com/golang/go/releases.atom"
feed "linux kernel" "https://www.kernel.org/feeds/kdist.xml" "https://www.kernel.org"
diff --git a/style.css b/style.css
@@ -57,3 +57,12 @@ body.frame {
body.frame #sidebar br {
display: none;
}
+@media (prefers-color-scheme: dark) {
+ body {
+ background-color: #000;
+ color: #bdbdbd;
+ }
+ a {
+ color: #56c8ff;
+ }
+}
diff --git a/themes/mono.h b/themes/mono.h
@@ -0,0 +1,13 @@
+/* default mono theme */
+#define THEME_ITEM_NORMAL() do { } while(0)
+#define THEME_ITEM_FOCUS() do { } while(0)
+#define THEME_ITEM_BOLD() do { attrmode(ATTR_BOLD_ON); } while(0)
+#define THEME_ITEM_SELECTED() do { if (p->focused) attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_SCROLLBAR_FOCUS() do { } while(0)
+#define THEME_SCROLLBAR_NORMAL() do { attrmode(ATTR_FAINT_ON); } while(0)
+#define THEME_SCROLLBAR_TICK_FOCUS() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_SCROLLBAR_TICK_NORMAL() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_LINEBAR() do { attrmode(ATTR_FAINT_ON); } while(0)
+#define THEME_STATUSBAR() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_INPUT_LABEL() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_INPUT_NORMAL() do { } while(0)
diff --git a/themes/mono_highlight.h b/themes/mono_highlight.h
@@ -0,0 +1,15 @@
+/* mono theme with highlighting of the active panel.
+ The faint attribute may not work on all terminals though.
+ The combination bold with faint generally does not work either. */
+#define THEME_ITEM_NORMAL() do { } while(0)
+#define THEME_ITEM_FOCUS() do { } while(0)
+#define THEME_ITEM_BOLD() do { if (p->focused || !selected) attrmode(ATTR_BOLD_ON); } while(0)
+#define THEME_ITEM_SELECTED() do { attrmode(ATTR_REVERSE_ON); if (!p->focused) attrmode(ATTR_FAINT_ON); } while(0)
+#define THEME_SCROLLBAR_FOCUS() do { } while(0)
+#define THEME_SCROLLBAR_NORMAL() do { attrmode(ATTR_FAINT_ON); } while(0)
+#define THEME_SCROLLBAR_TICK_FOCUS() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_SCROLLBAR_TICK_NORMAL() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_LINEBAR() do { attrmode(ATTR_FAINT_ON); } while(0)
+#define THEME_STATUSBAR() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_INPUT_LABEL() do { attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_INPUT_NORMAL() do { } while(0)
diff --git a/themes/newsboat.h b/themes/newsboat.h
@@ -0,0 +1,13 @@
+/* newsboat-like (blue, yellow) */
+#define THEME_ITEM_NORMAL() do { } while(0)
+#define THEME_ITEM_FOCUS() do { } while(0)
+#define THEME_ITEM_BOLD() do { attrmode(ATTR_BOLD_ON); } while(0)
+#define THEME_ITEM_SELECTED() do { if (p->focused) ttywrite("\x1b[93;44m"); } while(0) /* bright yellow fg, blue bg */
+#define THEME_SCROLLBAR_FOCUS() do { ttywrite("\x1b[34m"); } while(0) /* blue fg */
+#define THEME_SCROLLBAR_NORMAL() do { ttywrite("\x1b[34m"); } while(0)
+#define THEME_SCROLLBAR_TICK_FOCUS() do { ttywrite("\x1b[44m"); } while(0) /* blue bg */
+#define THEME_SCROLLBAR_TICK_NORMAL() do { ttywrite("\x1b[44m"); } while(0)
+#define THEME_LINEBAR() do { ttywrite("\x1b[34m"); } while(0)
+#define THEME_STATUSBAR() do { attrmode(ATTR_BOLD_ON); ttywrite("\x1b[93;44m"); } while(0)
+#define THEME_INPUT_LABEL() do { } while(0)
+#define THEME_INPUT_NORMAL() do { } while(0)
diff --git a/themes/templeos.h b/themes/templeos.h
@@ -0,0 +1,24 @@
+/* TempleOS-like (for fun and god) */
+/* set true-color foreground / background, Terry would've preferred ANSI */
+#define SETFGCOLOR(r,g,b) ttywritef("\x1b[38;2;%d;%d;%dm", r, g, b)
+#define SETBGCOLOR(r,g,b) ttywritef("\x1b[48;2;%d;%d;%dm", r, g, b)
+
+#define THEME_ITEM_NORMAL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_ITEM_FOCUS() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_ITEM_BOLD() do { attrmode(ATTR_BOLD_ON); SETFGCOLOR(0xaa, 0x00, 0x00); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_ITEM_SELECTED() do { if (p->focused) attrmode(ATTR_REVERSE_ON); } while(0)
+#define THEME_SCROLLBAR_FOCUS() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_SCROLLBAR_NORMAL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_SCROLLBAR_TICK_FOCUS() do { SETBGCOLOR(0x00, 0x00, 0xaa); SETFGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_SCROLLBAR_TICK_NORMAL() do { SETBGCOLOR(0x00, 0x00, 0xaa); SETFGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_LINEBAR() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_STATUSBAR() do { ttywrite("\x1b[6m"); SETBGCOLOR(0x00, 0x00, 0xaa); SETFGCOLOR(0xff, 0xff, 0xff); } while(0) /* blink statusbar */
+#define THEME_INPUT_LABEL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+#define THEME_INPUT_NORMAL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0)
+
+#undef SCROLLBAR_SYMBOL_BAR
+#define SCROLLBAR_SYMBOL_BAR "\xe2\x95\x91" /* symbol: "double vertical" */
+#undef LINEBAR_SYMBOL_BAR
+#define LINEBAR_SYMBOL_BAR "\xe2\x95\x90" /* symbol: "double horizontal" */
+#undef LINEBAR_SYMBOL_RIGHT
+#define LINEBAR_SYMBOL_RIGHT "\xe2\x95\xa3" /* symbol: "double vertical and left" */
diff --git a/util.c b/util.c
@@ -46,7 +46,37 @@ errx(int exitstatus, const char *fmt, ...)
exit(exitstatus);
}
-/* check if string has a non-empty scheme / protocol part */
+/* Handle read or write errors for a FILE * stream */
+void
+checkfileerror(FILE *fp, const char *name, int mode)
+{
+ if (mode == 'r' && ferror(fp))
+ errx(1, "read error: %s", name);
+ else if (mode == 'w' && (fflush(fp) || ferror(fp)))
+ errx(1, "write error: %s", name);
+}
+
+/* strcasestr() included for portability */
+char *
+strcasestr(const char *h, const char *n)
+{
+ size_t i;
+
+ if (!n[0])
+ return (char *)h;
+
+ for (; *h; ++h) {
+ for (i = 0; n[i] && tolower((unsigned char)n[i]) ==
+ tolower((unsigned char)h[i]); ++i)
+ ;
+ if (n[i] == '\0')
+ return (char *)h;
+ }
+
+ return NULL;
+}
+
+/* Check if string has a non-empty scheme / protocol part. */
int
uri_hasscheme(const char *s)
{
@@ -59,6 +89,8 @@ uri_hasscheme(const char *s)
return (*p == ':' && p != s);
}
+/* Parse URI string `s` into an uri structure `u`.
+ Returns 0 on success or -1 on failure */
int
uri_parse(const char *s, struct uri *u)
{
@@ -278,8 +310,9 @@ strtotime(const char *s, time_t *t)
l = strtoll(s, &e, 10);
if (errno || *s == '\0' || *e)
return -1;
- /* NOTE: assumes time_t is 64-bit on 64-bit platforms:
- long long (at least 32-bit) to time_t. */
+
+ /* NOTE: the type long long supports the 64-bit range. If time_t is
+ 64-bit it is "2038-ready", otherwise it is truncated/wrapped. */
if (t)
*t = (time_t)l;
@@ -302,7 +335,7 @@ xmlencode(const char *s, FILE *fp)
}
}
-/* print `len' columns of characters. If string is shorter pad the rest with
+/* print `len` columns of characters. If string is shorter pad the rest with
* characters `pad`. */
void
printutf8pad(FILE *fp, const char *s, size_t len, int pad)
@@ -331,11 +364,11 @@ printutf8pad(FILE *fp, const char *s, size_t len, int pad)
}
if (col + w > len || (col + w == len && s[i + inc])) {
- fputs("\xe2\x80\xa6", fp); /* ellipsis */
+ fputs(PAD_TRUNCATE_SYMBOL, fp); /* ellipsis */
col++;
break;
} else if (rl < 0) {
- fputs("\xef\xbf\xbd", fp); /* replacement */
+ fputs(UTF_INVALID_SYMBOL, fp); /* replacement */
col++;
continue;
}
@@ -344,7 +377,7 @@ printutf8pad(FILE *fp, const char *s, size_t len, int pad)
} else {
/* optimization: simple ASCII character */
if (col + 1 > len || (col + 1 == len && s[i + 1])) {
- fputs("\xe2\x80\xa6", fp); /* ellipsis */
+ fputs(PAD_TRUNCATE_SYMBOL, fp); /* ellipsis */
col++;
break;
}
diff --git a/util.h b/util.h
@@ -1,6 +1,5 @@
-#include <sys/types.h>
-
#include <stdio.h>
+#include <time.h>
#ifdef __OpenBSD__
#include <unistd.h>
@@ -9,16 +8,29 @@
#define unveil(p1,p2) 0
#endif
+#undef strcasestr
+char *strcasestr(const char *, const char *);
#undef strlcat
size_t strlcat(char *, const char *, size_t);
#undef strlcpy
size_t strlcpy(char *, const char *, size_t);
+#ifndef SFEED_DUMBTERM
+#define PAD_TRUNCATE_SYMBOL "\xe2\x80\xa6" /* symbol: "ellipsis" */
+#define UTF_INVALID_SYMBOL "\xef\xbf\xbd" /* symbol: "replacement" */
+#else
+#define PAD_TRUNCATE_SYMBOL "." /* symbol: "ellipsis" */
+#define UTF_INVALID_SYMBOL "?" /* symbol: "replacement" */
+#endif
+
/* feed info */
struct feed {
char *name; /* feed name */
unsigned long totalnew; /* amount of new items per feed */
unsigned long total; /* total items */
+ /* sfeed_curses */
+ char *path; /* path to feed or NULL for stdin */
+ FILE *fp; /* file pointer */
};
/* URI */
@@ -51,6 +63,7 @@ int uri_hasscheme(const char *);
int uri_makeabs(struct uri *, struct uri *, struct uri *);
int uri_parse(const char *, struct uri *);
+void checkfileerror(FILE *, const char *, int);
void parseline(char *, char *[FieldLast]);
void printutf8pad(FILE *, const char *, size_t, int);
int strtotime(const char *, time_t *);