sfeed

simple feed reader - forked from git.codemadness.org/sfeed
git clone git://src.gearsix.net/sfeed
Log | Files | Refs | Atom | README | LICENSE

commit 5062ab20feb901630dc69e8ed49d05d82b1756ba
parent 875f4880bc520ca25b718cb7c0ede141f13aeab5
Author: gearsix <gearsix@tuta.io>
Date:   Wed, 23 Mar 2022 12:13:53 +0000

Merge branch 'master' into gearsix

Diffstat:
MLICENSE | 2+-
MMakefile | 49++++++++++++++++++++++++++++++++++++++++++-------
MREADME | 366++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aminicurses.h | 38++++++++++++++++++++++++++++++++++++++
Msfeed.1 | 46++++++++++++++++++++++++++++++++++++++++++----
Msfeed.5 | 8++++----
Msfeed.c | 82++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msfeed_atom.c | 27+++++++++++++++++++++------
Asfeed_content | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfeed_content.1 | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfeed_curses.1 | 329+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfeed_curses.c | 2365+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msfeed_frames.c | 19+++++++++++++------
Msfeed_gopher.c | 14+++++++++-----
Msfeed_html.c | 16+++++++++-------
Asfeed_markread | 35+++++++++++++++++++++++++++++++++++
Asfeed_markread.1 | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msfeed_mbox.c | 45+++++++++++++++++++++++++++------------------
Msfeed_opml_export | 2+-
Msfeed_opml_import.c | 3+++
Msfeed_plain.c | 13++++++++-----
Msfeed_twtxt.c | 10+++++++---
Msfeed_update | 47++++++++++++++++++++++++++++++-----------------
Msfeed_update.1 | 17+++++++----------
Msfeed_web.c | 3+++
Msfeed_xmlenc.c | 3+++
Msfeedrc.5 | 4++--
Msfeedrc.example | 2+-
Mstyle.css | 9+++++++++
Athemes/mono.h | 13+++++++++++++
Athemes/mono_highlight.h | 15+++++++++++++++
Athemes/newsboat.h | 13+++++++++++++
Athemes/templeos.h | 24++++++++++++++++++++++++
Mutil.c | 47++++++++++++++++++++++++++++++++++++++++-------
Mutil.h | 17+++++++++++++++--
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 *);