fin-lisp.lisp 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. ;;;(ql:quickload "cl-store")
  2. (ql:quickload "yason")
  3. (ql:quickload "cl-ppcre")
  4. (ql:quickload "unix-opts")
  5. (ql:quickload "ironclad")
  6. ;;; Features
  7. ;;; - Import records from old .txt format
  8. ;;; - Removed as I'll never use it again
  9. ;;; - Interactive prompt to manage expenses
  10. ;;; - Generic expense handling
  11. ;;; TODO
  12. ;;; - Non interactive CLI Interface
  13. ;;; - Upload/download support like perl version
  14. ;;; - Should support encryption/decryption of records - DONE
  15. ;;; - Interface is not good, and doesn't protect the user from
  16. ;;; data entry mistakes
  17. ;;;; Reimplementation of my bills tracker in Lisp
  18. ;;; All records exist in this data structure
  19. ;;; nil on start and loaded in from file
  20. ;;; *records* represents as hash of months,
  21. ;;; where the key is the month stamp, eg 20210701
  22. ;;; and the value is the monthly expenses hash
  23. (defvar *records* (make-hash-table :test 'equalp))
  24. (defun file-test (filename)
  25. (if (probe-file filename) filename (print "Couldn't find filename")))
  26. ;;; Used by "print-month" arg to validate
  27. ;;; the user provided a valid key
  28. (defun check-month (month-key)
  29. (if (stringp month-key) month-key))
  30. (opts:define-opts
  31. (:name :help
  32. :description "Print help text"
  33. :short #\h
  34. :long "help")
  35. (:name :read
  36. :description "Read serialized records file"
  37. :short #\r
  38. :long "read"
  39. :arg-parser #'file-test)
  40. (:name :print-month
  41. :description "Print records for given month"
  42. :short #\p
  43. :long "print-month"
  44. :arg-parser #'check-month)
  45. (:name :interactive-mode
  46. :description "Run in interactive mode"
  47. :short #\i
  48. :long "interactive"))
  49. ;; See: https://github.com/libre-man/unix-opts/blob/master/example/example.lisp
  50. (defmacro when-option ((options opt) &body body)
  51. `(let ((it (getf ,options ,opt)))
  52. (when it
  53. ,@body)))
  54. (defun reload ()
  55. (load "~/Repos/fin-lisp/fin-lisp.lisp"))
  56. (defun wfile (file-content file-path)
  57. (alexandria:write-string-into-file
  58. (concatenate 'string file-content) file-path :if-exists :overwrite
  59. :if-does-not-exist :create))
  60. ;;;;;;;;;;;;;;;;;;;;;;;;
  61. ;;; Encryption stuff ;;;
  62. ;;;;;;;;;;;;;;;;;;;;;;;;
  63. ;;; See: https://www.cliki.net/Ironclad
  64. ;;; Return cipher when provided key
  65. (defun get-cipher (key)
  66. (ironclad:make-cipher
  67. :blowfish
  68. :mode :ecb
  69. :key (ironclad:ascii-string-to-byte-array key)))
  70. ;;; First serialize the file,
  71. ;;; then encrypt it from disk
  72. (defun encrypt-records (key filename)
  73. (let ((cipher (get-cipher key))
  74. (file-content (uiop:read-file-string filename)))
  75. (let ((content (ironclad:ascii-string-to-byte-array file-content)))
  76. (ironclad:encrypt-in-place cipher content)
  77. (wfile
  78. (write-to-string (ironclad:octets-to-integer content))
  79. (concatenate 'string filename ".enc")))))
  80. (defun decrypt-records (key filename)
  81. (let ((cipher (get-cipher key))
  82. (file-content (uiop:read-file-string filename)))
  83. (let ((content (ironclad:integer-to-octets (parse-integer file-content))))
  84. (ironclad:decrypt-in-place cipher content)
  85. (coerce (mapcar #'code-char (coerce content 'list)) 'string))))
  86. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;
  87. ;;; End Encryption Stuff ;;;
  88. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;
  89. (defun reset-records ()
  90. (setf *records* (make-hash-table :test 'equal)))
  91. ;; Called like: (add-month '202107)
  92. (defun add-month (month-key)
  93. (setf (gethash month-key *records*) (make-hash-table :test 'equalp))
  94. month-key)
  95. ;;; Taken from practical common lisp
  96. (defun prompt-read (prompt)
  97. (format *query-io* "~a: " prompt)
  98. (force-output *query-io*)
  99. (read-line *query-io*))
  100. (defun prompt-for-expense ()
  101. (list
  102. (prompt-read "Enter expense name")
  103. (parse-integer
  104. (prompt-read "Enter expense value"))))
  105. (defun add-expense-to-month (month)
  106. (if (gethash month *records*)
  107. (let ((innerhash (gethash month *records*))
  108. (exp-l (prompt-for-expense)))
  109. (setf (gethash (first exp-l) innerhash) (second exp-l)))
  110. ;;NIL))
  111. (add-expense-to-month (add-month month))))
  112. ;;; Given key for *records* hash,
  113. ;;; print expenses/values for month
  114. (defun dump-month (month-key)
  115. (format t "~a~C" month-key #\linefeed)
  116. (let ((month-hash)
  117. (exp-keys))
  118. (setf month-hash (gethash month-key *records*))
  119. (setf exp-keys (loop for key being the hash-keys of month-hash collect key))
  120. (dolist (exp-key exp-keys)
  121. (format t "~a : ~a~C" exp-key (gethash exp-key month-hash) #\linefeed))))
  122. ;;; Dump all records.
  123. (defun dump-records ()
  124. (let ((record-key-list (loop for key being the hash-keys of *records* collect key)))
  125. (dolist (month-key record-key-list) (dump-month month-key))))
  126. (defun serialize-records (key filename)
  127. (with-open-file (stream filename
  128. :direction :output
  129. :if-exists :overwrite
  130. :if-does-not-exist :create)
  131. (yason:encode *records* stream))
  132. (encrypt-records key filename)
  133. (delete-file filename))
  134. (defun deserialize-records (key filename)
  135. (setf *records* (yason:parse (decrypt-records key filename))))
  136. (defmacro generic-handler (form error-string)
  137. `(handler-case ,form
  138. (error (e)
  139. (format t "Invalid input: ~a ~%" ,error-string)
  140. (values 0 e))))
  141. ;; Util screen clearer
  142. (defun cls()
  143. (format t "~A[H~@*~A[J" #\escape))
  144. (defun interactive-mode ()
  145. (format t "~%")
  146. (format t "Available options:~%")
  147. (format t "1. Enter expense~%")
  148. (format t "2. Display month~%")
  149. (format t "3. Write records~%")
  150. (format t "4. Read records~%")
  151. (format t "5. Quit~%")
  152. (let
  153. ((answer (prompt-read "Select an option")))
  154. (if (string= answer "1")
  155. (generic-handler
  156. (add-expense-to-month (prompt-read "Enter month"))
  157. "Invalid Input"))
  158. (if (string= answer "2")
  159. (generic-handler
  160. (dump-month (prompt-read "Enter month"))
  161. "Invalid month"))
  162. (if (string= answer "3")
  163. (generic-handler
  164. (serialize-records (prompt-read "Enter encryption key")
  165. (prompt-read "Enter filename"))
  166. "Serialization error or invalid filename"))
  167. (if (string= answer "4")
  168. (generic-handler
  169. (deserialize-records (prompt-read "Enter decryption key")
  170. (prompt-read "Enter filename"))
  171. "Deserialization error or invalid filename"))
  172. (if (string= answer "5")
  173. (quit)))
  174. (interactive-mode))
  175. (defun display-help ()
  176. (format t "foo ~%")
  177. (opts:describe
  178. :prefix "fin-lisp.lisp - Basic expense tracker in lisp"
  179. :usage-of "fin-lisp.lisp"
  180. :args "[FREE-ARGS]")
  181. (quit))
  182. ;; Entry point
  183. (defun main ()
  184. (if (= 1 (length sb-ext:*posix-argv*)) (interactive-mode))
  185. (let ((matches (opts:get-opts)))
  186. (format t "~a ~%" matches)
  187. (when-option (matches :help)
  188. (display-help))
  189. (when-option (matches :print-month)
  190. (when-option (matches :interactive-mode)
  191. (progn
  192. (interactive-mode)
  193. (quit))))))