iens

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

iens.scm (48746B)


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