;; ;; brmsaptool.scm ;; ;; ISC License ;; ;; Copyright 2023 Brmlab, z.s. ;; Dominik Pantůček ;; ;; Permission to use, copy, modify, and/or distribute this software ;; for any purpose with or without fee is hereby granted, provided ;; that the above copyright notice and this permission notice appear ;; in all copies. ;; ;; THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL ;; WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED ;; WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE ;; AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR ;; CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS ;; OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, ;; NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN ;; CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ;; (import (chicken condition) (chicken file) (chicken pathname) (chicken file posix) (chicken io) (chicken string) (chicken format) (chicken sort) (chicken process-context)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Static default configuration (define *members-directory* (make-parameter "members")) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Testing (define-syntax with-handler (syntax-rules () ((_ handler body ...) (call-with-current-continuation (lambda (k) (with-exception-handler (lambda (x) (k (handler x))) (lambda () body ...))))))) (define-syntax unit-test (syntax-rules () ((_ name condition) (if (with-handler (lambda (x) #f) condition) (display ".") (error 'unit-test name))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Dictionary ;; Returns an empty dictionary represented as empty list. (define (make-dict) '()) ;; Checks whether given dictionary d contains the key k. (define (dict-has-key? d k) (if (assq k d) #t #f)) ;; Retrieves the value for key k from dictionary d. If third argument ;; is provided it is used as default value in case the key does not ;; exist. If only two arguments are given and the key does not exist, ;; raises an error. (define (dict-ref d k . r) (let ((p (assq k d))) (if p (cdr p) (if (null? r) (error 'dict-ref "Key does not exist" k) (car r))))) ;; Returns a new dictionary based on d with key k removed. If it ;; doesn't contain the key, an error is raised. (define (dict-remove d k) (let loop ((s d) (r '()) (e #t)) (if (null? s) (if e (error 'dict-remove "Key does not exist" k) r) (if (eq? (caar s) k) (loop (cdr s) r #f) (loop (cdr s) (cons (car s) r) e))))) ;; Adds a new value v under the key k to the dictionary d possibly ;; overwriting any value which has been stored under the key ;; before. Returns the updated dictionary. (define (dict-set d k v) (let ((dr (let loop ((s d) (r '())) (if (null? s) r (if (eq? (caar s) k) (loop (cdr s) r) (loop (cdr s) (cons (car s) r))))))) (cons (cons k v) dr))) ;; Returns the list of keys stored in given dictionary. (define (dict-keys d) (map car d)) (define (dict-tests!) (display "[test] dict ") (unit-test 'make-dict (null? (make-dict))) (unit-test 'dict-ref-nonexistent (with-handler (lambda (x) #t) (dict-ref (make-dict) 'nonexistent) #f)) (unit-test 'dict-ref-default (dict-ref (make-dict) 'nonexistent #t)) (unit-test 'dict-set-nonexistent (equal? (dict-set (make-dict) 'nonexistent 1) '((nonexistent . 1)))) (unit-test 'dict-set-existent (equal? (dict-set (dict-set (make-dict) 'existent 1) 'existent 2) '((existent . 2)))) (unit-test 'dict-remove-nonexistent (with-handler (lambda (x) #t) (dict-remove (make-dict) 'nonexistent) #f)) (unit-test 'dict-remove-existing (null? (dict-remove (dict-set (make-dict) 'existing 1) 'existing))) (unit-test 'dict-keys (equal? (dict-keys (dict-set (make-dict) 'existing 1)) '(existing))) (print " ok.")) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Months support ;; Returns true if this is a valid month representation - a list with ;; two integer elements within the allowed range. (define (month-valid? m) (and (list? m) (car m) (cdr m) (cadr m) (null? (cddr m)) (integer? (car m)) (integer? (cadr m)) (>= (car m) 1000) (<= (car m) 9999) (>= (cadr m) 1) (<= (cadr m) 12))) ;; Converts string in a format YYYY-MM to valid month. Returns #f if ;; the conversion fails. (define (string->month s) (let ((l (string-split s "-"))) (if (or (not l) (null? l) (null? (cdr l)) (not (null? (cddr l)))) #f (let ((y (string->number (car l))) (m (string->number (cadr l)))) (if (and y m) (let ((M (list y m))) (if (month-valid? M) M #f)) #f))))) ;; Formats (valid) month as YYYY-MM string (define (month->string M) (if (month-valid? M) (let ((y (car M)) (m (cadr M))) (sprintf "~A-~A~A" y (if (< m 10) "0" "") m)) (error 'string->month "Invalid month" M))) ;; Returns true if both arguments are a valid month and are equal (define (month=? m n) (and (month-valid? m) (month-valid? n) (equal? m n))) ;; Returns true if the first argument is a month in the past of the ;; second argument month (define (monthmonth (equal? (string->month "2023-01") '(2023 1))) (unit-test 'string->month-bad-month (not (string->month "2023-13"))) (unit-test 'string->month-nonumber-year (not (string->month "YYYY-01"))) (unit-test 'string->month-nonumber-month (not (string->month "2023-MMM"))) (unit-test 'month->string (equal? (month->string '(2023 1)) "2023-01")) (unit-test 'month->string-bad-year (with-handler (lambda (x) #t) (month->string '(999 12)) #f)) (unit-test 'month->string-bad-month (with-handler (lambda (x) #t) (month->string '(2023 13)) #f)) (unit-test 'monthperiods l) (let loop ((l l) (ps '()) (cb #f)) (if (null? l) (if cb (reverse (cons (cons cb #f) ps)) (reverse ps)) (let ((m (car l)) (rmt (if cb 'stop 'start))) (if (eq? (car m) rmt) (if cb (loop (cdr l) (cons (cons cb (cdr m)) ps) #f) (loop (cdr l) ps (cdr m))) (error 'period-markers->periods "Invalid start/stop sequence marker" m)))))) (define (period-duration p) 1) (define (periods-duration l) 1) (define (period-tests!) (display "[test] period ") (unit-test 'sort-period-markers (equal? (sort-period-markers '((start 2023 1) (stop 2022 10) (start 2022 3))) '((start 2022 3) (stop 2022 10) (start 2023 1)))) (unit-test 'period-markers->periods (equal? (period-markers->periods '((start 2022 3) (stop 2022 10) (start 2023 1) (stop 2023 4))) '(((2022 3) . (2022 10)) ((2023 1) . (2023 4))))) (unit-test 'period-markers->periods-open (equal? (period-markers->periods '((start 2022 3) (stop 2022 10) (start 2023 1) (stop 2023 4) (start 2023 5))) '(((2022 3) . (2022 10)) ((2023 1) . (2023 4)) ((2023 5) . #f)))) (print " ok.")) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Members database ;; Loads all symlinks from (*members-directory*) returning a list of ;; pairs (name . destination) (define (load-members-raw-index) (let loop ((fns (directory (*members-directory*))) (rs '())) (if (null? fns) rs (let* ((fn (car fns)) (ffn (make-pathname (*members-directory*) fn)) (sl (if (symbolic-link? ffn) (read-symbolic-link ffn) #f))) (loop (cdr fns) (if sl (cons (cons fn sl) rs) rs)))))) ;; Converts the raw members index to a list of dictionaries with keys ;; 'id, 'name and 'file. File names are without directory element. (define (expand-members-raw-index ri) (let loop ((ri ri) (ds '())) (if (null? ri) ds (let* ((mp (car ri)) (lnk (car mp)) (dfn (cdr mp)) (lnkn (string->number lnk)) (dfnn (string->number dfn)) (id (or dfnn lnkn)) (name (if lnkn dfn lnk))) (loop (cdr ri) (cons (list (cons 'id id) (cons 'name name) (cons 'file dfn)) ds)))))) ;; Parses given key-value line. Key is up to first space, value is the ;; rest of the line. If the line doesn't contain anything, returns #f. (define (parse-member-line l) (let ((sp (string-split l " "))) (and sp (not (null? sp)) (list (string->symbol (car sp)) (string-intersperse (cdr sp)))))) ;; If given symbol represents start/stop symbol of either kind, ;; returns a list of the symbol representing the type and start/stop ;; symbol. It returns false otherwise. (define (split-start/stop-symbol s) (cond ((eq? s 'studentstart) '(student start)) ((eq? s 'studentstop) '(student stop)) ((eq? s 'suspendstart) '(suspend start)) ((eq? s 'suspendstop) '(suspend stop)) (else #f))) ;; Processes member line adding given value v to the dictionary d ;; under key k. Special handling for start/stop symbols means given ;; value is prepended to given start/stop key (student/suspend) as ;; parsed month for later processing of student/suspend periods. (define (process-member-line d k v) (let ((ss (split-start/stop-symbol k))) (cond (ss (let ((pk (car ss)) (pd (cadr ss))) (dict-set d pk (cons (cons pd (string->month v)) (dict-ref d pk '()))))) (else (dict-set d k v))))) ;; Processes all lines and returns a dictionary representing given ;; member. (define (parse-member-lines ls) (let loop ((ls ls) (r (make-dict))) (if (null? ls) (let* ((r1 (dict-set r 'suspend (sort-period-markers (dict-ref r 'suspend '())))) (r2 (dict-set r1 'student (sort-period-markers (dict-ref r1 'student '()))))) r2) (let ((p (parse-member-line (car ls)))) (loop (cdr ls) (if p (apply process-member-line r p) r)))))) ;; Loads lines from given file in (*members-directory*) and parses ;; them. (define (load-member-file fn) (let* ((ffn (make-pathname (*members-directory*) fn)) (f (open-input-file ffn)) (ls (read-lines f))) (parse-member-lines ls))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Run everything ;; Print banner (print "brmsaptool 0.1 (c) 2023 Brmlab, z.s.") (newline) ;; Run tests (print "Running self-tests:") (dict-tests!) (month-tests!) (period-tests!) (print "All self-tests ok!") (newline) ;; Perform requested action ;(print (expand-members-raw-index (load-members-raw-index))) (print (load-member-file "trimen"))