iens

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

iens.scm (48956B)


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