iens

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

get-gruiks.scm (17009B)


      1 ; Copyright (c) 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
     16   (chicken condition)
     17   (chicken file)
     18   (chicken file posix)
     19   (chicken io)
     20   (chicken port)
     21   (chicken process signal)
     22   (chicken process-context)
     23   (chicken string)
     24   (chicken time)
     25   (chicken time posix)
     26   atom
     27   comparse
     28   openssl ; must be above http-client
     29   http-client
     30   intarweb
     31   nanosleep
     32   rss
     33   sql-de-lite
     34   srfi-19-time
     35   uri-common)
     36 
     37 (define verbosity
     38   (let ((var (get-environment-variable "VERBOSE")))
     39     (if var
     40         (let ((n (string->number var))) (if n n 1))
     41         0)))
     42 (define (write-log n . args)
     43   (when (>= verbosity n)
     44     (let ((ts (time->string (seconds->local-time) "%H:%M:%S ")))
     45       (write-line (apply conc (cons ts args))))))
     46 
     47 ;;;;;;;;;;;;;;;;;;;;;;;;;;
     48 ;; Scheduling primitives
     49 
     50 (define min-sleep (seconds->time 0.125))
     51 (define (sleep-until deadline)
     52   (let* ((dt  (time-max min-sleep (time-difference deadline (monotonic-time))))
     53          (sec (time->seconds dt)))
     54     (write-log 2 " Sleeping for " (exact->inexact sec) "s")
     55     (secosleep sec)))
     56 (define (run-until deadline count thunk)
     57   (if deadline
     58     (let* ((now (monotonic-time))
     59            (my-period (/ (time->seconds (time-difference deadline now)) count))
     60            (my-deadline (add-duration now (seconds->time my-period))))
     61       (thunk)
     62       (sleep-until my-deadline))
     63     (thunk)))
     64 
     65 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;
     66 ;; Command-Line Processing
     67 
     68 (define arg-list (command-line-arguments))
     69 
     70 (define db-name
     71   (if (>= (length arg-list) 1)
     72       (car arg-list)
     73       "iens.sqlite"))
     74 
     75 (define total-period
     76   (if (>= (length arg-list) 2)
     77       (string->number (list-ref arg-list 1))
     78       #f))
     79 
     80 ;;;;;;;;;;;;;;;;;;;;;;;
     81 ;; Persistent Storage
     82 
     83 (define db
     84   (open-database db-name))
     85 (exec (sql/transient db "PRAGMA foreign_keys = ON;
     86                          PRAGMA journal_mode = WAL;
     87                          PRAGMA synchronous = NORMAL;
     88                          PRAGMA busy_timeout = 5000;"))
     89 (set-busy-handler! db (busy-timeout 10000))
     90 
     91 (include "common.scm")
     92 
     93 (assert (= 8 (db-version)))
     94 
     95 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
     96 ;; Gruik build from sources
     97 
     98 (define gruik-inserted 0)
     99 (define gruik-processed 0)
    100 
    101 (define (reset-gruik-counters)
    102   (set! gruik-inserted 0)
    103   (set! gruik-processed 0))
    104 
    105 (define (zero-gruik-counters)
    106   (unless (and (zero? gruik-inserted) (zero? gruik-processed))
    107     (write-log 0 "Unexpected processing of "
    108                  gruik-inserted "/" gruik-processed " gruiks")
    109     (reset-gruik-counters)))
    110 
    111 (define (process-gruik source url title comm)
    112   (set! gruik-processed (add1 gruik-processed))
    113   (when (= 0 (exec (sql db "UPDATE gruik
    114                             SET lastseen=CAST(strftime('%s', 'now') as INT),
    115                                 comment_url=?
    116                             WHERE section=? AND url=? AND title=?
    117                               AND (comment_url IS NULL OR comment_url=?1);")
    118                    (if comm comm '()) source url title)
    119              (query fetch-value
    120                     (sql db "SELECT count(id) FROM entry
    121                              WHERE source=? AND url=? AND title=?;")
    122                     source url title))
    123     (when (= 0 (exec (sql db "UPDATE gruik
    124                               SET title=?,
    125                                   comment_url=?,
    126                                   notes=trim(notes||char(10)
    127                                              ||'Previously “'||title||'”',
    128                                              char(10)),
    129                                   lastseen=CAST(strftime('%s', 'now') AS INT)
    130                               WHERE url=? AND section=?
    131                                 AND (comment_url IS NULL OR comment_url=?2);")
    132                      title (if comm comm '()) url source))
    133       (set! gruik-inserted (add1 gruik-inserted))
    134       (exec
    135         (sql db "INSERT INTO gruik(position, notes, ptime,
    136                                    section, url, title, comment_url,
    137                                    mark, ctime, mtime, lastseen)
    138                  VALUES (-1, '', datetime(?1,'unixepoch')||'*',
    139                          ?2, ?3, ?4, ?5,
    140                          ?6, ?1, ?1, CAST(strftime('%s', 'now') as INT));")
    141         (query fetch-value
    142                (sql db "SELECT MAX(CAST(strftime('%s', 'now') as INT),
    143                                    (SELECT max(mtime) FROM gruik) + 1);"))
    144         source url title (if comm comm '())
    145         (if (= 0 (query fetch-value
    146                         (sql db "SELECT count(id) FROM gruik WHERE url=?;")
    147                         url)
    148                  (query fetch-value
    149                         (sql db "SELECT count(id) FROM entry WHERE url=?;")
    150                         url))
    151             0 -1)))))
    152 
    153 (define (process-atom deadline source items)
    154   (unless (null? items)
    155     (run-until deadline (length items)
    156       (lambda ()
    157         (process-gruik source
    158                        (link-uri (car (entry-links (car items))))
    159                        (title-text (entry-title (car items)))
    160                        #f)))
    161     (process-atom deadline source (cdr items))))
    162 
    163 (define (process-rss deadline source items)
    164   (unless (null? items)
    165     (let* ((item  (car items))
    166            (attr  (rss:item-attributes item))
    167            (link  (rss:item-link item))
    168            (title (rss:item-title item))
    169            (comm  (alist-ref 'comments attr)))
    170       (run-until deadline (length items)
    171         (lambda () (process-gruik source link (if title title link) comm)))
    172       (process-rss deadline source (cdr items)))))
    173 
    174 (define (absorb-304 req parse)
    175   (condition-case
    176     (with-input-from-request req #f parse)
    177     ((exn unexpected-server-response) (values #f #f #f))))
    178 
    179 (define (get-source parse url last-modified etag)
    180   (let* ((hlm (if (null? last-modified) '()
    181                   `((if-modified-since
    182                       #(,(seconds->local-time last-modified) ())))))
    183          (het (cond ((null? etag) '())
    184                     ((string=? etag "") '())
    185                     ((eqv? (string-ref etag 0) #\S)
    186                       `((if-none-match (strong . ,(substring etag 1)))))
    187                     ((eqv? (string-ref etag 0) #\W)
    188                       `((if-none-match (weak . ,(substring etag 1)))))
    189                     (else '())))
    190          (req (make-request
    191                 uri: (uri-reference url)
    192                 headers: (headers `(,@hlm ,@het)))))
    193     (let-values (((result _ resp) (absorb-304 req parse)))
    194       (when resp
    195         (let* ((hdr (response-headers resp))
    196                (lm  (header-value 'last-modified hdr))
    197                (et  (header-value 'etag hdr)))
    198           (when (or (not (null? last-modified)) (not (null? etag)) lm et)
    199             (exec (sql db "UPDATE source_rss SET last_modified=?, etag=?
    200                            WHERE url=?;")
    201                   (if lm (local-time->seconds lm) '())
    202                   (if et (string-append
    203                            (cond ((eq? (car et) 'weak) "W")
    204                                  ((eq? (car et) 'strong) "S")
    205                                  (else "*"))
    206                            (cdr et))
    207                       '())
    208                   url))))
    209       result)))
    210 
    211 (define (atom:read) (read-atom-feed (current-input-port)))
    212 (define (get-atom url last-modified etag)
    213   (let ((feed (get-source atom:read url last-modified etag)))
    214     (if feed
    215         (list 1
    216               (feed-entries feed)
    217               (title-text (feed-title feed)))
    218         #f)))
    219 
    220 (define (get-rss url last-modified etag)
    221   (let ((feed (get-source rss:read url last-modified etag)))
    222     (if feed
    223         (list 2
    224               (rss:feed-items feed)
    225               (rss:item-title (rss:feed-channel feed)))
    226         #f)))
    227 
    228 (define (get-auto url)
    229   (let* ((data (get-source read-string url '() '()))
    230          (da   (condition-case (with-input-from-string data atom:read)
    231                                ((atom) #f)))
    232          (dr   (condition-case (with-input-from-string data rss:read)
    233                                ((rss) #f))))
    234     (exec (sql db "UPDATE source_rss SET format=? WHERE url=?;")
    235       (cond (da 1) (dr 2) (else -1))
    236       url)
    237     (cond
    238       (da (list 1
    239                 (feed-entries da)
    240                 (title-text (feed-title da))))
    241       (dr (list 2
    242                 (rss:feed-items dr)
    243                 (rss:item-title (rss:feed-channel dr))))
    244       (else #f))))
    245 
    246 (define (process-source deadline name url format last-modified etag)
    247   (zero-gruik-counters)
    248   (write-log 1 "Processing source " name)
    249   (condition-case
    250     (let ((data (case format ((0) (get-auto url))
    251                              ((1) (get-atom url last-modified etag))
    252                              ((2) (get-rss  url last-modified etag))
    253                              (else #f))))
    254       (if data
    255         (let ((args (list
    256                       (if (and deadline (not (null? deadline))) deadline #f)
    257                       (if (string=? name url)
    258                           (begin
    259                             (exec (sql db "UPDATE source_rss SET name=?
    260                                            WHERE name=? AND url=?;")
    261                                   (caddr data) name url)
    262                             (caddr data))
    263                           name)
    264                       (cadr data))))
    265           (case (car data)
    266             ((1) (apply process-atom args))
    267             ((2) (apply process-rss  args))
    268             (else (assert #f "Bad process index")))
    269           (write-log 1 "Inserted " gruik-inserted "/" gruik-processed " gruiks")
    270           (reset-gruik-counters))
    271         (exec (sql db "UPDATE gruik
    272                        SET lastseen=CAST(strftime('%s', 'now') as INT)
    273                        WHERE section=?;")
    274               name)))
    275     (exn (client-error)
    276       (write-line (conc "Error while checking " name))
    277       (write-line (conc "  Headers: " (response-headers ((condition-property-accessor 'client-error 'response) exn))))
    278       (print-error-message exn))
    279     (exn (user-interrupt) (signal exn))
    280     (exn () (write-line (conc "Error while checking " name))
    281             (print-error-message exn))))
    282 
    283 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    284 ;; Gruik build from IRC log
    285 
    286 (define irc-digit      (in #\0 #\1 #\2 #\3 #\4 #\5 #\6 #\7 #\8 #\9))
    287 (define irc-hex        (in #\0 #\1 #\2 #\3 #\4 #\5 #\6 #\7
    288                            #\8 #\9 #\a #\b #\c #\d #\e #\f))
    289 (define (irc-digits n) (repeated irc-digit n))
    290 (define irc-date
    291   (as-string
    292     (sequence (irc-digits 4) (is #\.)
    293               (irc-digits 2) (is #\.)
    294               (irc-digits 2) (is #\ )
    295               (irc-digits 2) (is #\:)
    296               (irc-digits 2) (is #\:)
    297               (irc-digits 2))))
    298 (define irc-nick
    299   (as-string
    300     (enclosed-by (is #\<)
    301                  (repeated item until: (is #\>))
    302                  (is #\>))))
    303 (define irc-source
    304   (as-string
    305     (enclosed-by (char-seq " [")
    306                  (repeated item until: (is #\]))
    307                  (char-seq "] "))))
    308 (define irc-url
    309   (as-string
    310     (enclosed-by (char-seq " ")
    311                  (sequence (char-seq "http")
    312                            (repeated item until: (is #\space)))
    313                  (char-seq " "))))
    314 (define irc-hash
    315   (as-string
    316     (enclosed-by (char-seq "#")
    317                  (repeated irc-hex 8)
    318                  end-of-input)))
    319 (define irc-suffix (sequence irc-url irc-hash))
    320 (define irc-line
    321   (sequence irc-date
    322             irc-nick
    323             irc-source
    324             (as-string (repeated item until: irc-suffix))
    325             irc-url
    326             irc-hash))
    327 
    328 (define (read-line-pos fd)
    329   (let loop ((acc ""))
    330     (let ((c (file-read fd 1)))
    331       (if (and (= 1 (cadr c))
    332                (not (string=? (car c) "\n")))
    333           (loop (string-append acc (car c)))
    334           (list acc (file-position fd))))))
    335 
    336 (define (line->notes line max-width)
    337   (let loop ((rest (string-split line " " #t))
    338              (lines  '())
    339              (words  ""))
    340     (cond
    341       ((null? rest)
    342         (reverse-string-append (cons words lines)))
    343       ((<= (+ (string-length words) 1 (string-length (car rest))) max-width)
    344         (loop (cdr rest)
    345               lines
    346               (string-append words
    347                              (if (string=? words "") "" " ")
    348                              (car rest))))
    349       (else
    350         (loop (cdr rest)
    351               (cons (string-append words "\n") lines)
    352               (car rest))))))
    353 
    354 (define (insert-line line offset)
    355   (set! gruik-processed (add1 gruik-processed))
    356   (secosleep (time->seconds (min-sleep)))
    357   (and-let* ((parsed  (parse irc-line line))
    358              (now     (current-seconds))
    359              (section (list-ref parsed 2))
    360              (title   (list-ref parsed 3))
    361              (url     (list-ref parsed 4))
    362              (_ (= 0 (exec (sql db
    363                              "UPDATE gruik
    364                               SET mtime=CAST(strftime('%s', 'now') as INT),
    365                                   notes=(CASE WHEN title=?3
    366                                          THEN notes
    367                                          ELSE trim(notes||char(10)
    368                                                    ||'Also “'||?3||'”',
    369                                                    char(10))
    370                                          END)
    371                               WHERE section=?1 AND url=?2;")
    372                            section url title)
    373                      (query fetch-value
    374                             (sql db "SELECT COUNT(id) FROM entry
    375                                      WHERE source=? AND url=? AND title=?;")
    376                             section url title))))
    377     (set! gruik-inserted (add1 gruik-inserted))
    378     (exec
    379       (sql db
    380         "INSERT INTO gruik(position, notes, ptime,
    381                            section, title, url, mark, ctime, mtime)
    382          VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);")
    383       offset
    384       (line->notes line 79)
    385       (car parsed)
    386       section
    387       title
    388       url
    389       (+ (query fetch-value
    390                 (sql db "SELECT -2*COUNT(*) FROM gruik WHERE url=?;")
    391                 url)
    392          (query fetch-value
    393                 (sql db "SELECT -2*COUNT(*) FROM entry WHERE url=?;")
    394                 url))
    395       now
    396       now)))
    397 
    398 (define (import-gruiks)
    399   (let ((src-path (get-config "gruik-source")))
    400     (when src-path
    401       (let* ((fd (file-open src-path open/rdonly))
    402              (so (get-config/default "gruik-seen" 0))
    403              (_  (set-file-position! fd so seek/set)))
    404         (zero-gruik-counters)
    405         (write-log 1 "Importing gruiks from " so)
    406         (let loop ((offset so))
    407           (let ((rp (read-line-pos fd)))
    408             (if (= (cadr rp) offset)
    409               (begin
    410                 (write-log 1 "Imported " gruik-inserted "/" gruik-processed
    411                              " gruiks until " offset)
    412                 (reset-gruik-counters)
    413                 (exec
    414                   (sql db "INSERT OR REPLACE INTO config VALUES (?,?);")
    415                   "gruik-seen"
    416                   offset))
    417               (begin
    418                 (apply insert-line rp)
    419                 (loop (cadr rp))))))))))
    420 
    421 ;;;;;;;;;;;;;;;
    422 ;; Actual Run
    423 
    424 (define (source-deadline)
    425   (add-duration
    426     (monotonic-time)
    427     (seconds->time
    428       (/ total-period
    429          (query fetch-value (sql db "SELECT count(*) FROM source_rss;"))))))
    430 
    431 (define usr1-queue (make-signal-handler signal/usr1))
    432 
    433 (import-gruiks)
    434 
    435 (if total-period
    436     (let loop ((index (query fetch-value
    437                              (sql/transient db
    438                                "SELECT min(id) FROM source_rss;"))))
    439       (let ((deadline (source-deadline))
    440             (arg (query fetch-row
    441                         (sql db "SELECT
    442                                    COALESCE((SELECT min(id) FROM source_rss
    443                                                             WHERE id > ?1),
    444                                             (SELECT min(id) FROM source_rss)),
    445                                    name,url,format,last_modified,etag
    446                                  FROM source_rss WHERE id = ?1;")
    447                         index)))
    448         (apply process-source (cons deadline (cdr arg)))
    449         (sleep-until deadline)
    450         (when (<= (car arg) index)
    451           (import-gruiks))
    452         (unless (and (<= (car arg) index) (usr1-queue))
    453           (loop (car arg)))))
    454     (query
    455       (for-each-row* process-source)
    456       (sql db "SELECT NULL,name,url,format,last_modified,etag
    457                FROM source_rss;")))