iens

Manager of links to read
git clone https://git.instinctive.eu/iens.git
Log | Files | Refs | README | LICENSE

iens.scm (57120B)


      1 ; Copyright (c) 2023-2026, Natacha Porté
      2 ;
      3 ; Permission to use, copy, modify, and distribute this software for any
      4 ; purpose with or without fee is hereby granted, provided that the above
      5 ; copyright notice and this permission notice appear in all copies.
      6 ;
      7 ; THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
      8 ; WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
      9 ; MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
     10 ; ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
     11 ; WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
     12 ; ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
     13 ; OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
     14 
     15 (import (chicken condition)
     16         (chicken file)
     17         (chicken file posix)
     18         (chicken io)
     19         (chicken process)
     20         (chicken process signal)
     21         (chicken process-context)
     22         (chicken sort)
     23         (chicken string)
     24         (chicken time)
     25         (chicken time posix)
     26         breadline
     27         breadline-scheme-completion
     28         http-client
     29         lowdown
     30         ncurses
     31         rss
     32         sql-de-lite
     33         srfi-1
     34         sxml-serializer)
     35 
     36 (define (starts-with? maybe-prefix s)
     37   (and (<= (string-length maybe-prefix) (string-length s))
     38        (substring=? s maybe-prefix 0 0 (string-length maybe-prefix))))
     39 
     40 (define (ends-with? maybe-suffix s)
     41   (let ((ls  (string-length s))
     42         (lms (string-length maybe-suffix)))
     43   (and (>= ls lms)
     44        (substring=? s maybe-suffix (- ls lms)))))
     45 
     46 (define (time->rfc-3339 time)
     47   (let ((time-str (time->string time "%FT%T%z")))
     48     (assert (= 24 (string-length time-str)))
     49     (if (equal? "0000" (substring time-str 20))
     50         (string-append (substring time-str 0 19) "Z")
     51         (string-append (substring time-str 0 22)
     52                        ":"
     53                        (substring time-str 22)))))
     54 
     55 (define (rfc-3339-local seconds)
     56   (time->rfc-3339 (seconds->local-time seconds)))
     57 (define (rfc-3339-utc seconds)
     58   (time->rfc-3339 (seconds->utc-time seconds)))
     59 (define rfc-3339 rfc-3339-local)
     60 
     61 (define (terminate-line line)
     62   (let ((l (string-length line)))
     63     (if (or (zero? l)
     64             (eqv? (string-ref line (sub1 l)) #\newline))
     65         line
     66         (string-append line "\n"))))
     67 
     68 (define cmd-list '())
     69 
     70 (define-syntax defcmd
     71   (syntax-rules ()
     72     ((defcmd (name . args) str first . rest)
     73       (begin
     74         (set! cmd-list (cons (list (symbol->string 'name) str first) cmd-list))
     75         (define (name . args) . rest)))))
     76 
     77 (define vt100-alert        "\033[31m")
     78 (define vt100-entry-header "\033[34m")
     79 (define vt100-reset        "\033[0m")
     80 
     81 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;
     82 ;; Command-Line Processing
     83 
     84 (define db-filename #f)
     85 (define arg-replay #f)
     86 
     87 (let ((arg-list (command-line-arguments)))
     88   (when (>= (length arg-list) 2) (set! arg-replay (cadr arg-list)))
     89   (when (>= (length arg-list) 1) (set! db-filename (car arg-list))))
     90 
     91 ;;;;;;;;;;;;;
     92 ;; Tracing
     93 
     94 (define trace-port #f)
     95 (define display-trace #t)
     96 
     97 (define (trace obj)
     98   (when display-trace
     99     (write obj)
    100     (newline))
    101   (when trace-port
    102     (write obj trace-port)
    103     (newline trace-port)))
    104 
    105 ;;;;;;;;;;;;;;;;;;;;;;;
    106 ;; Persistent Storage
    107 
    108 (define db-name
    109   (if db-filename db-filename "iens.sqlite"))
    110 
    111 (define db
    112   (open-database db-name))
    113 (write-line (conc "Using database " db-name " with SQLite " library-version))
    114 (exec (sql db "PRAGMA foreign_keys = ON;"))
    115 
    116 (include "common.scm")
    117 
    118 (assert (= 4 (db-version)))
    119 
    120 ;;;;;;;;;;;;;;;;;;
    121 ;; Configuration
    122 
    123 (define config-author-name #f)
    124 (define config-author-email #f)
    125 (define config-author-uri #f)
    126 (define config-autogenerate #f)
    127 (define config-editor #f)
    128 (define config-entry-id-prefix "")
    129 (define config-list-tagged-count 0)
    130 (define config-verbose #f)
    131 
    132 (define default-editor
    133   (let ((term   (get-environment-variable "TERM"))
    134         (visual (get-environment-variable "VISUAL"))
    135         (editor (get-environment-variable "EDITOR"))
    136         (fallback "vi"))
    137     (cond
    138       ((and visual term (not (equal? "dumb" term))) visual)
    139       (editor editor)
    140       (else fallback))))
    141 
    142 (define (get-config key)
    143   (query fetch-value (sql db "SELECT val FROM config WHERE key = ?;") key))
    144 
    145 (define (get-config/default key default-value)
    146   (let ((result (get-config key)))
    147     (if result
    148         result
    149         default-value)))
    150 
    151 (define (string->filename data)
    152   (cond ((not data) #f)
    153         ((starts-with? "~/" data)
    154           (string-append (get-environment-variable "HOME")
    155                          (substring data 1)))
    156         (else data)))
    157 
    158 (define (read-config!)
    159   (set! display-trace  (not (zero? (get-config/default "display-trace" 0))))
    160   (set! config-verbose (not (zero? (get-config/default "verbose" 0))))
    161   (set! rfc-3339        (if (zero? (get-config/default "local-time" 1))
    162                             rfc-3339-utc rfc-3339-local))
    163   (set! config-author-name  (get-config "author-name"))
    164   (set! config-author-email (get-config "author-email"))
    165   (set! config-author-uri   (get-config "author-uri"))
    166   (set! config-autogenerate (not (zero? (get-config/default "autogenerate" 0))))
    167   (set! config-editor       (get-config/default "editor" default-editor))
    168   (set! config-entry-id-prefix (get-config/default "entry-id-prefix" ""))
    169   (set! config-list-tagged-count (get-config/default "list-tagged-count" 0))
    170   (let ((trace-filename (get-config "trace")))
    171     (when trace-port (close-output-port trace-port))
    172     (set! trace-port
    173       (if trace-filename
    174           (open-output-file (string->filename trace-filename) #:text #:append)
    175           #f)))
    176   (history-file (string->filename (get-config "histfile"))))
    177 
    178 (read-config!)
    179 
    180 (defcmd (print-config . args)
    181   "[key ...]" "Print configuration"
    182   (if (null? args)
    183       (query
    184         (for-each-row*
    185           (lambda (key val) (write-line (conc key ": " val))))
    186         (sql db "SELECT key,val FROM config ORDER BY key;"))
    187       (let loop ((todo args))
    188         (unless (null? todo)
    189           (write-line (conc (car todo) ": " (get-config (car todo))))
    190           (loop (cdr todo))))))
    191 
    192 (defcmd (set-config key val)
    193   "key value" "Set configuration variable"
    194   (trace `(set-config ,key ,val))
    195   (exec (sql db "INSERT OR REPLACE INTO config VALUES (?,?);") key val)
    196   (read-config!))
    197 
    198 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    199 ;; Configurable Query Selectors
    200 
    201 (defcmd (add-selector text)
    202   "\"WHERE …\"" "Creates a pre-defined query selector"
    203   (trace `(add-select ,text))
    204   (exec (sql db "INSERT INTO selector(text) VALUES (?);") text)
    205   (write-line (conc " -> " (last-insert-rowid db))))
    206 
    207 (define (call-with-selector arg proc)
    208   (cond ((string? arg) (proc arg #f))
    209         ((number? arg) (let ((selector (get-selector arg)))
    210                          (if selector
    211                              (proc selector arg)
    212                              (write-line
    213                                (conc "No selector #" arg " found")))))
    214         (else (write-line (conc "Invalid selection argument " arg)))))
    215 
    216 (define (get-selector id)
    217   (query fetch-value (sql db "SELECT text FROM selector WHERE id=?;") id))
    218 
    219 (defcmd (list-selectors)
    220   "" "List pre-defined query selectors"
    221   (query
    222     (for-each-row
    223       (lambda (row)
    224         (write-line (conc "#" (car row) ": \"" (cadr row) "\""))))
    225     (sql db "SELECT id,text FROM selector;")))
    226 
    227 (defcmd (set-selector id text)
    228   "id \"WHERE …\"" "Sets a pre-defined query selector"
    229   (trace `(set-selector ,id ,text))
    230   (exec (sql db "INSERT OR REPLACE INTO selector(id,text) VALUES (?,?);")
    231         id text))
    232 
    233 ;;;;;;;;;;;;;;;;;;;;;
    234 ;; Database Updates
    235 
    236 ;; Feed Management
    237 
    238 (define (set-feed-active id n)
    239   (exec (sql db "UPDATE feed SET active=? WHERE id=?;") n id))
    240 
    241 (defcmd (activate-feed feed-id)
    242   "feed-id" "Activate the given feed"
    243   (trace `(activate-feed ,feed-id))
    244   (set-feed-active feed-id 1))
    245 
    246 (defcmd (add-feed filename url selector title)
    247   "filename url selector title" "Add a new feed"
    248   (trace `(add-feed ,filename ,url ,selector ,title))
    249   (exec (sql db
    250              "INSERT INTO feed(filename,url,selector,title) VALUES (?,?,?,?);")
    251         filename url selector title)
    252   (write-line (conc "Added feed " (last-insert-rowid db))))
    253 
    254 (defcmd (disable-feed feed-id)
    255   "feed-id" "Disable the given feed"
    256   (trace `(disable-feed ,feed-id))
    257   (set-feed-active feed-id 0))
    258 
    259 (defcmd (list-feeds)
    260   "" "Display all feeds"
    261   (query
    262     (map-rows*
    263       (lambda (id filename url selector title active-int mtime)
    264         (write-line (conc (if (zero? active-int)
    265                               (conc "(" id ")")
    266                               (conc "#" id))
    267                           " "
    268                           filename
    269                           " - "
    270                           title))
    271         (write-line (conc "    " url))
    272         (write-line (conc "    " selector))
    273         (unless (null? mtime)
    274           (write-line (conc "    Updated " (rfc-3339 mtime))))))
    275     (sql db "SELECT id,filename,url,selector,title,active,mtime FROM feed;")))
    276 
    277 (defcmd (remove-feed feed-id)
    278   "feed-id" "Remove the given feed"
    279   (trace `(remove-feed ,feed-id))
    280   (exec (sql db "DELETE FROM feed WHERE id=?;") feed-id))
    281 
    282 (define (touch-feed mtime feed-id)
    283   (trace `(touch-feed ,mtime ,feed-id))
    284   (exec (sql db "UPDATE feed SET mtime=? WHERE id=?;") mtime feed-id))
    285 
    286 ;; Feed Caching
    287 
    288 (define (build-signature selector)
    289   (query fetch-rows
    290          (sql db (string-append "SELECT id,mtime FROM entry " selector ";"))))
    291 
    292 (define (car< a b) (< (car a) (car b)))
    293 
    294 (define (diff-signature old-sig new-sig)
    295   (let loop ((old    (sort old-sig car<))
    296              (new    (sort new-sig car<))
    297              (result '()))
    298     (cond ((and (null? old) (null? new))
    299               result)
    300           ((null? old)
    301               (loop old
    302                     (cdr new)
    303                     (cons `(add ,@(car new)) result)))
    304           ((null? new)
    305               (loop (cdr old)
    306                     new
    307                     (cons `(del ,@(car old)) result)))
    308           ((equal? (car new) (car old))
    309               (loop (cdr old)
    310                     (cdr new)
    311                     result))
    312           ((= (caar new) (caar old))
    313               (loop (cdr old)
    314                     (cdr new)
    315                     (cons `(chg ,@(car old) ,(cadar new)) result)))
    316           ((< (caar new) (caar old))
    317               (loop old
    318                     (cdr new)
    319                     (cons `(add ,@(car new)) result)))
    320           ((> (caar new) (caar old))
    321               (loop (cdr old)
    322                     new
    323                     (cons `(del ,@(car old)) result)))
    324           (else (assert #f "Should be unreachable")))))
    325 
    326 (define (write-diff sig-diff)
    327   (for-each
    328     (lambda (hunk)
    329       (cond ((eqv? (car hunk) 'add)
    330               (write-line (conc "    added item #" (cadr hunk)
    331                                 " at " (rfc-3339 (caddr hunk)))))
    332             ((eqv? (car hunk) 'del)
    333               (write-line (conc "    removed item #" (cadr hunk)
    334                                 " at " (rfc-3339 (caddr hunk)))))
    335             ((eqv? (car hunk) 'chg)
    336               (write-line (conc "    updated item #" (cadr hunk)
    337                                 ": " (rfc-3339 (caddr hunk))
    338                                 " → " (rfc-3339 (cadddr hunk)))))
    339           (else (assert #f "Should be unreachable"))))
    340     sig-diff))
    341 
    342 (define feed-cache
    343   (query (map-rows* (lambda (id selector)
    344                             (cons id (build-signature selector))))
    345          (sql db "SELECT id,selector FROM feed WHERE active=1;")))
    346 (define dirty-feeds '())
    347 
    348 (define (check-feed* id)
    349   (let ((new (query fetch-value
    350                     (sql db "SELECT selector FROM feed WHERE id=?;")
    351                     id))
    352         (old (alist-ref id feed-cache = '())))
    353     (cond ((and (not new) (null? old))
    354             (write-line (conc "Feed #" id " does not exist")))
    355           ((not new)
    356             (write-line (conc "Feed #" id " does not exist anymore")))
    357           ((null? old)
    358             (write-line (conc "Feed #" id " is not cached")))
    359           (else
    360             (let ((sig-diff (diff-signature old (build-signature new))))
    361               (if (null? sig-diff)
    362                   (write-line (conc "Feed #" id " has not changed"))
    363                   (write-line (conc "Feed #" id " was modified:")))
    364               (write-diff sig-diff))))))
    365 
    366 (defcmd (check-feed . args)
    367   "[feed-id ...]" "Check the cache for the given feeds, or all active feeds"
    368   (for-each check-feed*
    369     (if (null? args)
    370         (query fetch-column (sql db "SELECT id FROM feed WHERE active=1;"))
    371         args)))
    372 
    373 (define (update-feed-cache* mtime id)
    374   (let ((data (query fetch-row
    375                      (sql db "SELECT mtime,selector,filename,title,url
    376                               FROM feed WHERE id=?;")
    377                      id))
    378         (old-sig (alist-ref id feed-cache = '())))
    379     (if (null? data)
    380         (write-line (conc "Feed #" id " does not exist"))
    381         (let ((new-sig (build-signature (cadr data))))
    382           (unless (equal? old-sig new-sig)
    383             (when (or (null? (car data))
    384                       (> mtime (car data)))
    385               (touch-feed mtime id)
    386               (set! (car data) mtime))
    387             (when config-verbose
    388                (write-line (if config-autogenerate
    389                                (conc "Autogenerating feed " id)
    390                                (conc "Marking feed " id " as dirty:")))
    391                (write-diff (diff-signature old-sig new-sig)))
    392             (if config-autogenerate
    393                 (with-output-to-file (caddr data) ;filename
    394                   (cut write-feed
    395                     (car data) ;mtime
    396                     (list-ref data 3) ;title
    397                     (list-ref data 4) ;url
    398                     (query fetch-rows
    399                       (sql db (string-append "SELECT id,url,type,description,
    400                                                      notes,ptime,ctime,mtime
    401                                               FROM entry " (cadr data) ";")))))
    402                 (unless (any (cut = id <>) dirty-feeds)
    403                   (set! dirty-feeds (cons id dirty-feeds))))
    404             (set! feed-cache
    405                   (alist-update! id new-sig feed-cache =)))))))
    406 
    407 (define (update-feed-cache mtime . id-list)
    408   (for-each
    409     (cut update-feed-cache* mtime <>)
    410     (if (null? id-list)
    411         (query fetch-column (sql db "SELECT id FROM feed WHERE active=1;"))
    412         id-list)))
    413 
    414 ;; Tag Management
    415 
    416 (define (set-tag-auto name auto)
    417   (exec (sql db "UPDATE tag SET auto=? WHERE name=?;") auto name))
    418 
    419 (defcmd (add-auto-tag name . rest)
    420   "tag-name [tag-name ...]" "Set tags as automatic"
    421   (trace `(add-auto-tag ,name))
    422   (set-tag-auto name 1)
    423   (unless (null? rest)
    424     (apply add-auto-tag rest)))
    425 
    426 (defcmd (add-tag name . rest)
    427   "tag-name [tag-name ...]" "Create a new tag"
    428   (trace `(add-tag ,name))
    429   (exec (sql db "INSERT INTO tag(name) VALUES (?);") name)
    430   (unless (null? rest)
    431     (apply add-tag rest)))
    432 
    433 (defcmd (auto-tags . tag-list)
    434   "[tag-name ...]" "Set the list of automatic tags"
    435   (trace `(auto-tags . ,tag-list))
    436   (with-transaction db
    437     (lambda ()
    438       (exec (sql db "UPDATE tag SET auto=0;"))
    439       (let loop ((todo tag-list))
    440         (unless (null? todo)
    441           (set-tag-auto (car todo) 1)
    442           (loop (cdr todo)))))))
    443 
    444 (define (n-split l n)
    445   (let loop ((todo-l l) (todo-n n) (acc '()))
    446     (if (or (zero? todo-n) (null? todo-l))
    447         (reverse acc)
    448         (let ((chunk-size (ceiling (/ (length todo-l) todo-n))))
    449           (loop (drop todo-l chunk-size)
    450                 (sub1 todo-n)
    451                 (cons (take todo-l chunk-size) acc))))))
    452 
    453 (define (expand-cols cols)
    454   (let loop ((todo cols) (acc '()))
    455     (if (> (length todo) 1)
    456         (loop
    457           (cons (append (cadr todo)
    458                         (make-list (- (length (car todo)) (length (cadr todo)))
    459                                    ""))
    460                 (cddr todo))
    461           (let ((width (apply max (map string-length (car todo)))))
    462             (cons
    463               (append
    464                 (map (lambda (s t)
    465                        (string-append
    466                          s
    467                          (make-string (- width -2 (string-length s))
    468                                       #\space)))
    469                      (car todo)
    470                      (cadr todo))
    471                 (drop (car todo) (length (cadr todo))))
    472               acc)))
    473         (reverse (append todo acc)))))
    474 
    475 (defcmd (list-tags #!optional (cols 1) (threshold 0))
    476   "[n-columns [min-count]]"
    477   "List available tag, automatic tags are marked with *"
    478   (apply for-each
    479          (lambda row
    480            (write-line (apply string-append row)))
    481          (expand-cols
    482            (n-split
    483              (query
    484                (map-rows*
    485                  (lambda (name auto count)
    486                    (conc name (if (zero? auto) " (" "* (") count ")")))
    487                (sql db "SELECT name,auto,COUNT(tagrel.url_id) AS cnt
    488                         FROM tag OUTER LEFT JOIN tagrel ON id=tagrel.tag_id
    489                         GROUP BY id HAVING cnt >= ? ORDER BY name;")
    490                threshold)
    491              cols))))
    492 
    493 (defcmd (remove-auto-tag name . rest)
    494   "[tag-name ...]" "Set tags as not automatic"
    495   (trace `(remove-auto-tag ,name))
    496   (set-tag-auto name 0)
    497   (unless (null? rest)
    498     (apply remove-auto-tag rest)))
    499 
    500 (defcmd (remove-tag name . rest)
    501   "tag-name [tag-name ...]" "Remove tags"
    502   (trace `(remove-tag ,name))
    503   (exec (sql db "DELETE FROM tag WHERE name=?;") name)
    504   (unless (null? rest)
    505     (apply remove-tag rest)))
    506 
    507 (defcmd (rename-tag old-name new-name)
    508   "old-tag-name new-tag-name" "Rename a tag, preserving associations"
    509   (trace `(rename-tag ,old-name ,new-name))
    510   (exec (sql db "UPDATE tag SET name=? WHERE name=?;") new-name old-name))
    511 
    512 ;; Entry Protection
    513 
    514 (define (is-protected? entry-id)
    515   (not (zero?
    516           (query fetch-value
    517                  (sql db "SELECT protected FROM entry WHERE id=?;")
    518                  entry-id))))
    519 
    520 (define protection-overrides '())
    521 
    522 (define (is-overridden? entry-id)
    523   (any (cut = entry-id <>) protection-overrides))
    524 
    525 (define (update-allowed? entry-id)
    526   (or (not (is-protected? entry-id)) (is-overridden? entry-id)))
    527 
    528 (define-syntax unless-protected
    529   (syntax-rules ()
    530     ((unless-protected entry-id . form)
    531       (if (update-allowed? entry-id)
    532           (begin . form)
    533           (write-line (conc "Warning: entry " entry-id " is protected"))))))
    534 
    535 (define (unoverride! entry-id)
    536   (trace `(unoverride! ,entry-id))
    537   (set! protection-overrides (delete! entry-id protection-overrides =)))
    538 
    539 (define (protect* ptime entry-id)
    540   (trace `(protect ,ptime ,entry-id))
    541   (unless-protected entry-id
    542      (exec (sql db "UPDATE entry SET protected=1,ptime=? WHERE id=?;")
    543            ptime entry-id)
    544      (update-feed-cache ptime)))
    545 
    546 (defcmd (protect . args)
    547   "[[timestamp] entry-id]" "Protect entries from modification"
    548   (cond ((null? args)
    549           (protect* (current-seconds) cur-entry))
    550         ((null? (cdr args))
    551           (protect* (current-seconds) (car args)))
    552         (else
    553           (protect* (car args) (cadr args)))))
    554 
    555 (define (override! entry-id)
    556   (trace `(override! ,entry-id))
    557   (unless (update-allowed? entry-id)
    558     (set! protection-overrides (cons entry-id protection-overrides))))
    559 
    560 (define (unprotect* mtime entry-id)
    561   (trace `(unprotect ,mtime ,entry-id))
    562   (exec (sql db "UPDATE entry SET protected=0,ptime=NULL,mtime=? WHERE id=?;")
    563         mtime entry-id)
    564   (update-feed-cache mtime))
    565 
    566 (defcmd (unprotect . args)
    567   "[[timestamp] entry-id]" "Unprotect entries from modification"
    568   (cond ((null? args)
    569           (unprotect* (current-seconds) cur-entry))
    570         ((null? (cdr args))
    571           (unprotect* (current-seconds) (car args)))
    572         (else
    573           (unprotect* (car args) (cadr args)))))
    574 
    575 (define (without-protection* entry-id proc)
    576   (if (or (procedure? proc) (list? proc))
    577       (let ((prev-cur-entry-id cur-entry))
    578         (set! cur-entry entry-id)
    579         (if (is-protected? entry-id)
    580             (begin
    581               (override! entry-id)
    582               (if (procedure? proc) (proc) (eval proc))
    583               (unoverride! entry-id))
    584             (if (procedure? proc) (proc) (eval proc)))
    585         (set! cur-entry prev-cur-entry-id))
    586       (write-line (conc "Invalid procedure " proc))))
    587 
    588 (defcmd (without-protection! first . args)
    589   "[entry-id] '(...)" "Perform updates bypassing protection"
    590   (cond ((null? args)
    591           (without-protection* cur-entry first))
    592         ((and (null? (cdr args)) (integer? first))
    593           (without-protection* first (car args)))
    594         (else (assert #f "Invalid arguments " (cons first args)))))
    595 
    596 ;; Entry Management
    597 
    598 (define cur-entry
    599   (query fetch-value
    600          (sql/transient db "SELECT id FROM entry ORDER BY id DESC LIMIT 1;")))
    601 
    602 (define (time-id-strings args)
    603   (cond ((or (null? args) (string? (car args)))
    604           (list (current-seconds) cur-entry args))
    605         ((not (integer? (car args)))
    606           (assert #f "Unknown type parameter for " (car args)))
    607         ((or (null? (cdr args)) (string? (cadr args)))
    608           (list (current-seconds) (car args) (cdr args)))
    609         ((integer? (cadr args))
    610           (list (car args) (cadr args) (cddr args)))
    611         (else (assert #f "Unknown type parameter for " (cadr args)))))
    612 
    613 (define (add-entry* ctime url notes)
    614   (trace `(add-entry ,ctime ,url ,notes))
    615   (let ((new-id
    616     (with-transaction db
    617       (lambda ()
    618         (exec (sql db "INSERT INTO entry(url,notes,ctime,mtime) VALUES (?,?,?,?);")
    619               url notes ctime ctime)
    620         (let ((new-id (last-insert-rowid db)))
    621           (exec (sql db "INSERT INTO tagrel SELECT ?,id FROM tag WHERE auto=1;")
    622                 new-id)
    623           new-id)))))
    624     (set! cur-entry new-id)
    625     (write-line (conc "Added " new-id)))
    626   (update-feed-cache ctime))
    627 
    628 (defcmd (add-entry first second . rest)
    629   "[timestamp] URL note-line [note-line ...]" "Create a new entry"
    630   (if (or (null? rest) (string? first))
    631       (add-entry* (current-seconds)
    632                   first
    633                   (apply string-append (map terminate-line (cons second rest))))
    634       (add-entry* first
    635                   second
    636                   (apply string-append (map terminate-line rest)))))
    637 
    638 (define (add-notes* mtime entry-id lines)
    639   (unless (null? lines)
    640     (trace `(add-notes ,mtime ,entry-id . ,lines))
    641     (with-transaction db
    642       (lambda ()
    643         (let ((prev-notes (query fetch-value
    644                                  (sql db "SELECT notes FROM entry WHERE id=?;")
    645                                  entry-id)))
    646           (unless-protected entry-id
    647             (exec (sql db "UPDATE entry SET notes=?,mtime=? WHERE id=?;")
    648                   (apply string-append prev-notes
    649                     (map terminate-line lines))
    650                   mtime
    651                   entry-id))))))
    652   (update-feed-cache mtime))
    653 
    654 (defcmd (add-notes . args)
    655   "[[timestamp] entry-id] note-line [note-line ...]"
    656   "Append new lines of notes"
    657   (apply add-notes* (time-id-strings args)))
    658 
    659 (define (print-entry-row id url type descr notes protected ptime ctime mtime tags)
    660   (write-line (conc vt100-entry-header
    661                     "#" id (if (zero? protected) "" "*") " - " url
    662                     vt100-reset))
    663     (unless (null? ctime) (write-line (conc "Created   " (rfc-3339 ctime))))
    664     (unless (null? ptime) (write-line (conc "Protected " (rfc-3339 ptime))))
    665     (unless (null? mtime) (write-line (conc "Modified  " (rfc-3339 mtime))))
    666     (unless (null? descr)
    667       (if (null? type)
    668           (write-line "Descripiton:")
    669           (write-line (conc "Description (" type "):")))
    670       (write-string descr))
    671     (unless (null? notes)
    672       (write-line (conc "Notes:"))
    673       (write-string notes))
    674     (if (null? tags)
    675         (write-line "No tags.")
    676         (write-line (string-append "Tags: " tags))))
    677 
    678 (define (print-listed-entry-row id url notes protected)
    679   (write-line (conc vt100-entry-header
    680                     "#" id (if (zero? protected) "" "*") " - " url
    681                     vt100-reset))
    682   (write-string notes))
    683 
    684 (define (count-selection* text id)
    685   (write-line (string-append (if id (conc "#" id ": ") "")
    686                              "\"" text "\""))
    687   (write-line (conc " -> " (query fetch-value
    688                                   ((if id sql sql/transient)
    689                                     db
    690                                     (string-append
    691                                       "SELECT COUNT(id) FROM entry "
    692                                       text ";"))))))
    693 
    694 (defcmd (count-selection . args)
    695   "\"WHERE ...\"|selector-id ..." "Count results of a custom queries"
    696   (if (null? args)
    697       (query (for-each-row* count-selection*)
    698              (sql db "SELECT text,id FROM selector;"))
    699       (let loop ((todo args))
    700         (unless (null? todo)
    701           (call-with-selector (car todo) count-selection*)
    702           (loop (cdr todo))))))
    703 
    704 (defcmd (list-selection arg)
    705   "\"WHERE ...\"|selector-id" "Display a custom query as an entry list"
    706   (call-with-selector arg
    707     (lambda (selector id)
    708       (query (for-each-row* print-listed-entry-row)
    709              ((if id sql sql/transient) db
    710                (string-append "SELECT id,url,notes,protected FROM entry "
    711                               selector ";"))))))
    712 
    713 (defcmd (list-tagged tag-name #!optional (count config-list-tagged-count))
    714   "tag-name [limit]" "Display entries with the given tag"
    715   (query (for-each-row* print-listed-entry-row)
    716          (sql db (cond ((positive? count)
    717                          "SELECT * FROM
    718                             (SELECT id,url,notes,protected FROM entry
    719                               WHERE id IN (SELECT url_id FROM tagrel
    720                                             WHERE tag_id IN (SELECT id FROM tag
    721                                                               WHERE name=?))
    722                             ORDER BY id DESC LIMIT ?)
    723                            ORDER BY id ASC;")
    724                        ((negative? count)
    725                          "SELECT id,url,notes,protected FROM entry
    726                             WHERE id IN (SELECT url_id FROM tagrel
    727                                           WHERE tag_id IN (SELECT id FROM tag
    728                                                             WHERE name=?))
    729                           ORDER BY id ASC LIMIT ?;")
    730                        (else ; (zero? count)
    731                          "SELECT id,url,notes,protected FROM entry
    732                             WHERE id IN (SELECT url_id FROM tagrel
    733                                           WHERE tag_id IN (SELECT id FROM tag
    734                                                             WHERE name=?))
    735                               OR id=?
    736                           ORDER BY id ASC;")))
    737          tag-name
    738          (abs count)))
    739 
    740 (defcmd (list-untagged)
    741   "" "Display entries without any tag"
    742   (query (for-each-row* print-listed-entry-row)
    743          (sql db "SELECT id,url,notes,protected FROM entry
    744                    WHERE id NOT IN (SELECT url_id FROM tagrel);")))
    745 
    746 (define (print-entry* entry-id)
    747   (query (for-each-row* print-entry-row)
    748          (sql db "SELECT entry.id,url,type,description,notes,
    749                          protected,ptime,ctime,mtime,group_concat(tag.name,' ')
    750                   FROM entry
    751                   LEFT OUTER JOIN tagrel ON entry.id=tagrel.url_id
    752                   LEFT OUTER JOIN tag ON tag.id=tagrel.tag_id
    753                   WHERE entry.id=? GROUP BY entry.id;")
    754          entry-id))
    755 
    756 (defcmd (print-entry . args)
    757   "[entry-id]" "Display an entry"
    758   (if (null? args)
    759       (print-entry* cur-entry)
    760       (let loop ((todo args))
    761         (unless (null? todo)
    762           (print-entry* (car todo))
    763           (loop (cdr todo))))))
    764 
    765 (defcmd (print-selection arg)
    766   "\"WHERE ...\"|selector-id" "Display entries from a custom query"
    767   (call-with-selector arg
    768     (lambda (selector id)
    769       (query
    770         (for-each-row* print-entry-row)
    771         ((if id sql sql/transient) db
    772           (string-append
    773             "SELECT entry.id,url,type,description,notes,
    774                     protected,ptime,ctime,mtime,group_concat(tag.name,' ')
    775              FROM entry
    776              LEFT OUTER JOIN tagrel ON entry.id=tagrel.url_id
    777              LEFT OUTER JOIN tag ON tag.id=tagrel.tag_id "
    778             selector
    779             " GROUP BY entry.id;"))))))
    780 
    781 (defcmd (random-tagged tag-name)
    782   "tag" "Select a random entry with the given tag"
    783   (let ((entry-id (query fetch-value
    784                          (sql db "SELECT url_id FROM tagrel WHERE tag_id IN
    785                                     (SELECT id FROM tag WHERE name=?)
    786                                   ORDER BY RANDOM() LIMIT 1;")
    787                          tag-name)))
    788     (if entry-id
    789         (begin
    790           (set! cur-entry entry-id)
    791           (print-entry))
    792         (write-line "No such entry found"))))
    793 
    794 (defcmd (random-untagged)
    795   "" "Select a random entry without tag"
    796   (let ((entry-id (query fetch-value
    797                          (sql db "SELECT id FROM entry WHERE id NOT IN
    798                                     (SELECT url_id FROM tagrel)
    799                                   ORDER BY RANDOM() LIMIT 1;"))))
    800     (if entry-id
    801         (begin
    802           (set! cur-entry entry-id)
    803           (print-entry))
    804         (write-line "No such entry found"))))
    805 
    806 (define (guess-type str)
    807   (cond ((null? str) '())
    808         ((starts-with? "<" str) "html")
    809         ((or (starts-with? " - " str)
    810              (starts-with? " + " str)) "markdown-li")
    811         (else "text")))
    812 
    813 (define (set-descr* mtime entry-id type text)
    814   (trace `(set-descr ,mtime ,entry-id ,type ,text))
    815   (unless-protected entry-id
    816     (exec (sql db "UPDATE entry SET type=?,description=?,mtime=? WHERE id=?;")
    817           type text mtime entry-id)
    818     (update-feed-cache mtime)))
    819 
    820 (defcmd (set-descr first . args)
    821   "[[[mtime] entry-id] type] description" "Sets an entry description"
    822   (case (length args)
    823     ((0) (set-descr* (current-seconds) cur-entry (guess-type first) first))
    824     ((1) (set-descr* (current-seconds) cur-entry first (car args)))
    825     ((2) (set-descr* (current-seconds) first (car args) (cadr args)))
    826     ((3) (set-descr* first (car args) (cadr args) (caddr args)))
    827     (else (assert #f "Too many arguments to set-descr " (cons first args)))))
    828 
    829 (defcmd (set-entry arg)
    830   "entry-id|url" "Set current entry"
    831   (cond ((integer? arg)
    832           (set! cur-entry arg)
    833           (when config-verbose (print-entry)))
    834         ((string? arg)
    835           (let ((id (query fetch-value
    836                            (sql db "SELECT id FROM entry WHERE url=?;")
    837                            arg)))
    838             (if id
    839                 (begin
    840                   (set! cur-entry id)
    841                   (when config-verbose (print-entry)))
    842                 (write-line (conc "No entry found for \"" arg "\"")))))
    843         (else (assert #f "Unsupported argument type for " arg))))
    844 
    845 (define (touch* mtime entry-id)
    846   (trace `(touch ,mtime ,entry-id))
    847   (unless-protected entry-id
    848     (exec (sql db "UPDATE entry SET mtime=? WHERE id=?;") mtime entry-id)
    849     (update-feed-cache mtime)))
    850 
    851 (define (touch . args)
    852   (cond ((null? args)
    853           (touch* (current-seconds) cur-entry))
    854         ((not (integer? (car args)))
    855           (assert #f "Bad type for " (car args)))
    856         ((null? (cdr args))
    857           (touch* (current-seconds) (car args)))
    858         ((not (integer? (cadr args)))
    859           (assert #f "Bad type for " (car args)))
    860         (else
    861           (touch* (car args) (cadr args)))))
    862 
    863 (define (without-mtime* entry-id proc)
    864   (if (or (procedure? proc) (list? proc))
    865       (let ((prev-entry cur-entry)
    866             (prev-mtime (query fetch-value
    867                                (sql db "SELECT mtime FROM entry WHERE id=?;")
    868                                entry-id)))
    869         (set! cur-entry entry-id)
    870         (if (procedure? proc) (proc) (eval proc))
    871         (touch* prev-mtime entry-id)
    872         (set! cur-entry prev-entry))
    873       (write-line (conc "Invalid procedure " proc))))
    874 
    875 (defcmd (without-mtime! first . args)
    876   "[entry-id] '(...)" "Perform updates and restore entry mtime"
    877   (cond ((null? args)
    878           (without-mtime* cur-entry first))
    879         ((and (null? (cdr args)) (integer? first))
    880           (without-mtime* first (car args)))
    881         (else (assert #f "Invalid arguments " (cons first args)))))
    882 
    883 ;; Entry Tagging
    884 
    885 (define (print-tags* entry-id)
    886   (write-line (apply conc (append (list "Tags for " entry-id ":")
    887     (query (map-rows (lambda (x) (string-append " " (car x))))
    888            (sql db "SELECT tag.name FROM tagrel
    889                     OUTER LEFT JOIN tag ON tagrel.tag_id=tag.id
    890                     WHERE url_id=? ORDER BY tag.name;")
    891            entry-id)))))
    892 
    893 (defcmd (print-tags . args)
    894   "[entry-id ...]" "Print tags associated with an entry"
    895   (if (null? args)
    896       (print-tags* cur-entry)
    897       (let loop ((todo args))
    898         (unless (null? todo)
    899           (print-tags* (car todo))
    900           (loop (cdr todo))))))
    901 
    902 
    903 (define (resolve-tag-id tag-name)
    904   (let ((result (query fetch-value
    905                        (sql db "SELECT id from tag WHERE name=?;")
    906                        tag-name)))
    907     (unless result
    908       (write-line (conc "Unknown tag " tag-name)))
    909     result))
    910 
    911 (define (exec-on-tags stmt mtime entry-id tag-list)
    912   (with-transaction db
    913     (lambda ()
    914       (unless-protected entry-id
    915         (let ((tag-id-list (map resolve-tag-id tag-list)))
    916           (when (every identity tag-id-list)
    917             (let loop ((todo tag-id-list))
    918               (if (null? todo)
    919                   (exec (sql db "UPDATE entry SET mtime=? WHERE id=?;")
    920                         mtime entry-id)
    921                   (begin
    922                     (exec stmt entry-id (car todo))
    923                     (loop (cdr todo))))))))))
    924   (print-tags entry-id)
    925   (update-feed-cache mtime))
    926 
    927 (define (retag* mtime entry-id tag-list)
    928   (trace `(retag ,mtime ,entry-id . ,tag-list))
    929   (unless-protected entry-id
    930     (exec (sql db "DELETE FROM tagrel WHERE url_id=?;") entry-id)
    931     (exec-on-tags (sql db "INSERT OR IGNORE INTO tagrel VALUES (?,?);")
    932                   mtime entry-id tag-list)))
    933 
    934 (defcmd (retag . args)
    935   "[[timestamp] entry-id] tag-name [tag-name ...]"
    936   "Overwrite tag list for an entry"
    937   (apply retag* (time-id-strings args)))
    938 
    939 (define (tag* mtime entry-id tag-list)
    940   (unless (null? tag-list)
    941     (trace `(tag ,mtime ,entry-id . ,tag-list))
    942     (exec-on-tags (sql db "INSERT OR IGNORE INTO tagrel VALUES (?,?);")
    943                   mtime entry-id tag-list)))
    944 
    945 (defcmd (tag . args)
    946   "[[timestamp] entry-id] tag-name [tag-name ...]"
    947   "Associate tags to an entry"
    948   (apply tag* (time-id-strings args)))
    949 
    950 (define (untag* mtime entry-id tag-list)
    951   (unless (null? tag-list)
    952     (trace `(untag ,mtime ,entry-id . ,tag-list))
    953     (exec-on-tags (sql db "DELETE FROM tagrel WHERE url_id=? AND tag_id=?;")
    954                   mtime entry-id tag-list)))
    955 
    956 (defcmd (untag . args)
    957   "[[timestamp] entry-id] tag-name [tag-name ...]"
    958   "Disssociates tags from an entry"
    959   (apply untag* (time-id-strings args)))
    960 
    961 ;;;;;;;;;;;;;;;;;;;;
    962 ;; Editor Spawning
    963 
    964 (define (comment-link section url)
    965   (let* ((rss-url  (query fetch-value
    966                           (sql db "SELECT url FROM source_rss WHERE name=?;")
    967                           section)))
    968     (if rss-url
    969       (let ((rss (with-input-from-request rss-url #f rss:read)))
    970         (let loop ((items (rss:feed-items rss)))
    971           (cond
    972             ((null? items) #f)
    973             ((string=? url (rss:item-link (car items)))
    974               (alist-ref 'comments (rss:item-attributes (car items))))
    975             (else (loop (cdr items))))))
    976       #f)))
    977 
    978 (define (edit-descr* entry-id)
    979   (let ((file-name (create-temporary-file
    980                      (string-append "."
    981                        (get-config/default "description-ext" "txt"))))
    982         (fields
    983            (query fetch-row
    984                   (sql db "SELECT description,notes,url FROM entry WHERE id=?;")
    985                   entry-id)))
    986     (unless (null? fields)
    987       (call-with-output-file file-name
    988         (lambda (port)
    989           (if (or (null? (car fields)) (string=? (car fields) ""))
    990             (let* ((s-sec (substring-index "[" (cadr fields)))
    991                    (e-sec (if s-sec
    992                               (substring-index "]" (cadr fields) s-sec)
    993                               #f))
    994                    (sect  (if e-sec
    995                               (substring (cadr fields) (+ s-sec 1) e-sec) #f))
    996                    (comm  (if sect (comment-link sect (caddr fields)) #f)))
    997               (write-string (conc " + [](" (caddr fields) ")\n") #f port)
    998               (when sect
    999                 (write-string
   1000                   (conc "(via "
   1001                     (if comm (conc "[" sect "](" comm ")") sect)
   1002                     " sur #gcufeed)\n")
   1003                   #f port)))
   1004             (write-string (car fields) #f port))
   1005           (unless (null? (cadr fields))
   1006             (write-string "-+-+-\n" #f port)
   1007             (write-string (cadr fields) #f port)))))
   1008     (when config-editor
   1009       (process-wait
   1010         (process-run (string-append config-editor " " (qs file-name)))))
   1011     (let ((result (call-with-input-file file-name
   1012                     (lambda (port)
   1013                       (let* ((text (read-string #f port))
   1014                              (end  (substring-index-ci "-+-+-\n" text)))
   1015                         (if end
   1016                             (substring text 0 end)
   1017                             text))))))
   1018       (delete-file file-name)
   1019       (if (or (zero? (string-length result))
   1020               (equal? (if (or (null? fields) (null? (car fields)))
   1021                           "" (car fields))
   1022                       result))
   1023           #f
   1024           result))))
   1025 
   1026 
   1027 (defcmd (edit-descr . args)
   1028   "[[mtime] entry-id]" "Describe using an external editor"
   1029   (let ((new-value (case (length args)
   1030                      ((0) (edit-descr* cur-entry))
   1031                      ((1) (edit-descr* (car args)))
   1032                      ((2) (edit-descr* (cadr args)))
   1033                      (else
   1034                        (assert #f "Too many arguments to edit-descr " args)))))
   1035     (when new-value
   1036       (case (length args)
   1037         ((0) (set-descr* (current-seconds)
   1038                          cur-entry
   1039                          (guess-type new-value)
   1040                          new-value))
   1041         ((1) (set-descr* (current-seconds)
   1042                          (car args)
   1043                          (guess-type new-value)
   1044                          new-value))
   1045         ((2) (set-descr* (car args)
   1046                          (cadr args)
   1047                          (guess-type new-value)
   1048                          new-value))
   1049         (else (assert #f "Too many arguments to edit-descr " args))))))
   1050 
   1051 (define (auto-cols widths avail)
   1052   (letrec ((len     (vector-length widths))
   1053          (w-slice (lambda (start len acc)
   1054                     (if (< len 1)
   1055                         acc
   1056                         (w-slice (+ start 1) (- len 1)
   1057                                  (max acc (vector-ref widths start))))))
   1058          (w-total (lambda (start stride acc)
   1059                     (if (< (+ start stride) len)
   1060                         (w-total (+ start stride)
   1061                                  stride
   1062                                  (cons (+ (car acc) 1 (w-slice start stride 0))
   1063                                        acc))
   1064                         (cons (+ (car acc) (w-slice start (- len start) 0))
   1065                               acc))))
   1066          (h-cols  (lambda (ncols) (quotient (+ len ncols -1) ncols)))
   1067          (w-cols  (lambda (ncols) (w-total 0 (h-cols ncols) (list 0)))))
   1068     (let loop ((ncols len) (best #f))
   1069       (if (zero? ncols) best
   1070         (let ((w (w-cols ncols)) (h (h-cols ncols)))
   1071           (loop (- ncols 1)
   1072                 (if (and (< (car w) avail)
   1073                          (or (not best) (<= h (car best))))
   1074                     (list h (list->vector (reverse (cdr w))))
   1075                     best)))))))
   1076 
   1077 (define (select-tags** entry-id tags)
   1078   (let* ((ntags (vector-length tags))
   1079          (state (list->vector (map cadddr (vector->list tags))))
   1080          (cols (auto-cols (list->vector
   1081                             (map (lambda (x)
   1082                                    (+ (string-length (cadr x))
   1083                                       (string-length (caddr x))))
   1084                                  (vector->list tags))) (COLS)))
   1085          (stride (car cols))
   1086          (x-cols (cadr cols))
   1087          (show-tag (lambda (index sel)
   1088                      (unless (zero? (vector-ref state index))
   1089 ;                      (attron (COLOR_PAIR 1)))
   1090                        (attron A_REVERSE))
   1091                      (when (= index sel)
   1092 ;                      (attron A_REVERSE))
   1093                        (attron A_UNDERLINE))
   1094                      (mvprintw
   1095                        (remainder index stride)
   1096                        (vector-ref x-cols (quotient index stride))
   1097                        "~A~A"
   1098                        (cadr (vector-ref tags index))
   1099                        (caddr (vector-ref tags index)))
   1100                      (when (= index sel)
   1101 ;                      (attroff A_REVERSE))
   1102                        (attroff A_UNDERLINE))
   1103                      (unless (zero? (vector-ref state index))
   1104 ;                      (attroff (COLOR_PAIR 1)))))
   1105                        (attroff A_REVERSE))))
   1106          (update-tags (lambda (old new) (show-tag old new) (show-tag new new))))
   1107     (keypad (stdscr) #t)
   1108     (noecho)
   1109     (curs_set 0)
   1110 ;   (start_color)
   1111 ;   (init_pair 1 COLOR_BLUE COLOR_BLACK)
   1112     (let init ((index 0))
   1113       (when (< index ntags)
   1114         (show-tag index 0)
   1115         (init (+ index 1))))
   1116     (let loop ((sel 0))
   1117       (let ((c (char->integer (getch))))
   1118         (cond
   1119           ((= c KEY_UP)
   1120             (let ((next-sel (modulo (- sel 1) ntags)))
   1121               (update-tags sel next-sel)
   1122               (loop next-sel)))
   1123           ((= c KEY_DOWN)
   1124             (let ((next-sel (modulo (+ sel 1) ntags)))
   1125               (update-tags sel next-sel)
   1126               (loop next-sel)))
   1127           ((= c KEY_LEFT)
   1128             (let ((next-sel (if (>= sel stride)
   1129                                 (- sel stride)
   1130                                 (min (+ sel (- ntags (modulo ntags stride)))
   1131                                      (- ntags 1)))))
   1132               (update-tags sel next-sel)
   1133               (loop next-sel)))
   1134           ((= c KEY_RIGHT)
   1135             (let ((next-sel (cond ((< (+ sel stride) ntags)
   1136                                     (+ sel stride))
   1137                                   ((< sel (- ntags (modulo ntags stride)))
   1138                                     (- ntags 1))
   1139                                   (else (modulo sel stride)))))
   1140               (update-tags sel next-sel)
   1141               (loop next-sel)))
   1142           ((= c 32)
   1143             (vector-set! state sel (- 1 (vector-ref state sel)))
   1144             (show-tag sel sel)
   1145             (loop sel))
   1146           ((= c 10)
   1147             (let result ((index 0) (add '()) (del '()))
   1148               (cond
   1149                 ((>= index ntags)
   1150                   (list add del))
   1151                 ((= (cadddr (vector-ref tags index)) (vector-ref state index))
   1152                   (result (+ index 1) add del))
   1153                 ((zero? (vector-ref state index))
   1154                   (result (+ index 1) add
   1155                           (cons (cadr (vector-ref tags index)) del)))
   1156                 (else
   1157                   (result (+ index 1)
   1158                           (cons (cadr (vector-ref tags index)) add)
   1159                           del)))))
   1160           ((= c 27) '(()()))
   1161           ((or (<= 65 c 90) (<= 97 c 122))
   1162             (let search ((prev-sel sel)
   1163                          (prev-ch  (char->integer (string-ref
   1164                                            (cadr (vector-ref tags sel)) 0))))
   1165               (let* ((next-sel (modulo (+ prev-sel 1) ntags))
   1166                      (next-ch  (char->integer (string-ref
   1167                                        (cadr (vector-ref tags next-sel)) 0))))
   1168                 (cond
   1169                   ((= next-sel sel)
   1170                     (loop sel))
   1171                   ((or (= next-ch c) (< prev-ch c next-ch))
   1172                     (update-tags sel next-sel)
   1173                     (loop next-sel))
   1174                   (else (search next-sel next-ch))))))
   1175           (else (mvprintw (+ 1 stride) 0 "~S ~S" KEY_DOWN c) (loop sel)))))))
   1176 
   1177 (define (select-tags* entry-id)
   1178   (if (update-allowed? entry-id)
   1179     (let ((tags (list->vector (query
   1180                    (map-rows* (lambda (id name count active)
   1181                                 (list id name (conc " (" count ")")
   1182                                       active)))
   1183                    (sql db
   1184                     "SELECT id,name,COUNT(url_id),COALESCE(MAX(url_id==?),0)
   1185                      FROM tag LEFT OUTER JOIN tagrel ON tag_id=tag.id
   1186                      GROUP BY tag.name;")
   1187                    entry-id))))
   1188       (dynamic-wind initscr (lambda () (select-tags** entry-id tags)) endwin))
   1189     '(()())))
   1190 
   1191 (defcmd (select-tags . args)
   1192   "[[mtime] entry-id]" "Interactively select tags using dialog(1)"
   1193   (let* ((entry-id (case (length args)
   1194                      ((0) cur-entry)
   1195                      ((1) (car args))
   1196                      ((2) (cadr args))
   1197                      (else
   1198                        (assert #f "Too many arguments to select-tags " args))))
   1199          (mtime    (if (= 2 (length args)) (car args) (current-seconds)))
   1200          (changes  (select-tags* entry-id))
   1201          (added    (car changes))
   1202          (removed  (cadr changes)))
   1203     (unless-protected entry-id
   1204       (untag* (- mtime 1) entry-id removed)
   1205       (tag* mtime entry-id added))))
   1206 
   1207 ;;;;;;;;;;;;;;;;;;;;;
   1208 ;; Gruik Management
   1209 
   1210 (define (pull-gruiks* mtime mark)
   1211   (let ((last-id (query fetch-value (sql db "SELECT MAX(id) FROM entry;"))))
   1212     (exec
   1213       (sql db "INSERT OR IGNORE
   1214                  INTO entry(url,type,description,notes,ctime,mtime)
   1215                SELECT url,
   1216                       CASE WHEN description IS NULL THEN NULL
   1217                            WHEN substr(description,1,1)='<' THEN 'html'
   1218                            WHEN substr(description,1,3)=' - '
   1219                             OR substr(description,1,3)=' + ' THEN 'markdown-li'
   1220                            ELSE 'text' END,
   1221                       trim(description,char(10))||char(10),
   1222                       trim(notes,char(10))||char(10),
   1223                       stime,?
   1224                FROM gruik
   1225                WHERE mark=? AND url NOT IN (SELECT url FROM entry);")
   1226       mtime
   1227       mark)
   1228     (exec
   1229       (sql db "INSERT OR IGNORE INTO tagrel(url_id,tag_id)
   1230                SELECT entry.id,tag_id
   1231                FROM gruik_tags LEFT OUTER JOIN gruik ON gruik_id = gruik.id
   1232                                LEFT OUTER JOIN entry ON gruik.url = entry.url
   1233                WHERE gruik.mark=?;")
   1234       mark)
   1235     (exec
   1236       (sql db "DELETE FROM gruik WHERE mark=?;")
   1237       mark)
   1238     (print-selection (conc "WHERE entry.id > " last-id)))
   1239     (update-feed-cache mtime))
   1240 
   1241 (defcmd (pull-gruiks mark)
   1242   "mark" "import gruiks at the given mark level"
   1243   (let* ((wh (conc "WHERE url IN (SELECT url FROM gruik WHERE mark=" mark ")"))
   1244          (n  (query fetch-value
   1245                (sql/transient db (conc "SELECT COUNT(id) FROM entry " wh)))))
   1246     (if (zero? n)
   1247         (pull-gruiks* (current-seconds) mark)
   1248         (begin
   1249           (write-line (conc vt100-alert "Conflicting gruiks:" vt100-reset))
   1250           (query
   1251             (for-each-row* (lambda (id url notes)
   1252               (write-line (conc id " - " vt100-entry-header url vt100-reset))
   1253               (write-line notes)))
   1254             (sql db "SELECT id,url,notes FROM gruik
   1255                      WHERE mark=? AND url IN (SELECT url FROM entry);")
   1256             mark)
   1257           (write-line (conc vt100-alert "Conflicting entries:" vt100-reset))
   1258           (print-selection wh)))))
   1259 
   1260 (defcmd (catchup-gruik)
   1261   "" "skip all past unfetched gruiks"
   1262   (let ((src-path (get-config "gruik-source")))
   1263     (write-line (conc "Before: " (get-config "gruik-seen")))
   1264     (when src-path
   1265       (set-config "gruik-seen" (file-size src-path)))
   1266     (write-line (conc "After " (get-config "gruik-seen")))))
   1267 
   1268 ;;;;;;;;;;;;;;;;;;;;
   1269 ;; Feed Generation
   1270 
   1271 (define (atom-content type descr notes)
   1272   (cond ((null? descr) `(atom:content ,notes))
   1273         ((null? type)  `(atom:content ,descr))
   1274         ((equal? type "markdown-li")
   1275           (let ((acc (open-output-string))
   1276                 (prev-output (current-output-port)))
   1277             (current-output-port acc)
   1278             (let ((result (markdown->html (substring descr 3))))
   1279               (current-output-port prev-output)
   1280               (if result
   1281                   `(atom:content (@ (type "html")) ,(get-output-string acc))
   1282                   `(atom:content ,descr)))))
   1283         (else `(atom:content (@ (type ,type)) ,descr))))
   1284 
   1285 (define (feed->sxml id url type descr notes ptime ctime mtime)
   1286   `(atom:entry
   1287      (atom:id ,(string-append config-entry-id-prefix (number->string id)))
   1288      (atom:title ,url)
   1289      (atom:updated ,(rfc-3339 mtime))
   1290      (atom:published ,(rfc-3339 (if (null? ptime) ctime ptime)))
   1291      (atom:link (@ (rel "related") (href ,url)))
   1292      ,(atom-content type descr notes)
   1293      ,@(query (map-rows (lambda (x) `(atom:category (@ (term ,(car x))))))
   1294               (sql db "SELECT tag.name FROM tagrel
   1295                        OUTER LEFT JOIN tag ON tagrel.tag_id=tag.id
   1296                        WHERE url_id=? ORDER BY tag.name;")
   1297               id)))
   1298 
   1299 (define (write-feed mtime title self rows)
   1300   (write-string
   1301     (serialize-sxml
   1302       `(*TOP* (@ (*NAMESPACES* (atom "http://www.w3.org/2005/Atom")))
   1303          (*PI* xml "version='1.0' encoding='utf-8'")
   1304          (atom:feed
   1305            (atom:title ,title)
   1306            (atom:author
   1307              (atom:name ,(if config-author-name
   1308                              config-author-name
   1309                              "Unknown Author"))
   1310              ,@(if config-author-email `((atom:email ,config-author-email)) '())
   1311              ,@(if config-author-uri `((atom:uri ,config-author-uri)) '()))
   1312            (atom:id ,self)
   1313            (atom:link (@ (rel "self") (href ,self)))
   1314            (atom:updated ,(rfc-3339 mtime))
   1315            ,@(map (lambda (row) (apply feed->sxml row)) rows)))
   1316       ns-prefixes: '((*default* . "http://www.w3.org/2005/Atom")))))
   1317 
   1318 (define (generate-feed forced feed-id filename url selector title mtime)
   1319   (let* ((rows (query fetch-rows
   1320                       (sql db (string-append "SELECT id,url,type,description,
   1321                                                      notes,ptime,ctime,mtime
   1322                                               FROM entry " selector ";"))))
   1323          (generate?
   1324            (cond ((null? rows)
   1325                    (when config-verbose
   1326                      (write-line (conc "Feed " feed-id " is empty")))
   1327                    #f)
   1328                  ((any (cut = feed-id <>) dirty-feeds)
   1329                    (when config-verbose
   1330                      (write-line (conc "Generating feed " feed-id)))
   1331                    #t)
   1332                  (forced
   1333                    (when config-verbose
   1334                      (write-line (conc "Generating feed " feed-id
   1335                                        " unconditionally")))
   1336                    #t)
   1337                  (else
   1338                    (when config-verbose
   1339                      (write-line (conc "Feed " feed-id
   1340                                        " is already up to date")))
   1341                    #t))))
   1342     (when generate?
   1343       (with-output-to-file filename
   1344         (lambda () (write-feed (if (null? mtime) (list-ref (car rows) 7) mtime)
   1345                                title url rows)))
   1346       (set! dirty-feeds (delete! feed-id dirty-feeds =))
   1347       (set! feed-cache
   1348         (alist-update! feed-id
   1349                        (map (lambda (row) (list (car row) (list-ref row 7)))
   1350                             rows)
   1351                        feed-cache =)))))
   1352 
   1353 (define (generate-feeds forced id-list)
   1354   (for-each
   1355     (lambda (row) (apply generate-feed forced row))
   1356     (if (null? id-list)
   1357         (query fetch-rows
   1358                (sql db "SELECT id,filename,url,selector,title,mtime
   1359                         FROM feed WHERE active=1;"))
   1360         (map (lambda (id)
   1361                (query fetch
   1362                       (sql db "SELECT id,filename,url,selector,title,mtime
   1363                                FROM feed WHERE id=?;")
   1364                       id))
   1365              id-list))))
   1366 
   1367 (defcmd (force-generate . args)
   1368   "[feed-id ...]"
   1369   "Generate unconditionally the given feeds, or all active feeds"
   1370   (generate-feeds #t args))
   1371 
   1372 (defcmd (generate . args)
   1373   "[feed-id ...]" "Generate if needed the given feeds, or all active feeds"
   1374   (generate-feeds #f args))
   1375 
   1376 ;;;;;;;;;;;;;
   1377 ;; Auto Add
   1378 
   1379 (define (auto-add lines)
   1380   (unless arg-replay
   1381     (trace `(auto-add ,lines))
   1382     (let loop ((index 0) (urls '()))
   1383       (let* ((start0 (substring-index-ci "https://" lines index))
   1384              (start  (if start0 start0
   1385                          (substring-index-ci "http://" lines index)))
   1386              (end    (if start
   1387                          (apply min
   1388                            (filter identity
   1389                              (list
   1390                                (string-length lines)
   1391                                (substring-index " " lines start)
   1392                                (substring-index "\n" lines start))))
   1393                          #f)))
   1394         (cond (start
   1395                 (loop end (cons (substring lines start end) urls)))
   1396               ((null? urls)
   1397                 (write-line (conc "Warning: no URL found")))
   1398               (else
   1399                 (for-each (lambda (url) (add-entry url lines)) urls)))))))
   1400 
   1401 ;;;;;;;;;;;;;;
   1402 ;; Main loop
   1403 
   1404 (defcmd (replay filename)
   1405   "filename" "Replay the given file"
   1406   (let ((old-arg-replay arg-replay))
   1407     (set! arg-replay #t)
   1408     (load filename)
   1409     (set! arg-replay old-arg-replay)))
   1410 
   1411 (define write-each-row
   1412   (for-each-row
   1413     (lambda (row) (if (= 1 (length row))
   1414                       (write-line (->string (car row)))
   1415                       (begin (write row) (newline))))))
   1416 
   1417 (define (write-query text . args)
   1418    (apply query write-each-row (sql/transient db text) args))
   1419 
   1420 (defcmd (help)
   1421   "" "Display this help"
   1422   (for-each
   1423     (lambda (row)
   1424       (write-line (conc
   1425         "("
   1426         (car row)
   1427         (if (zero? (string-length (cadr row))) "" " ")
   1428         (cadr row)
   1429         ")"))
   1430       (write-line (conc "    " (caddr row))))
   1431     cmd-list))
   1432 
   1433 (set! cmd-list (sort! cmd-list (lambda (r1 r2) (string<? (car r1) (car r2)))))
   1434 
   1435 (define completion-ptr cmd-list)
   1436 (define new-completion #t)
   1437 (define (completer prefix state)
   1438   (when (zero? state)
   1439     (set! completion-ptr cmd-list)
   1440     (set! new-completion #t))
   1441   (let ((buf (line-buffer)))
   1442     (cond ((and (positive? (string-length buf))
   1443                 (not (eqv? (string-ref buf 0) #\()))
   1444             #f)
   1445           ((substring-index " " buf)
   1446             (let ((other-state (if new-completion 0 state)))
   1447               (set! new-completion #f)
   1448               (scheme-completer prefix other-state)))
   1449           (else
   1450             (let loop ()
   1451               (cond ((null? completion-ptr)
   1452                       #f)
   1453                     ((starts-with? prefix (caar completion-ptr))
   1454                       (let ((result (caar completion-ptr)))
   1455                         (set! completion-ptr (cdr completion-ptr))
   1456                         result))
   1457                     (else
   1458                         (set! completion-ptr (cdr completion-ptr))
   1459                         (loop))))))))
   1460 
   1461 (define state 'general)
   1462 (define (prompt)
   1463   (string-append
   1464     (if (null? protection-overrides)
   1465         ""
   1466         (string-append "!"
   1467           (string-intersperse (map ->string protection-overrides) ",")))
   1468     (cond ((eqv? state 'general) "> ")
   1469           ((eqv? state 'in-command) "… ")
   1470           (else "? "))))
   1471 
   1472 (define (interactive-main)
   1473   (basic-quote-characters-set! "\"|")
   1474   (completer-word-break-characters-set! "\"\'`;|()[] ")
   1475   (completer-set! completer)
   1476   (variable-bind! "blink-matching-paren" "on")
   1477   (paren-blink-timeout-set! 200000)
   1478 
   1479   (let ((handler (signal-handler signal/int)))
   1480     (set-signal-handler! signal/int (lambda (s) (cleanup-after-signal!)
   1481                                                 (reset-after-signal!)
   1482                                                 (handler s))))
   1483   (on-exit reset-terminal!)
   1484   (current-input-port (make-readline-port prompt))
   1485 
   1486   (let main-loop ()
   1487     (let ((c (peek-char)))
   1488       (cond ((eof-object? c))
   1489             ((eqv? c #\()
   1490               (set! state 'in-command)
   1491               (handle-exceptions
   1492                 exn
   1493                 (begin
   1494                   (print-error-message exn)
   1495                   (print-call-chain))
   1496                 (eval (read)))
   1497               (set! state 'general)
   1498               (main-loop))
   1499             (else
   1500               (let data-loop ((acc (list (read-line))))
   1501                 (if (char-ready?)
   1502                     (data-loop (cons (read-line) acc))
   1503                     (let ((lines (reverse-string-append
   1504                                    (map terminate-line acc))))
   1505                       (when (positive? (string-length lines))
   1506                         (auto-add lines))
   1507                       (main-loop)))))))))
   1508 
   1509 (cond ((not arg-replay)
   1510         (interactive-main))
   1511       ((eqv? (string-ref arg-replay 0) #\()
   1512         (eval (read (open-input-string arg-replay))))
   1513       (else
   1514         (load arg-replay)))