iens

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

iens.scm (53467B)


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