-
Notifications
You must be signed in to change notification settings - Fork 0
/
recipian.el
202 lines (167 loc) · 6.75 KB
/
recipian.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
;;; recipian.el -- handle recipes in org-mode
;;
;; Author: Ian Clark <ian@cyclone.local>
;;
;; This file is not part of GNU Emacs.
;;
;;; License: GPLv3
;;; Commentary:
;;; Code:
(defun recipian-generate-static-site (org-file www-root)
"Parse ORG-FILE for recipes, generate a static site at WWW-ROOT."
(let ((recipes (recipian-parse-recipes org-file)))
(recipian-write-json (concat www-root "/recipes.json") recipes)))
(defun recipian-parse-recipes (org-file)
"Parse a list of recipes from ORG-FILE."
(with-temp-buffer
(insert-file-contents org-file)
(org-mode)
(org-element-map (org-element-parse-buffer) 'headline
#'recipian--parse-recipe)))
(defun recipian-write-json (filename json-data)
"Write `JSON-data' to `FILENAME'."
(when (not json-data)
(error "cannot write json file with nil data"))
(write-region (json-encode json-data) nil filename))
(defun recipian-parse-plans (org-file)
"Parse a list of meal plans from ORG-FILE"
(with-temp-buffer
(insert-file-contents org-file)
(org-mode)
(org-element-map (org-element-parse-buffer) 'headline
#'recipian--parse-plan)))
(defun recipian-all-names (filename)
"Parse FILENAME for recipes and return list of names."
(mapcar (lambda (recipe) (alist-get 'name recipe))
(recipian-parse-recipes filename)))
(defun recipian--org-element-tags (elem)
"Return a list of all tags of ELEM. `org-element-property' doesn't implement
inherited tags or filetags"
(mapcar #'recipian--strip-props
(org-get-tags (org-element-property :begin elem))))
(defun recipian--strip-props (string)
"Remove all string properties from STRING. `org-element' places pointers to
the parse tree on strings, so when we call `(message)' on it we get a bunch of
junk."
(when string
(set-text-properties 0 (length string) nil string))
string)
(defun recipian--org-element-contents (elem)
"Return all text content under ELEM as a string."
(recipian--strip-props (org-element-interpret-data (org-element-contents elem))))
(defun recipian--find-child (elem name)
"Find the first direct child of ELEM with :raw-value of `NAME'."
(org-element-map (org-element-contents elem) 'headline
(lambda (child)
(when (equal name (org-element-property :raw-value child))
child))
nil t 'headline))
(defun recipian--child-as-list (elem)
"Find any org list items under ELEM and return as a lisp list."
(org-element-map (org-element-contents elem) 'item
(lambda (item)
(let ((start (org-element-property :contents-begin item))
(end (org-element-property :contents-end item)))
(buffer-substring start (1- end))))
nil nil 'item))
(defun recipian--parse-recipe (elem)
"Parse the recipe at ELEM and return an associated list of data. Returns NIL
on an invalid recipe."
(let ((name (org-element-property :raw-value elem))
(tags (recipian--org-element-tags elem))
(ingredients (mapcar #'recipian--parse-ingredient
(recipian--child-as-list
(recipian--find-child elem "Ingredients"))))
(steps (mapcar #'recipian--strip-props
(recipian--child-as-list
(recipian--find-child elem "Steps"))))
(notes (recipian--org-element-contents
(recipian--find-child elem "Notes")))
(servings (org-element-property :SERVINGS elem))
serving-size
serving-type
(source (org-element-property :SOURCE elem)))
(when (and ingredients steps)
(when (string-match "^\\([0-9./ ]+\\)\\b\\(.*\\)$" servings)
(let ((grp1 (match-string 1 servings))
(grp2 (match-string 2 servings)))
(setq serving-size (recipian--parse-number grp1))
(setq serving-type (recipian--strip-props grp2))))
`((name . ,name)
(tags . ,tags)
(ingredients . ,ingredients)
(steps . ,steps)
(notes . ,notes)
(serving-size . ,serving-size)
(serving-type . ,serving-type)
(source . ,source)))))
(defun recipian--parse-number (num)
"Parse NUM as a number, supported formats: N, N.M, N/M, N M/O"
(let ((num (string-trim num)))
(cond ((string-match-p " " num)
(apply #'+ (mapcar #'recipian--parse-number (split-string num " "))))
((string-match-p "/" num)
(apply #'/ (mapcar #'recipian--parse-number (split-string num "/"))))
(t
(float (string-to-number num))))))
(defun recipian--parse-ingredient (line)
"Parse an ingredient LINE and return a triple of (AMOUNT UNIT TEXT)."
(if (not (string-match "^\\([0-9./ ]+\\)\\([a-z]+\\)\\b\\(.*\\)$" line))
`((amount . nil)
(unit . nil)
(ingredient . ,line))
(defun string-clean (s)
(recipian--strip-props (string-trim s)))
;; NOTE: we can't use `(string-trim)' until we have captured all matching
;; groups as `(string-trim)' uses regexp's to trim messing up our outer
;; regexp.
(let ((grp1 (match-string 1 line))
(grp2 (match-string 2 line))
(grp3 (match-string 3 line)))
`((amount . ,(recipian--parse-number grp1))
(unit . ,(string-clean grp2))
(ingredient . ,(string-clean grp3)))
)))
(defun recipian--parse-plan (elem)
"Parse the plan at ELEM and return an associated list of data. Returns NIL on
an invalid plan."
(let ((name (org-element-property :raw-value elem))
(tags (recipian--org-element-tags elem))
(todo (recipian--strip-props (org-element-property :todo-keyword elem)))
(notes (recipian--element-as-string elem))
(date (org-element-property :scheduled elem))
count)
(when (and date
(member todo '("COOK" "PREP" "DONE"))
(member "plan" tags))
;; if name ends in "xN": pull out number into count and strip from name
(setq name
(string-trim
(replace-regexp-in-string
"x[0-9.]+$"
(lambda (match)
(setq count (string-to-number (substring match 1)))
"")
name
nil)))
`((name . ,name)
(todo . ,todo)
(notes . ,notes)
(count . ,count)
(date . ,(format-time-string "%Y-%m-%d" (org-timestamp-to-time date)))))))
(defun recipian--element-as-string (elem)
"Return a textual representation of ELEM, without any `org-mode' special
blocks"
(recipian--strip-props
(mapconcat
#'identity
(org-element-map (org-element-contents elem) 'paragraph
(lambda (item)
(let ((start (org-element-property :contents-begin item))
(end (org-element-property :contents-end item)))
(buffer-substring start (1- end))))
nil nil 'paragraph)
"\n"
)))
(provide 'recipian)
;;; recipian.el ends here