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