iens

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

iens.scm (52168B)


      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 (write-diff sig-diff)
    265   (for-each
    266     (lambda (hunk)
    267       (cond ((eqv? (car hunk) 'add)
    268               (write-line (conc "    added item #" (cadr hunk)
    269                                 " at " (rfc-3339 (caddr hunk)))))
    270             ((eqv? (car hunk) 'del)
    271               (write-line (conc "    removed item #" (cadr hunk)
    272                                 " at " (rfc-3339 (caddr hunk)))))
    273             ((eqv? (car hunk) 'chg)
    274               (write-line (conc "    updated item #" (cadr hunk)
    275                                 ": " (rfc-3339 (caddr hunk))
    276                                 " → " (rfc-3339 (cadddr hunk)))))
    277           (else (assert #f "Should be unreachable"))))
    278     sig-diff))
    279 
    280 (define feed-cache
    281   (query (map-rows* (lambda (id selector)
    282                             (cons id (build-signature selector))))
    283          (sql db "SELECT id,selector FROM feed WHERE active=1;")))
    284 (define dirty-feeds '())
    285 
    286 (define (check-feed* id)
    287   (let ((new (query fetch-value
    288                     (sql db "SELECT selector FROM feed WHERE id=?;")
    289                     id))
    290         (old (alist-ref id feed-cache = '())))
    291     (cond ((and (not new) (null? old))
    292             (write-line (conc "Feed #" id " does not exist")))
    293           ((not new)
    294             (write-line (conc "Feed #" id " does not exist anymore")))
    295           ((null? old)
    296             (write-line (conc "Feed #" id " is not cached")))
    297           (else
    298             (let ((sig-diff (diff-signature old (build-signature new))))
    299               (if (null? sig-diff)
    300                   (write-line (conc "Feed #" id " has not changed"))
    301                   (write-line (conc "Feed #" id " was modified:")))
    302               (write-diff sig-diff))))))
    303 
    304 (defcmd (check-feed . args)
    305   "[feed-id ...]" "Check the cache for the given feeds, or all active feeds"
    306   (for-each check-feed*
    307     (if (null? args)
    308         (query fetch-column (sql db "SELECT id FROM feed WHERE active=1;"))
    309         args)))
    310 
    311 (define (update-feed-cache* mtime id)
    312   (let ((data (query fetch-row
    313                      (sql db "SELECT mtime,selector,filename,title,url
    314                               FROM feed WHERE id=?;")
    315                      id))
    316         (old-sig (alist-ref id feed-cache = '())))
    317     (if (null? data)
    318         (write-line (conc "Feed #" id " does not exist"))
    319         (let ((new-sig (build-signature (cadr data))))
    320           (unless (equal? old-sig new-sig)
    321             (when (or (null? (car data))
    322                       (> mtime (car data)))
    323               (touch-feed mtime id)
    324               (set! (car data) mtime))
    325             (when config-verbose
    326                (write-line (if config-autogenerate
    327                                (conc "Autogenerating feed " id)
    328                                (conc "Marking feed " id " as dirty:")))
    329                (write-diff (diff-signature old-sig new-sig)))
    330             (if config-autogenerate
    331                 (with-output-to-file (caddr data) ;filename
    332                   (cut write-feed
    333                     (car data) ;mtime
    334                     (list-ref data 3) ;title
    335                     (list-ref data 4) ;url
    336                     (query fetch-rows
    337                       (sql db (string-append "SELECT id,url,type,description,
    338                                                      notes,ptime,ctime,mtime
    339                                               FROM entry " (cadr data) ";")))))
    340                 (unless (any (cut = id <>) dirty-feeds)
    341                   (set! dirty-feeds (cons id dirty-feeds))))
    342             (set! feed-cache
    343                   (alist-update! id new-sig feed-cache =)))))))
    344 
    345 (define (update-feed-cache mtime . id-list)
    346   (for-each
    347     (cut update-feed-cache* mtime <>)
    348     (if (null? id-list)
    349         (query fetch-column (sql db "SELECT id FROM feed WHERE active=1;"))
    350         id-list)))
    351 
    352 ;; Tag Management
    353 
    354 (define (set-tag-auto name auto)
    355   (exec (sql db "UPDATE tag SET auto=? WHERE name=?;") auto name))
    356 
    357 (defcmd (add-auto-tag name . rest)
    358   "tag-name [tag-name ...]" "Set tags as automatic"
    359   (trace `(add-auto-tag ,name))
    360   (set-tag-auto name 1)
    361   (unless (null? rest)
    362     (apply add-auto-tag rest)))
    363 
    364 (defcmd (add-tag name . rest)
    365   "tag-name [tag-name ...]" "Create a new tag"
    366   (trace `(add-tag ,name))
    367   (exec (sql db "INSERT INTO tag(name) VALUES (?);") name)
    368   (unless (null? rest)
    369     (apply add-tag rest)))
    370 
    371 (defcmd (auto-tags . tag-list)
    372   "[tag-name ...]" "Set the list of automatic tags"
    373   (trace `(auto-tags . ,tag-list))
    374   (with-transaction db
    375     (lambda ()
    376       (exec (sql db "UPDATE tag SET auto=0;"))
    377       (let loop ((todo tag-list))
    378         (unless (null? todo)
    379           (set-tag-auto (car todo) 1)
    380           (loop (cdr todo)))))))
    381 
    382 (define (n-split l n)
    383   (let loop ((todo-l l) (todo-n n) (acc '()))
    384     (if (or (zero? todo-n) (null? todo-l))
    385         (reverse acc)
    386         (let ((chunk-size (ceiling (/ (length todo-l) todo-n))))
    387           (loop (drop todo-l chunk-size)
    388                 (sub1 todo-n)
    389                 (cons (take todo-l chunk-size) acc))))))
    390 
    391 (define (expand-cols cols)
    392   (let loop ((todo cols) (acc '()))
    393     (if (> (length todo) 1)
    394         (loop
    395           (cons (append (cadr todo)
    396                         (make-list (- (length (car todo)) (length (cadr todo)))
    397                                    ""))
    398                 (cddr todo))
    399           (let ((width (apply max (map string-length (car todo)))))
    400             (cons
    401               (append
    402                 (map (lambda (s t)
    403                        (string-append
    404                          s
    405                          (make-string (- width -2 (string-length s))
    406                                       #\space)))
    407                      (car todo)
    408                      (cadr todo))
    409                 (drop (car todo) (length (cadr todo))))
    410               acc)))
    411         (reverse (append todo acc)))))
    412 
    413 (defcmd (list-tags #!optional (cols 1) (threshold 0))
    414   "[n-columns [min-count]]"
    415   "List available tag, automatic tags are marked with *"
    416   (apply for-each
    417          (lambda row
    418            (write-line (apply string-append row)))
    419          (expand-cols
    420            (n-split
    421              (query
    422                (map-rows*
    423                  (lambda (name auto count)
    424                    (conc name (if (zero? auto) " (" "* (") count ")")))
    425                (sql db "SELECT name,auto,COUNT(tagrel.url_id) AS cnt
    426                         FROM tag OUTER LEFT JOIN tagrel ON id=tagrel.tag_id
    427                         GROUP BY id HAVING cnt >= ? ORDER BY name;")
    428                threshold)
    429              cols))))
    430 
    431 (defcmd (remove-auto-tag name . rest)
    432   "[tag-name ...]" "Set tags as not automatic"
    433   (trace `(remove-auto-tag ,name))
    434   (set-tag-auto name 0)
    435   (unless (null? rest)
    436     (apply remove-auto-tag rest)))
    437 
    438 (defcmd (remove-tag name . rest)
    439   "tag-name [tag-name ...]" "Remove tags"
    440   (trace `(remove-tag ,name))
    441   (exec (sql db "DELETE FROM tag WHERE name=?;") name)
    442   (unless (null? rest)
    443     (apply remove-tag rest)))
    444 
    445 (defcmd (rename-tag old-name new-name)
    446   "old-tag-name new-tag-name" "Rename a tag, preserving associations"
    447   (trace `(rename-tag ,old-name ,new-name))
    448   (exec (sql db "UPDATE tag SET name=? WHERE name=?;") new-name old-name))
    449 
    450 ;; Entry Protection
    451 
    452 (define (is-protected? entry-id)
    453   (not (zero?
    454           (query fetch-value
    455                  (sql db "SELECT protected FROM entry WHERE id=?;")
    456                  entry-id))))
    457 
    458 (define protection-overrides '())
    459 
    460 (define (is-overridden? entry-id)
    461   (any (cut = entry-id <>) protection-overrides))
    462 
    463 (define (update-allowed? entry-id)
    464   (or (not (is-protected? entry-id)) (is-overridden? entry-id)))
    465 
    466 (define-syntax unless-protected
    467   (syntax-rules ()
    468     ((unless-protected entry-id . form)
    469       (if (update-allowed? entry-id)
    470           (begin . form)
    471           (write-line (conc "Warning: entry " entry-id " is protected"))))))
    472 
    473 (define (unoverride! entry-id)
    474   (trace `(unoverride! ,entry-id))
    475   (set! protection-overrides (delete! entry-id protection-overrides =)))
    476 
    477 (define (protect* ptime entry-id)
    478   (trace `(protect ,ptime ,entry-id))
    479   (unless-protected entry-id
    480      (exec (sql db "UPDATE entry SET protected=1,ptime=? WHERE id=?;")
    481            ptime entry-id)
    482      (update-feed-cache ptime)))
    483 
    484 (defcmd (protect . args)
    485   "[[timestamp] entry-id]" "Protect entries from modification"
    486   (cond ((null? args)
    487           (protect* (current-seconds) cur-entry))
    488         ((null? (cdr args))
    489           (protect* (current-seconds) (car args)))
    490         (else
    491           (protect* (car args) (cadr args)))))
    492 
    493 (define (override! entry-id)
    494   (trace `(override! ,entry-id))
    495   (unless (update-allowed? entry-id)
    496     (set! protection-overrides (cons entry-id protection-overrides))))
    497 
    498 (define (unprotect* mtime entry-id)
    499   (trace `(unprotect ,mtime ,entry-id))
    500   (exec (sql db "UPDATE entry SET protected=0,ptime=NULL,mtime=? WHERE id=?;")
    501         mtime entry-id)
    502   (update-feed-cache mtime))
    503 
    504 (defcmd (unprotect . args)
    505   "[[timestamp] entry-id]" "Unprotect entries from modification"
    506   (cond ((null? args)
    507           (unprotect* (current-seconds) cur-entry))
    508         ((null? (cdr args))
    509           (unprotect* (current-seconds) (car args)))
    510         (else
    511           (unprotect* (car args) (cadr args)))))
    512 
    513 (define (without-protection* entry-id proc)
    514   (if (or (procedure? proc) (list? proc))
    515       (let ((prev-cur-entry-id cur-entry))
    516         (set! cur-entry entry-id)
    517         (if (is-protected? entry-id)
    518             (begin
    519               (override! entry-id)
    520               (if (procedure? proc) (proc) (eval proc))
    521               (unoverride! entry-id))
    522             (if (procedure? proc) (proc) (eval proc)))
    523         (set! cur-entry prev-cur-entry-id))
    524       (write-line (conc "Invalid procedure " proc))))
    525 
    526 (defcmd (without-protection! first . args)
    527   "[entry-id] '(...)" "Perform updates bypassing protection"
    528   (cond ((null? args)
    529           (without-protection* cur-entry first))
    530         ((and (null? (cdr args)) (integer? first))
    531           (without-protection* first (car args)))
    532         (else (assert #f "Invalid arguments " (cons first args)))))
    533 
    534 ;; Entry Management
    535 
    536 (define cur-entry
    537   (query fetch-value
    538          (sql/transient db "SELECT id FROM entry ORDER BY id DESC LIMIT 1;")))
    539 
    540 (define (time-id-strings args)
    541   (cond ((or (null? args) (string? (car args)))
    542           (list (current-seconds) cur-entry args))
    543         ((not (integer? (car args)))
    544           (assert #f "Unknown type parameter for " (car args)))
    545         ((or (null? (cdr args)) (string? (cadr args)))
    546           (list (current-seconds) (car args) (cdr args)))
    547         ((integer? (cadr args))
    548           (list (car args) (cadr args) (cddr args)))
    549         (else (assert #f "Unknown type parameter for " (cadr args)))))
    550 
    551 (define (add-entry* ctime url notes)
    552   (trace `(add-entry ,ctime ,url ,notes))
    553   (let ((new-id
    554     (with-transaction db
    555       (lambda ()
    556         (exec (sql db "INSERT INTO entry(url,notes,ctime,mtime) VALUES (?,?,?,?);")
    557               url notes ctime ctime)
    558         (let ((new-id (last-insert-rowid db)))
    559           (exec (sql db "INSERT INTO tagrel SELECT ?,id FROM tag WHERE auto=1;")
    560                 new-id)
    561           new-id)))))
    562     (set! cur-entry new-id)
    563     (write-line (conc "Added " new-id)))
    564   (update-feed-cache ctime))
    565 
    566 (defcmd (add-entry first second . rest)
    567   "[timestamp] URL note-line [note-line ...]" "Create a new entry"
    568   (if (or (null? rest) (string? first))
    569       (add-entry* (current-seconds)
    570                   first
    571                   (apply string-append (map terminate-line (cons second rest))))
    572       (add-entry* first
    573                   second
    574                   (apply string-append (map terminate-line rest)))))
    575 
    576 (define (add-notes* mtime entry-id lines)
    577   (unless (null? lines)
    578     (trace `(add-notes ,mtime ,entry-id . ,lines))
    579     (with-transaction db
    580       (lambda ()
    581         (let ((prev-notes (query fetch-value
    582                                  (sql db "SELECT notes FROM entry WHERE id=?;")
    583                                  entry-id)))
    584           (unless-protected entry-id
    585             (exec (sql db "UPDATE entry SET notes=?,mtime=? WHERE id=?;")
    586                   (apply string-append prev-notes
    587                     (map terminate-line lines))
    588                   mtime
    589                   entry-id))))))
    590   (update-feed-cache mtime))
    591 
    592 (defcmd (add-notes . args)
    593   "[[timestamp] entry-id] note-line [note-line ...]"
    594   "Append new lines of notes"
    595   (apply add-notes* (time-id-strings args)))
    596 
    597 (define (print-entry-row id url type descr notes protected ptime ctime mtime tags)
    598   (write-line (conc vt100-entry-header
    599                     "#" id (if (zero? protected) "" "*") " - " url
    600                     vt100-reset))
    601     (unless (null? ctime) (write-line (conc "Created   " (rfc-3339 ctime))))
    602     (unless (null? ptime) (write-line (conc "Protected " (rfc-3339 ptime))))
    603     (unless (null? mtime) (write-line (conc "Modified  " (rfc-3339 mtime))))
    604     (unless (null? descr)
    605       (if (null? type)
    606           (write-line "Descripiton:")
    607           (write-line (conc "Description (" type "):")))
    608       (write-string descr))
    609     (unless (null? notes)
    610       (write-line (conc "Notes:"))
    611       (write-string notes))
    612     (if (null? tags)
    613         (write-line "No tags.")
    614         (write-line (string-append "Tags: " tags))))
    615 
    616 (define (print-listed-entry-row id url notes protected)
    617   (write-line (conc vt100-entry-header
    618                     "#" id (if (zero? protected) "" "*") " - " url
    619                     vt100-reset))
    620   (write-string notes))
    621 
    622 (define (count-selection* text id)
    623   (write-line (string-append (if id (conc "#" id ": ") "")
    624                              "\"" text "\""))
    625   (write-line (conc " -> " (query fetch-value
    626                                   ((if id sql sql/transient)
    627                                     db
    628                                     (string-append
    629                                       "SELECT COUNT(id) FROM entry "
    630                                       text ";"))))))
    631 
    632 (defcmd (count-selection . args)
    633   "\"WHERE ...\"|selector-id ..." "Count results of a custom queries"
    634   (if (null? args)
    635       (query (for-each-row* count-selection*)
    636              (sql db "SELECT text,id FROM selector;"))
    637       (let loop ((todo args))
    638         (unless (null? todo)
    639           (call-with-selector (car todo) count-selection*)
    640           (loop (cdr todo))))))
    641 
    642 (defcmd (list-selection arg)
    643   "\"WHERE ...\"|selector-id" "Display a custom query as an entry list"
    644   (call-with-selector arg
    645     (lambda (selector id)
    646       (query (for-each-row* print-listed-entry-row)
    647              ((if id sql sql/transient) db
    648                (string-append "SELECT id,url,notes,protected FROM entry "
    649                               selector ";"))))))
    650 
    651 (defcmd (list-tagged tag-name #!optional (count config-list-tagged-count))
    652   "tag-name [limit]" "Display entries with the given tag"
    653   (query (for-each-row* print-listed-entry-row)
    654          (sql db (cond ((positive? count)
    655                          "SELECT * FROM
    656                             (SELECT id,url,notes,protected FROM entry
    657                               WHERE id IN (SELECT url_id FROM tagrel
    658                                             WHERE tag_id IN (SELECT id FROM tag
    659                                                               WHERE name=?))
    660                             ORDER BY id DESC LIMIT ?)
    661                            ORDER BY id ASC;")
    662                        ((negative? count)
    663                          "SELECT id,url,notes,protected FROM entry
    664                             WHERE id IN (SELECT url_id FROM tagrel
    665                                           WHERE tag_id IN (SELECT id FROM tag
    666                                                             WHERE name=?))
    667                           ORDER BY id ASC LIMIT ?;")
    668                        (else ; (zero? count)
    669                          "SELECT id,url,notes,protected FROM entry
    670                             WHERE id IN (SELECT url_id FROM tagrel
    671                                           WHERE tag_id IN (SELECT id FROM tag
    672                                                             WHERE name=?))
    673                               OR id=?
    674                           ORDER BY id ASC;")))
    675          tag-name
    676          (abs count)))
    677 
    678 (defcmd (list-untagged)
    679   "" "Display entries without any tag"
    680   (query (for-each-row* print-listed-entry-row)
    681          (sql db "SELECT id,url,notes,protected FROM entry
    682                    WHERE id NOT IN (SELECT url_id FROM tagrel);")))
    683 
    684 (define (print-entry* entry-id)
    685   (query (for-each-row* print-entry-row)
    686          (sql db "SELECT entry.id,url,type,description,notes,
    687                          protected,ptime,ctime,mtime,group_concat(tag.name,' ')
    688                   FROM entry
    689                   LEFT OUTER JOIN tagrel ON entry.id=tagrel.url_id
    690                   LEFT OUTER JOIN tag ON tag.id=tagrel.tag_id
    691                   WHERE entry.id=? GROUP BY entry.id;")
    692          entry-id))
    693 
    694 (defcmd (print-entry . args)
    695   "[entry-id]" "Display an entry"
    696   (if (null? args)
    697       (print-entry* cur-entry)
    698       (let loop ((todo args))
    699         (unless (null? todo)
    700           (print-entry* (car todo))
    701           (loop (cdr todo))))))
    702 
    703 (defcmd (print-selection arg)
    704   "\"WHERE ...\"|selector-id" "Display entries from a custom query"
    705   (call-with-selector arg
    706     (lambda (selector id)
    707       (query
    708         (for-each-row* print-entry-row)
    709         ((if id sql sql/transient) db
    710           (string-append
    711             "SELECT entry.id,url,type,description,notes,
    712                     protected,ptime,ctime,mtime,group_concat(tag.name,' ')
    713              FROM entry
    714              LEFT OUTER JOIN tagrel ON entry.id=tagrel.url_id
    715              LEFT OUTER JOIN tag ON tag.id=tagrel.tag_id "
    716             selector
    717             " GROUP BY entry.id;"))))))
    718 
    719 (defcmd (random-tagged tag-name)
    720   "tag" "Select a random entry with the given tag"
    721   (let ((entry-id (query fetch-value
    722                          (sql db "SELECT url_id FROM tagrel WHERE tag_id IN
    723                                     (SELECT id FROM tag WHERE name=?)
    724                                   ORDER BY RANDOM() LIMIT 1;")
    725                          tag-name)))
    726     (if entry-id
    727         (begin
    728           (set! cur-entry entry-id)
    729           (print-entry))
    730         (write-line "No such entry found"))))
    731 
    732 (defcmd (random-untagged)
    733   "" "Select a random entry without tag"
    734   (let ((entry-id (query fetch-value
    735                          (sql db "SELECT id FROM entry WHERE id NOT IN
    736                                     (SELECT url_id FROM tagrel)
    737                                   ORDER BY RANDOM() LIMIT 1;"))))
    738     (if entry-id
    739         (begin
    740           (set! cur-entry entry-id)
    741           (print-entry))
    742         (write-line "No such entry found"))))
    743 
    744 (define (guess-type str)
    745   (cond ((null? str) '())
    746         ((starts-with? "<" str) "html")
    747         ((or (starts-with? " - " str)
    748              (starts-with? " + " str)) "markdown-li")
    749         (else "text")))
    750 
    751 (define (set-descr* mtime entry-id type text)
    752   (trace `(set-descr ,mtime ,entry-id ,type ,text))
    753   (unless-protected entry-id
    754     (exec (sql db "UPDATE entry SET type=?,description=?,mtime=? WHERE id=?;")
    755           type text mtime entry-id)
    756     (update-feed-cache mtime)))
    757 
    758 (defcmd (set-descr first . args)
    759   "[[[mtime] entry-id] type] description" "Sets an entry description"
    760   (case (length args)
    761     ((0) (set-descr* (current-seconds) cur-entry (guess-type first) first))
    762     ((1) (set-descr* (current-seconds) cur-entry first (car args)))
    763     ((2) (set-descr* (current-seconds) first (car args) (cadr args)))
    764     ((3) (set-descr* first (car args) (cadr args) (caddr args)))
    765     (else (assert #f "Too many arguments to set-descr " (cons first args)))))
    766 
    767 (defcmd (set-entry arg)
    768   "entry-id|url" "Set current entry"
    769   (cond ((integer? arg)
    770           (set! cur-entry arg)
    771           (when config-verbose (print-entry)))
    772         ((string? arg)
    773           (let ((id (query fetch-value
    774                            (sql db "SELECT id FROM entry WHERE url=?;")
    775                            arg)))
    776             (if id
    777                 (begin
    778                   (set! cur-entry id)
    779                   (when config-verbose (print-entry)))
    780                 (write-line (conc "No entry found for \"" arg "\"")))))
    781         (else (assert #f "Unsupported argument type for " arg))))
    782 
    783 (define (touch* mtime entry-id)
    784   (trace `(touch ,mtime ,entry-id))
    785   (unless-protected entry-id
    786     (exec (sql db "UPDATE entry SET mtime=? WHERE id=?;") mtime entry-id)
    787     (update-feed-cache mtime)))
    788 
    789 (define (touch . args)
    790   (cond ((null? args)
    791           (touch* (current-seconds) cur-entry))
    792         ((not (integer? (car args)))
    793           (assert #f "Bad type for " (car args)))
    794         ((null? (cdr args))
    795           (touch* (current-seconds) (car args)))
    796         ((not (integer? (cadr args)))
    797           (assert #f "Bad type for " (car args)))
    798         (else
    799           (touch* (car args) (cadr args)))))
    800 
    801 (define (without-mtime* entry-id proc)
    802   (if (or (procedure? proc) (list? proc))
    803       (let ((prev-entry cur-entry)
    804             (prev-mtime (query fetch-value
    805                                (sql db "SELECT mtime FROM entry WHERE id=?;")
    806                                entry-id)))
    807         (set! cur-entry entry-id)
    808         (if (procedure? proc) (proc) (eval proc))
    809         (touch* prev-mtime entry-id)
    810         (set! cur-entry prev-entry))
    811       (write-line (conc "Invalid procedure " proc))))
    812 
    813 (defcmd (without-mtime! first . args)
    814   "[entry-id] '(...)" "Perform updates and restore entry mtime"
    815   (cond ((null? args)
    816           (without-mtime* cur-entry first))
    817         ((and (null? (cdr args)) (integer? first))
    818           (without-mtime* first (car args)))
    819         (else (assert #f "Invalid arguments " (cons first args)))))
    820 
    821 ;; Entry Tagging
    822 
    823 (define (print-tags* entry-id)
    824   (write-line (apply conc (append (list "Tags for " entry-id ":")
    825     (query (map-rows (lambda (x) (string-append " " (car x))))
    826            (sql db "SELECT tag.name FROM tagrel
    827                     OUTER LEFT JOIN tag ON tagrel.tag_id=tag.id
    828                     WHERE url_id=? ORDER BY tag.name;")
    829            entry-id)))))
    830 
    831 (defcmd (print-tags . args)
    832   "[entry-id ...]" "Print tags associated with an entry"
    833   (if (null? args)
    834       (print-tags* cur-entry)
    835       (let loop ((todo args))
    836         (unless (null? todo)
    837           (print-tags* (car todo))
    838           (loop (cdr todo))))))
    839 
    840 
    841 (define (resolve-tag-id tag-name)
    842   (let ((result (query fetch-value
    843                        (sql db "SELECT id from tag WHERE name=?;")
    844                        tag-name)))
    845     (unless result
    846       (write-line (conc "Unknown tag " tag-name)))
    847     result))
    848 
    849 (define (exec-on-tags stmt mtime entry-id tag-list)
    850   (with-transaction db
    851     (lambda ()
    852       (unless-protected entry-id
    853         (let ((tag-id-list (map resolve-tag-id tag-list)))
    854           (when (every identity tag-id-list)
    855             (let loop ((todo tag-id-list))
    856               (if (null? todo)
    857                   (exec (sql db "UPDATE entry SET mtime=? WHERE id=?;")
    858                         mtime entry-id)
    859                   (begin
    860                     (exec stmt entry-id (car todo))
    861                     (loop (cdr todo))))))))))
    862   (print-tags entry-id)
    863   (update-feed-cache mtime))
    864 
    865 (define (retag* mtime entry-id tag-list)
    866   (trace `(retag ,mtime ,entry-id . ,tag-list))
    867   (unless-protected entry-id
    868     (exec (sql db "DELETE FROM tagrel WHERE url_id=?;") entry-id)
    869     (exec-on-tags (sql db "INSERT OR IGNORE INTO tagrel VALUES (?,?);")
    870                   mtime entry-id tag-list)))
    871 
    872 (defcmd (retag . args)
    873   "[[timestamp] entry-id] tag-name [tag-name ...]"
    874   "Overwrite tag list for an entry"
    875   (apply retag* (time-id-strings args)))
    876 
    877 (define (tag* mtime entry-id tag-list)
    878   (unless (null? tag-list)
    879     (trace `(tag ,mtime ,entry-id . ,tag-list))
    880     (exec-on-tags (sql db "INSERT OR IGNORE INTO tagrel VALUES (?,?);")
    881                   mtime entry-id tag-list)))
    882 
    883 (defcmd (tag . args)
    884   "[[timestamp] entry-id] tag-name [tag-name ...]"
    885   "Associate tags to an entry"
    886   (apply tag* (time-id-strings args)))
    887 
    888 (define (untag* mtime entry-id tag-list)
    889   (unless (null? tag-list)
    890     (trace `(untag ,mtime ,entry-id . ,tag-list))
    891     (exec-on-tags (sql db "DELETE FROM tagrel WHERE url_id=? AND tag_id=?;")
    892                   mtime entry-id tag-list)))
    893 
    894 (defcmd (untag . args)
    895   "[[timestamp] entry-id] tag-name [tag-name ...]"
    896   "Disssociates tags from an entry"
    897   (apply untag* (time-id-strings args)))
    898 
    899 ;;;;;;;;;;;;;;;;;;;;
    900 ;; Editor Spawning
    901 
    902 (define (edit-descr* entry-id)
    903   (let ((file-name (create-temporary-file
    904                      (string-append "."
    905                        (get-config/default "description-ext" "txt"))))
    906         (fields
    907            (query fetch-row
    908                   (sql db "SELECT description,notes,url FROM entry WHERE id=?;")
    909                   entry-id)))
    910     (unless (null? fields)
    911       (call-with-output-file file-name
    912         (lambda (port)
    913           (if (or (null? (car fields)) (string=? (car fields) ""))
    914             (let* ((s-sec (substring-index "[" (cadr fields)))
    915                    (e-sec (if s-sec
    916                               (substring-index "]" (cadr fields) s-sec)
    917                               #f))
    918                    (sect  (if e-sec
    919                               (substring (cadr fields) (+ s-sec 1) e-sec) #f))
    920                    (comm  (if sect (comment-link sect (caddr fields)) #f)))
    921               (write-string (conc " + [](" (caddr fields) ")\n") #f port)
    922               (when sect
    923                 (write-string
    924                   (conc "(via "
    925                     (if comm (conc "[" sect "](" comm ")") sect)
    926                     " sur #gcufeed)\n")
    927                   #f port)))
    928             (write-string (car fields) #f port))
    929           (unless (null? (cadr fields))
    930             (write-string "-+-+-\n" #f port)
    931             (write-string (cadr fields) #f port)))))
    932     (when config-editor
    933       (process-wait
    934         (process-run (string-append config-editor " " (qs file-name)))))
    935     (let ((result (call-with-input-file file-name
    936                     (lambda (port)
    937                       (let* ((text (read-string #f port))
    938                              (end  (substring-index-ci "-+-+-\n" text)))
    939                         (if end
    940                             (substring text 0 end)
    941                             text))))))
    942       (delete-file file-name)
    943       (if (or (zero? (string-length result))
    944               (equal? (if (or (null? fields) (null? (car fields)))
    945                           "" (car fields))
    946                       result))
    947           #f
    948           result))))
    949 
    950 
    951 (defcmd (edit-descr . args)
    952   "[[mtime] entry-id]" "Describe using an external editor"
    953   (let ((new-value (case (length args)
    954                      ((0) (edit-descr* cur-entry))
    955                      ((1) (edit-descr* (car args)))
    956                      ((2) (edit-descr* (cadr args)))
    957                      (else
    958                        (assert #f "Too many arguments to edit-descr " args)))))
    959     (when new-value
    960       (case (length args)
    961         ((0) (set-descr* (current-seconds)
    962                          cur-entry
    963                          (guess-type new-value)
    964                          new-value))
    965         ((1) (set-descr* (current-seconds)
    966                          (car args)
    967                          (guess-type new-value)
    968                          new-value))
    969         ((2) (set-descr* (car args)
    970                          (cadr args)
    971                          (guess-type new-value)
    972                          new-value))
    973         (else (assert #f "Too many arguments to edit-descr " args))))))
    974 
    975 (define (auto-cols widths avail)
    976   (letrec ((len     (vector-length widths))
    977          (w-slice (lambda (start len acc)
    978                     (if (< len 1)
    979                         acc
    980                         (w-slice (+ start 1) (- len 1)
    981                                  (max acc (vector-ref widths start))))))
    982          (w-total (lambda (start stride acc)
    983                     (if (< (+ start stride) len)
    984                         (w-total (+ start stride)
    985                                  stride
    986                                  (cons (+ (car acc) 1 (w-slice start stride 0))
    987                                        acc))
    988                         (cons (+ (car acc) (w-slice start (- len start) 0))
    989                               acc))))
    990          (h-cols  (lambda (ncols) (quotient (+ len ncols -1) ncols)))
    991          (w-cols  (lambda (ncols) (w-total 0 (h-cols ncols) (list 0)))))
    992     (let loop ((ncols len) (best #f))
    993       (if (zero? ncols) best
    994         (let ((w (w-cols ncols)) (h (h-cols ncols)))
    995           (loop (- ncols 1)
    996                 (if (and (< (car w) avail)
    997                          (or (not best) (<= h (car best))))
    998                     (list h (list->vector (reverse (cdr w))))
    999                     best)))))))
   1000 
   1001 (define (select-tags** entry-id tags)
   1002   (let* ((ntags (vector-length tags))
   1003          (state (list->vector (map cadddr (vector->list tags))))
   1004          (cols (auto-cols (list->vector
   1005                             (map (lambda (x)
   1006                                    (+ (string-length (cadr x))
   1007                                       (string-length (caddr x))))
   1008                                  (vector->list tags))) (COLS)))
   1009          (stride (car cols))
   1010          (x-cols (cadr cols))
   1011          (show-tag (lambda (index sel)
   1012                      (unless (zero? (vector-ref state index))
   1013 ;                      (attron (COLOR_PAIR 1)))
   1014                        (attron A_REVERSE))
   1015                      (when (= index sel)
   1016 ;                      (attron A_REVERSE))
   1017                        (attron A_UNDERLINE))
   1018                      (mvprintw
   1019                        (remainder index stride)
   1020                        (vector-ref x-cols (quotient index stride))
   1021                        "~A~A"
   1022                        (cadr (vector-ref tags index))
   1023                        (caddr (vector-ref tags index)))
   1024                      (when (= index sel)
   1025 ;                      (attroff A_REVERSE))
   1026                        (attroff A_UNDERLINE))
   1027                      (unless (zero? (vector-ref state index))
   1028 ;                      (attroff (COLOR_PAIR 1)))))
   1029                        (attroff A_REVERSE))))
   1030          (update-tags (lambda (old new) (show-tag old new) (show-tag new new))))
   1031     (keypad (stdscr) #t)
   1032     (noecho)
   1033     (curs_set 0)
   1034 ;   (start_color)
   1035 ;   (init_pair 1 COLOR_BLUE COLOR_BLACK)
   1036     (let init ((index 0))
   1037       (when (< index ntags)
   1038         (show-tag index 0)
   1039         (init (+ index 1))))
   1040     (let loop ((sel 0))
   1041       (let ((c (char->integer (getch))))
   1042         (cond
   1043           ((= c KEY_UP)
   1044             (let ((next-sel (modulo (- sel 1) ntags)))
   1045               (update-tags sel next-sel)
   1046               (loop next-sel)))
   1047           ((= c KEY_DOWN)
   1048             (let ((next-sel (modulo (+ sel 1) ntags)))
   1049               (update-tags sel next-sel)
   1050               (loop next-sel)))
   1051           ((= c KEY_LEFT)
   1052             (let ((next-sel (if (>= sel stride)
   1053                                 (- sel stride)
   1054                                 (min (+ sel (- ntags (modulo ntags stride)))
   1055                                      (- ntags 1)))))
   1056               (update-tags sel next-sel)
   1057               (loop next-sel)))
   1058           ((= c KEY_RIGHT)
   1059             (let ((next-sel (cond ((< (+ sel stride) ntags)
   1060                                     (+ sel stride))
   1061                                   ((< sel (- ntags (modulo ntags stride)))
   1062                                     (- ntags 1))
   1063                                   (else (modulo sel stride)))))
   1064               (update-tags sel next-sel)
   1065               (loop next-sel)))
   1066           ((= c 32)
   1067             (vector-set! state sel (- 1 (vector-ref state sel)))
   1068             (show-tag sel sel)
   1069             (loop sel))
   1070           ((= c 10)
   1071             (let result ((index 0) (add '()) (del '()))
   1072               (cond
   1073                 ((>= index ntags)
   1074                   (list add del))
   1075                 ((= (cadddr (vector-ref tags index)) (vector-ref state index))
   1076                   (result (+ index 1) add del))
   1077                 ((zero? (vector-ref state index))
   1078                   (result (+ index 1) add
   1079                           (cons (cadr (vector-ref tags index)) del)))
   1080                 (else
   1081                   (result (+ index 1)
   1082                           (cons (cadr (vector-ref tags index)) add)
   1083                           del)))))
   1084           ((= c 27) '(()()))
   1085           ((or (<= 65 c 90) (<= 97 c 122))
   1086             (let search ((prev-sel sel)
   1087                          (prev-ch  (char->integer (string-ref
   1088                                            (cadr (vector-ref tags sel)) 0))))
   1089               (let* ((next-sel (modulo (+ prev-sel 1) ntags))
   1090                      (next-ch  (char->integer (string-ref
   1091                                        (cadr (vector-ref tags next-sel)) 0))))
   1092                 (cond
   1093                   ((= next-sel sel)
   1094                     (loop sel))
   1095                   ((or (= next-ch c) (< prev-ch c next-ch))
   1096                     (update-tags sel next-sel)
   1097                     (loop next-sel))
   1098                   (else (search next-sel next-ch))))))
   1099           (else (mvprintw (+ 1 stride) 0 "~S ~S" KEY_DOWN c) (loop sel)))))))
   1100 
   1101 (define (select-tags* entry-id)
   1102   (if (update-allowed? entry-id)
   1103     (let ((tags (list->vector (query
   1104                    (map-rows* (lambda (id name count active)
   1105                                 (list id name (conc " (" count ")")
   1106                                       active)))
   1107                    (sql db
   1108                     "SELECT id,name,COUNT(url_id),COALESCE(MAX(url_id==?),0)
   1109                      FROM tag LEFT OUTER JOIN tagrel ON tag_id=tag.id
   1110                      GROUP BY tag.name;")
   1111                    entry-id))))
   1112       (dynamic-wind initscr (lambda () (select-tags** entry-id tags)) endwin))
   1113     '(()())))
   1114 
   1115 (defcmd (select-tags . args)
   1116   "[[mtime] entry-id]" "Interactively select tags using dialog(1)"
   1117   (let* ((entry-id (case (length args)
   1118                      ((0) cur-entry)
   1119                      ((1) (car args))
   1120                      ((2) (cadr args))
   1121                      (else
   1122                        (assert #f "Too many arguments to select-tags " args))))
   1123          (mtime    (if (= 2 (length args)) (car args) (current-seconds)))
   1124          (changes  (select-tags* entry-id))
   1125          (added    (car changes))
   1126          (removed  (cadr changes)))
   1127     (unless-protected entry-id
   1128       (untag* (- mtime 1) entry-id removed)
   1129       (tag* mtime entry-id added))))
   1130 
   1131 ;;;;;;;;;;;;;;;;;;;;;
   1132 ;; Gruik Management
   1133 
   1134 (define (pull-gruiks* mtime mark)
   1135   (let ((last-id (query fetch-value (sql db "SELECT MAX(id) FROM entry;"))))
   1136     (exec
   1137       (sql db "INSERT OR IGNORE
   1138                  INTO entry(url,type,description,notes,ctime,mtime)
   1139                SELECT url,
   1140                       CASE WHEN description IS NULL THEN NULL
   1141                            WHEN substr(description,1,1)='<' THEN 'html'
   1142                            WHEN substr(description,1,3)=' - '
   1143                             OR substr(description,1,3)=' + ' THEN 'markdown-li'
   1144                            ELSE 'text' END,
   1145                       trim(description,char(10))||char(10),
   1146                       trim(notes,char(10))||char(10),
   1147                       stime,?
   1148                FROM gruik
   1149                WHERE mark=? AND url NOT IN (SELECT url FROM entry);")
   1150       mtime
   1151       mark)
   1152     (exec
   1153       (sql db "INSERT OR IGNORE INTO tagrel(url_id,tag_id)
   1154                SELECT entry.id,tag_id
   1155                FROM gruik_tags LEFT OUTER JOIN gruik ON gruik_id = gruik.id
   1156                                LEFT OUTER JOIN entry ON gruik.url = entry.url
   1157                WHERE gruik.mark=?;")
   1158       mark)
   1159     (exec
   1160       (sql db "UPDATE gruik SET mark=-10 WHERE mark=?;")
   1161       mark)
   1162     (print-selection (conc "WHERE entry.id > " last-id)))
   1163     (update-feed-cache mtime))
   1164 
   1165 (defcmd (pull-gruiks mark)
   1166   "mark" "import gruiks at the given mark level"
   1167   (let* ((wh (conc "WHERE url IN (SELECT url FROM gruik WHERE mark=" mark ")"))
   1168          (n  (query fetch-value
   1169                (sql/transient db (conc "SELECT COUNT(id) FROM entry " wh)))))
   1170     (if (zero? n)
   1171         (pull-gruiks* (current-seconds) mark)
   1172         (begin
   1173           (write-line (conc vt100-alert "Conflicting gruiks:" vt100-reset))
   1174           (query
   1175             (for-each-row* (lambda (id url notes)
   1176               (write-line (conc id " - " vt100-entry-header url vt100-reset))
   1177               (write-line notes)))
   1178             (sql db "SELECT id,url,notes FROM gruik
   1179                      WHERE mark=? AND url IN (SELECT url FROM entry);")
   1180             mark)
   1181           (write-line (conc vt100-alert "Conflicting entries:" vt100-reset))
   1182           (print-selection wh)))))
   1183 
   1184 (defcmd (catchup-gruik)
   1185   "" "skip all past unfetched gruiks"
   1186   (let ((src-path (get-config "gruik-source")))
   1187     (write-line (conc "Before: " (get-config "gruik-seen")))
   1188     (when src-path
   1189       (set-config "gruik-seen" (file-size src-path)))
   1190     (write-line (conc "After " (get-config "gruik-seen")))))
   1191 
   1192 ;;;;;;;;;;;;;;;;;;;;
   1193 ;; Feed Generation
   1194 
   1195 (define (generate-feed forced feed-id filename url selector title mtime)
   1196   (let* ((rows (feed-rows selector)))
   1197          (generate?
   1198            (cond ((null? rows)
   1199                    (when config-verbose
   1200                      (write-line (conc "Feed " feed-id " is empty")))
   1201                    #f)
   1202                  ((any (cut = feed-id <>) dirty-feeds)
   1203                    (when config-verbose
   1204                      (write-line (conc "Generating feed " feed-id)))
   1205                    #t)
   1206                  (forced
   1207                    (when config-verbose
   1208                      (write-line (conc "Generating feed " feed-id
   1209                                        " unconditionally")))
   1210                    #t)
   1211                  (else
   1212                    (when config-verbose
   1213                      (write-line (conc "Feed " feed-id
   1214                                        " is already up to date")))
   1215                    #t))))
   1216     (when generate?
   1217       (with-output-to-file filename
   1218         (lambda () (write-feed (if (null? mtime) (list-ref (car rows) 7) mtime)
   1219                                title url rows)))
   1220       (set! dirty-feeds (delete! feed-id dirty-feeds =))
   1221       (set! feed-cache
   1222         (alist-update! feed-id
   1223                        (map (lambda (row) (list (car row) (list-ref row 7)))
   1224                             rows)
   1225                        feed-cache =))))
   1226 
   1227 (define (generate-feeds forced id-list)
   1228   (for-each
   1229     (lambda (row) (apply generate-feed forced row))
   1230     (if (null? id-list)
   1231         (query fetch-rows
   1232                (sql db "SELECT id,filename,url,selector,title,mtime
   1233                         FROM feed WHERE active=1;"))
   1234         (map (lambda (id)
   1235                (query fetch
   1236                       (sql db "SELECT id,filename,url,selector,title,mtime
   1237                                FROM feed WHERE id=?;")
   1238                       id))
   1239              id-list))))
   1240 
   1241 (defcmd (force-generate . args)
   1242   "[feed-id ...]"
   1243   "Generate unconditionally the given feeds, or all active feeds"
   1244   (generate-feeds #t args))
   1245 
   1246 (defcmd (generate . args)
   1247   "[feed-id ...]" "Generate if needed the given feeds, or all active feeds"
   1248   (generate-feeds #f args))
   1249 
   1250 ;;;;;;;;;;;;;
   1251 ;; Auto Add
   1252 
   1253 (define (auto-add lines)
   1254   (unless arg-replay
   1255     (trace `(auto-add ,lines))
   1256     (let loop ((index 0) (urls '()))
   1257       (let* ((start0 (substring-index-ci "https://" lines index))
   1258              (start  (if start0 start0
   1259                          (substring-index-ci "http://" lines index)))
   1260              (end    (if start
   1261                          (apply min
   1262                            (filter identity
   1263                              (list
   1264                                (string-length lines)
   1265                                (substring-index " " lines start)
   1266                                (substring-index "\n" lines start))))
   1267                          #f)))
   1268         (cond (start
   1269                 (loop end (cons (substring lines start end) urls)))
   1270               ((null? urls)
   1271                 (write-line (conc "Warning: no URL found")))
   1272               (else
   1273                 (for-each (lambda (url) (add-entry url lines)) urls)))))))
   1274 
   1275 ;;;;;;;;;;;;;;
   1276 ;; Main loop
   1277 
   1278 (defcmd (replay filename)
   1279   "filename" "Replay the given file"
   1280   (let ((old-arg-replay arg-replay))
   1281     (set! arg-replay #t)
   1282     (load filename)
   1283     (set! arg-replay old-arg-replay)))
   1284 
   1285 (define write-each-row
   1286   (for-each-row
   1287     (lambda (row) (if (= 1 (length row))
   1288                       (write-line (->string (car row)))
   1289                       (begin (write row) (newline))))))
   1290 
   1291 (define (write-query text . args)
   1292    (apply query write-each-row (sql/transient db text) args))
   1293 
   1294 (defcmd (help)
   1295   "" "Display this help"
   1296   (for-each
   1297     (lambda (row)
   1298       (write-line (conc
   1299         "("
   1300         (car row)
   1301         (if (zero? (string-length (cadr row))) "" " ")
   1302         (cadr row)
   1303         ")"))
   1304       (write-line (conc "    " (caddr row))))
   1305     cmd-list))
   1306 
   1307 (set! cmd-list (sort! cmd-list (lambda (r1 r2) (string<? (car r1) (car r2)))))
   1308 
   1309 (define completion-ptr cmd-list)
   1310 (define new-completion #t)
   1311 (define (completer prefix state)
   1312   (when (zero? state)
   1313     (set! completion-ptr cmd-list)
   1314     (set! new-completion #t))
   1315   (let ((buf (line-buffer)))
   1316     (cond ((and (positive? (string-length buf))
   1317                 (not (eqv? (string-ref buf 0) #\()))
   1318             #f)
   1319           ((substring-index " " buf)
   1320             (let ((other-state (if new-completion 0 state)))
   1321               (set! new-completion #f)
   1322               (scheme-completer prefix other-state)))
   1323           (else
   1324             (let loop ()
   1325               (cond ((null? completion-ptr)
   1326                       #f)
   1327                     ((starts-with? prefix (caar completion-ptr))
   1328                       (let ((result (caar completion-ptr)))
   1329                         (set! completion-ptr (cdr completion-ptr))
   1330                         result))
   1331                     (else
   1332                         (set! completion-ptr (cdr completion-ptr))
   1333                         (loop))))))))
   1334 
   1335 (define state 'general)
   1336 (define (prompt)
   1337   (string-append
   1338     (if (null? protection-overrides)
   1339         ""
   1340         (string-append "!"
   1341           (string-intersperse (map ->string protection-overrides) ",")))
   1342     (cond ((eqv? state 'general) "> ")
   1343           ((eqv? state 'in-command) "… ")
   1344           (else "? "))))
   1345 
   1346 (define (interactive-main)
   1347   (basic-quote-characters-set! "\"|")
   1348   (completer-word-break-characters-set! "\"\'`;|()[] ")
   1349   (completer-set! completer)
   1350   (variable-bind! "blink-matching-paren" "on")
   1351   (paren-blink-timeout-set! 200000)
   1352 
   1353   (let ((handler (signal-handler signal/int)))
   1354     (set-signal-handler! signal/int (lambda (s) (cleanup-after-signal!)
   1355                                                 (reset-after-signal!)
   1356                                                 (handler s))))
   1357   (on-exit reset-terminal!)
   1358   (current-input-port (make-readline-port prompt))
   1359 
   1360   (let main-loop ()
   1361     (let ((c (peek-char)))
   1362       (cond ((eof-object? c))
   1363             ((eqv? c #\()
   1364               (set! state 'in-command)
   1365               (handle-exceptions
   1366                 exn
   1367                 (begin
   1368                   (print-error-message exn)
   1369                   (print-call-chain))
   1370                 (eval (read)))
   1371               (set! state 'general)
   1372               (main-loop))
   1373             (else
   1374               (let data-loop ((acc (list (read-line))))
   1375                 (if (char-ready?)
   1376                     (data-loop (cons (read-line) acc))
   1377                     (let ((lines (reverse-string-append
   1378                                    (map terminate-line acc))))
   1379                       (when (positive? (string-length lines))
   1380                         (auto-add lines))
   1381                       (main-loop)))))))))
   1382 
   1383 (cond ((not arg-replay)
   1384         (interactive-main))
   1385       ((eqv? (string-ref arg-replay 0) #\()
   1386         (eval (read (open-input-string arg-replay))))
   1387       (else
   1388         (load arg-replay)))