;; ;; 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 time) (chicken time posix) (chicken process-context)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Static default configuration (define *members-directory* (make-parameter "members")) (define *current-month* (make-parameter (let ((d (seconds->local-time (current-seconds)))) (list (vector-ref d 5) (vector-ref d 4))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; 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)))))) ;; Returns duration of period in months. Start is included, end is ;; not. The period contains the month just before the specified end. (define (period->duration p) (let* ((b (car p)) (e (cdr p)) (e- (if e e (*current-month*)))) (month-diff b e-))) ;; Returns sum of periods lengths. (define (periods-duration l) (apply + (map period->duration l))) (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)))) (unit-test 'period-duration (eq? (period->duration '((2023 1) . (2023 4))) 3)) (parameterize ((*current-month* (list 2023 4))) (unit-test 'period-duration (eq? (period->duration '((2023 1) . #f)) 3))) (unit-test 'periods-duration (eq? (periods-duration '(((2022 3) . (2022 10)) ((2023 1) . (2023 4)))) 10)) (print " ok.")) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Member info data file ;; 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))) (if ss (let ((pk (car ss)) (pd (cadr ss)) (vl (string-split v " "))) (if (null? vl) (error 'process-member-line "Missing date for start/stop symbol" k) (let ((ds (car vl))) (dict-set d pk (cons (cons pd (string->month ds)) (dict-ref d pk '())))))) (dict-set d k v)))) ;; Converts given key in member info dictionary from period markers ;; list to periods. (define (convert-member-key:markers->periods m k) (dict-set m k (period-markers->periods (sort-period-markers (dict-ref m k '()))))) ;; Converts all given keys using period-markers->periods. (define (convert-member-keys:markers->periods m . ks) (let loop ((m m) (ks ks)) (if (null? ks) m (loop (convert-member-key:markers->periods m (car ks)) (cdr ks))))) ;; 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) (convert-member-keys:markers->periods r 'suspend 'student) (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)) (md (parse-member-lines ls))) (display ".") md)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; 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) (let () (display "-") 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) (let () (display "*") 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)))))) ;; Adds the 'info key to all expanded index entries by loading ;; appropriate 'file key file from the members directory. (define (load-members-from-expanded-index ei) (let loop ((ei ei) (mdb '())) (if (null? ei) mdb (let ((mi (car ei))) (let ((mid (load-member-file (dict-ref mi 'file)))) (loop (cdr ei) (cons (dict-set mi 'info mid) mdb))))))) ;; Loads all member information from given members database. (define (load-members) (load-members-from-expanded-index (expand-members-raw-index (load-members-raw-index)))) ;; Gets member record by member key (define (find-member-by-key mdb key val) (let loop ((mdb mdb)) (if (null? mdb) #f (let ((mr (car mdb))) (if (equal? (dict-ref mr key) val) mr (loop (cdr mdb))))))) ;; Gets member record by member id (from file/symlink) (define (find-member-by-id mdb id) (find-member-by-key mdb 'id id)) ;; Gets member record by member name (from file/symlink) (define (find-member-by-fname mb fname) (find-member-by-key mdb 'name fname)) (define (find-member-by-nick mb nick) #f) (define (list-members-ids mdb) #f) (define (list-members-fnames mdb) #f) (define (list-members-nicks mdb) #f) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; 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 (display "Loading members ") (define mdb (load-members)) (print " ok.") (newline) ; (print mdb) ; (print (load-member-file "trimen")) (print (find-member-by-id mdb 2803)) (print (find-member-by-fname mdb "joe"))