Browse Source

add tag indexing with suggestions

Ondřej Hruška 1 year ago
parent
commit
21baa4810c
Signed by: Ondřej Hruška <ondra@ondrovo.com> GPG key ID: 2C5FD5035250423D
7 changed files with 281 additions and 170 deletions
  1. 1 0
      .gitignore
  2. 2 0
      data/repository.yaml
  3. 32 144
      src/main.rs
  4. 133 8
      src/store/form.rs
  5. 109 17
      src/store/mod.rs
  6. 2 0
      templates/_form_macros.html.tera
  7. 2 1
      templates/index.html.tera

+ 1 - 0
.gitignore View File

@@ -2,3 +2,4 @@
2 2
 **/*.rs.bk
3 3
 .idea/
4 4
 data/data.json
5
+data/index.json

+ 2 - 0
data/repository.yaml View File

@@ -9,3 +9,5 @@ model:
9 9
     alive:
10 10
       type: "bool"
11 11
       default: true
12
+    tags:
13
+      type: "free_tags"

+ 32 - 144
src/main.rs View File

@@ -14,13 +14,11 @@ use rocket_contrib::templates::Template;
14 14
 
15 15
 mod store;
16 16
 
17
-use crate::store::form::{render_card_fields, render_empty_fields, RenderedCard, RenderedField};
18
-use crate::store::model::FieldKind;
17
+use crate::store::form::{render_card_fields, render_empty_fields, RenderedCard, RenderedField, MapFromForm, collect_card_form};
19 18
 use crate::store::Store;
20
-use indexmap::map::IndexMap;
21 19
 use parking_lot::RwLock;
22 20
 
23
-use rocket::request::{Form, FormItems, FromForm};
21
+use rocket::request::Form;
24 22
 use rocket::response::Redirect;
25 23
 use rocket::State;
26 24
 use std::env;
@@ -33,7 +31,7 @@ pub struct ListContext<'a> {
33 31
     pub pages: usize,
34 32
 }
35 33
 
36
-const per_page: usize = 20;
34
+const PER_PAGE: usize = 20; // TODO configurable
37 35
 
38 36
 fn find_page_with_card(store: &Store, card_id: usize) -> Option<usize> {
39 37
     if let Some((n, _)) = store
@@ -43,22 +41,18 @@ fn find_page_with_card(store: &Store, card_id: usize) -> Option<usize> {
43 41
         .enumerate()
44 42
         .find(|(_n, (id, _card))| **id == card_id)
45 43
     {
46
-        Some(n / per_page)
44
+        Some(n / PER_PAGE)
47 45
     } else {
48 46
         None
49 47
     }
50 48
 }
51 49
 
52
-#[get("/?<page>&<card>")]
53
-fn route_index(store: State<RwLock<Store>>, page: Option<usize>, card: Option<usize>) -> Template {
50
+#[get("/?<page>")]
51
+fn route_index(store: State<RwLock<Store>>, page: Option<usize>) -> Template {
54 52
     let rg = store.read();
55 53
 
56 54
     let mut page = page.unwrap_or_default();
57
-    let n_pages = (rg.data.cards.len() as f64 / per_page as f64).ceil() as usize;
58
-
59
-    if let Some(card_id) = card {
60
-        page = find_page_with_card(&rg, card_id).unwrap_or(page);
61
-    }
55
+    let n_pages = (rg.data.cards.len() as f64 / PER_PAGE as f64).ceil() as usize;
62 56
 
63 57
     if page >= n_pages {
64 58
         page = n_pages - 1;
@@ -72,8 +66,8 @@ fn route_index(store: State<RwLock<Store>>, page: Option<usize>, card: Option<us
72 66
             .data
73 67
             .cards
74 68
             .iter()
75
-            .skip(page * per_page)
76
-            .take(per_page)
69
+            .skip(page * PER_PAGE)
70
+            .take(PER_PAGE)
77 71
             .filter_map(|(id, card)| {
78 72
                 if let Value::Object(ref map) = card {
79 73
                     Some(RenderedCard {
@@ -90,123 +84,6 @@ fn route_index(store: State<RwLock<Store>>, page: Option<usize>, card: Option<us
90 84
     Template::render("index", context)
91 85
 }
92 86
 
93
-#[derive(Default)]
94
-struct MapFromForm {
95
-    pub data: IndexMap<String, String>,
96
-}
97
-
98
-impl<'a> FromForm<'a> for MapFromForm {
99
-    type Error = ();
100
-
101
-    fn from_form(items: &mut FormItems, _strict: bool) -> Result<Self, Self::Error> {
102
-        let mut new = MapFromForm::default();
103
-        items.for_each(|item| {
104
-            let (k, v) = item.key_value_decoded();
105
-            new.data.insert(k, v);
106
-        });
107
-        Ok(new)
108
-    }
109
-}
110
-
111
-fn collect_card_form(store: &Store, mut form: MapFromForm) -> IndexMap<String, Value> {
112
-    let mut card = IndexMap::new();
113
-
114
-    for (k, field) in &store.model.fields {
115
-        let mut value: Option<Value> = None;
116
-        if let Some(input) = form.data.remove(k) {
117
-            value = Some(match &field.kind {
118
-                FieldKind::Text | FieldKind::String | FieldKind::FreeEnum { .. } => {
119
-                    Value::String(input)
120
-                }
121
-                FieldKind::Bool { .. } => serde_json::to_value(true).unwrap(),
122
-                FieldKind::Int { min, max, default } => {
123
-                    let mut val: i64 = if input.is_empty() {
124
-                        *default
125
-                    } else {
126
-                        input.parse().expect("Error parse number")
127
-                    };
128
-
129
-                    if let Some(min) = min {
130
-                        val = val.max(*min);
131
-                    }
132
-
133
-                    if let Some(max) = max {
134
-                        val = val.min(*max);
135
-                    }
136
-
137
-                    serde_json::to_value(val).unwrap()
138
-                }
139
-                FieldKind::Float { min, max, default } => {
140
-                    let mut val: f64 = if input.is_empty() {
141
-                        *default
142
-                    } else {
143
-                        input.parse().expect("Error parse number")
144
-                    };
145
-
146
-                    if let Some(min) = min {
147
-                        val = val.min(*min);
148
-                    }
149
-
150
-                    if let Some(max) = max {
151
-                        val = val.max(*max);
152
-                    }
153
-
154
-                    serde_json::to_value(val).unwrap()
155
-                }
156
-                FieldKind::Enum { options, default } => {
157
-                    if options.contains(&input) {
158
-                        Value::String(input)
159
-                    } else {
160
-                        let val = default.as_ref().map(ToOwned::to_owned).unwrap_or_else(|| {
161
-                            options
162
-                                .first()
163
-                                .expect("fixed enum must have values")
164
-                                .to_owned()
165
-                        });
166
-
167
-                        Value::String(val)
168
-                    }
169
-                }
170
-                FieldKind::Tags { options } => {
171
-                    let tags: Vec<String> = input
172
-                        .split(' ')
173
-                        .map(ToOwned::to_owned)
174
-                        .filter_map(|tag| {
175
-                            if options.contains(&tag) {
176
-                                Some(tag)
177
-                            } else {
178
-                                None
179
-                            }
180
-                        })
181
-                        .collect();
182
-
183
-                    serde_json::to_value(tags).unwrap()
184
-                }
185
-                FieldKind::FreeTags { .. } => {
186
-                    let tags: Vec<String> = input
187
-                        .split(' ')
188
-                        .map(str::trim)
189
-                        .filter(|s| !s.is_empty())
190
-                        .map(ToOwned::to_owned)
191
-                        .collect();
192
-
193
-                    serde_json::to_value(tags).unwrap()
194
-                }
195
-            });
196
-        } else {
197
-            if let FieldKind::Bool { .. } = field.kind {
198
-                value = Some(Value::Bool(false));
199
-            }
200
-        }
201
-
202
-        if let Some(v) = value {
203
-            card.insert(k.to_owned(), v);
204
-        }
205
-    }
206
-
207
-    card
208
-}
209
-
210 87
 #[derive(Serialize)]
211 88
 struct AddCardContext<'a> {
212 89
     pub fields: Vec<RenderedField<'a>>,
@@ -231,12 +108,13 @@ fn route_add(store: State<RwLock<Store>>) -> Template {
231 108
 
232 109
 #[post("/add", data = "<form>")]
233 110
 fn route_add_save(form: Form<MapFromForm>, store: State<RwLock<Store>>) -> Redirect {
234
-    let mut rg = store.write();
111
+    let mut wg = store.write();
235 112
 
236
-    let card = collect_card_form(&rg, form.into_inner());
237
-    let id = rg.add_card(card);
113
+    let card = collect_card_form(&wg, form.into_inner());
114
+    let id = wg.add_card(card);
238 115
 
239
-    Redirect::found(uri!(route_index: page=_, card=id))
116
+    let page = find_page_with_card(&wg, id).unwrap_or(0);
117
+    Redirect::found(uri!(route_index: page))
240 118
 }
241 119
 
242 120
 #[get("/edit/<id>")]
@@ -261,23 +139,32 @@ fn route_edit_save(
261 139
     form: Form<MapFromForm>,
262 140
     store: State<RwLock<Store>>,
263 141
 ) -> Option<Redirect> {
264
-    let mut rg = store.write();
142
+    let mut wg = store.write();
265 143
 
266
-    let card = collect_card_form(&rg, form.into_inner());
267
-    rg.update_card(id, card);
144
+    let card = collect_card_form(&wg, form.into_inner());
145
+    wg.update_card(id, card);
268 146
 
269
-    Some(Redirect::found(uri!(route_index: page=_, card=id)))
147
+    let page = find_page_with_card(&wg, id).unwrap_or(0);
148
+    Some(Redirect::found(uri!(route_index: page)))
270 149
 }
271 150
 
272 151
 #[get("/delete/<id>")]
273 152
 fn route_delete(id: usize, store: State<RwLock<Store>>) -> Redirect {
274
-    let mut rg = store.write();
153
+    let mut wg = store.write();
275 154
 
276
-    let page = find_page_with_card(&rg, id).unwrap_or(0);
155
+    // must find page before deleting
156
+    let page = find_page_with_card(&wg, id).unwrap_or(0);
157
+    wg.delete_card(id);
277 158
 
278
-    rg.delete_card(id);
159
+    Redirect::found(uri!(route_index: page))
160
+}
279 161
 
280
-    Redirect::found(uri!(route_index: page=page, card=_))
162
+#[get("/maintenance/reindex")]
163
+fn route_reindex(store: State<RwLock<Store>>) -> Redirect {
164
+    let mut wg = store.write();
165
+    wg.rebuild_indexes();
166
+    wg.persist();
167
+    Redirect::found(uri!(route_index: _))
281 168
 }
282 169
 
283 170
 fn main() {
@@ -298,6 +185,7 @@ fn main() {
298 185
                 route_edit,
299 186
                 route_edit_save,
300 187
                 route_delete,
188
+                route_reindex,
301 189
             ],
302 190
         )
303 191
         .launch();

+ 133 - 8
src/store/form.rs View File

@@ -2,12 +2,15 @@ use crate::store::model::FieldKind;
2 2
 use crate::store::{model, Indexes, Store};
3 3
 use serde_json::Value;
4 4
 use std::borrow::Cow;
5
+use std::collections::BTreeSet;
5 6
 
6 7
 use lazy_static::lazy_static;
8
+use indexmap::map::IndexMap;
9
+use rocket::request::{FormItems, FromForm};
7 10
 
8 11
 lazy_static! {
9 12
     /// This is an example for using doc comment attributes
10
-    static ref EMPTY_VEC: Vec<String> = vec![];
13
+    static ref EMPTY_SET: BTreeSet<String> = Default::default();
11 14
 }
12 15
 
13 16
 #[derive(Serialize, Debug, Default)]
@@ -20,7 +23,7 @@ pub struct RenderedField<'a> {
20 23
     pub max: String,
21 24
     pub all_tags_json: String,
22 25
     pub tags_json: String,
23
-    pub options: Option<&'a Vec<String>>,
26
+    pub options: Option<Vec<&'a String>>,
24 27
     pub value: Cow<'a, str>,
25 28
     pub checked: bool,
26 29
 }
@@ -98,7 +101,7 @@ impl<'a> RenderedField<'a> {
98 101
             }
99 102
             FieldKind::Enum { options, default } => {
100 103
                 rendered.kind = "select";
101
-                rendered.options = Some(options);
104
+                rendered.options = Some(options.iter().collect());
102 105
 
103 106
                 if let Some(Value::String(s)) = value {
104 107
                     rendered.value = Cow::Borrowed(&s.as_str());
@@ -109,21 +112,21 @@ impl<'a> RenderedField<'a> {
109 112
             FieldKind::FreeEnum { enum_group } => {
110 113
                 rendered.kind = "free_select";
111 114
                 let group = enum_group.as_ref().unwrap_or(key);
112
-                let options = index.free_enums.get(group).unwrap_or(&EMPTY_VEC);
113
-                rendered.options = Some(options);
115
+                let options = index.free_enums.get(group).unwrap_or(&EMPTY_SET);
116
+                rendered.options = Some(options.iter().collect());
114 117
             }
115 118
             FieldKind::Tags { options } => {
116 119
                 rendered.kind = "tags";
117
-                rendered.options = Some(options);
120
+                rendered.options = Some(options.iter().collect());
118 121
 
119 122
                 rendered.all_tags_json = serde_json::to_string(options).unwrap();
120 123
             }
121 124
             FieldKind::FreeTags { tag_group } => {
122 125
                 rendered.kind = "free_tags";
123 126
                 let group = tag_group.as_ref().unwrap_or(key);
124
-                let options = index.free_tags.get(group).unwrap_or(&EMPTY_VEC);
127
+                let options = index.free_tags.get(group).unwrap_or(&EMPTY_SET);
125 128
 
126
-                rendered.options = Some(options);
129
+                rendered.options = Some(options.iter().collect());
127 130
                 rendered.all_tags_json = "[]".into();
128 131
             }
129 132
         }
@@ -190,3 +193,125 @@ pub fn render_card_fields<'a>(
190 193
         })
191 194
         .collect()
192 195
 }
196
+
197
+
198
+#[derive(Default)]
199
+pub struct MapFromForm {
200
+    pub data: IndexMap<String, String>,
201
+}
202
+
203
+impl<'a> FromForm<'a> for MapFromForm {
204
+    type Error = ();
205
+
206
+    fn from_form(items: &mut FormItems, _strict: bool) -> Result<Self, Self::Error> {
207
+        let mut new = MapFromForm::default();
208
+        items.for_each(|item| {
209
+            let (k, v) = item.key_value_decoded();
210
+            new.data.insert(k, v);
211
+        });
212
+        Ok(new)
213
+    }
214
+}
215
+
216
+pub fn collect_card_form(store: &Store, mut form: MapFromForm) -> IndexMap<String, Value> {
217
+    let mut card = IndexMap::new();
218
+
219
+    for (k, field) in &store.model.fields {
220
+        let mut value: Option<Value> = None;
221
+        if let Some(input) = form.data.remove(k) {
222
+            value = Some(match &field.kind {
223
+                FieldKind::Text | FieldKind::String | FieldKind::FreeEnum { .. } => {
224
+                    Value::String(input)
225
+                }
226
+                FieldKind::Bool { .. } => serde_json::to_value(true).unwrap(),
227
+                FieldKind::Int { min, max, default } => {
228
+                    let mut val: i64 = if input.is_empty() {
229
+                        *default
230
+                    } else {
231
+                        input.parse().expect("Error parse number")
232
+                    };
233
+
234
+                    if let Some(min) = min {
235
+                        val = val.max(*min);
236
+                    }
237
+
238
+                    if let Some(max) = max {
239
+                        val = val.min(*max);
240
+                    }
241
+
242
+                    serde_json::to_value(val).unwrap()
243
+                }
244
+                FieldKind::Float { min, max, default } => {
245
+                    let mut val: f64 = if input.is_empty() {
246
+                        *default
247
+                    } else {
248
+                        input.parse().expect("Error parse number")
249
+                    };
250
+
251
+                    if let Some(min) = min {
252
+                        val = val.min(*min);
253
+                    }
254
+
255
+                    if let Some(max) = max {
256
+                        val = val.max(*max);
257
+                    }
258
+
259
+                    serde_json::to_value(val).unwrap()
260
+                }
261
+                FieldKind::Enum { options, default } => {
262
+                    if options.contains(&input) {
263
+                        Value::String(input)
264
+                    } else {
265
+                        let val = default.as_ref().map(ToOwned::to_owned).unwrap_or_else(|| {
266
+                            options
267
+                                .first()
268
+                                .expect("fixed enum must have values")
269
+                                .to_owned()
270
+                        });
271
+
272
+                        Value::String(val)
273
+                    }
274
+                }
275
+                FieldKind::Tags { options } => {
276
+                    let mut tags: Vec<String> = input
277
+                        .split(' ')
278
+                        .map(ToOwned::to_owned)
279
+                        .filter_map(|tag| {
280
+                            if options.contains(&tag) {
281
+                                Some(tag)
282
+                            } else {
283
+                                None
284
+                            }
285
+                        })
286
+                        .collect();
287
+
288
+                    tags.sort();
289
+
290
+                    serde_json::to_value(tags).unwrap()
291
+                }
292
+                FieldKind::FreeTags { .. } => {
293
+                    let mut tags: Vec<String> = input
294
+                        .split(' ')
295
+                        .map(str::trim)
296
+                        .filter(|s| !s.is_empty())
297
+                        .map(ToOwned::to_owned)
298
+                        .collect();
299
+
300
+                    tags.sort();
301
+
302
+                    serde_json::to_value(tags).unwrap()
303
+                }
304
+            });
305
+        } else {
306
+            if let FieldKind::Bool { .. } = field.kind {
307
+                value = Some(Value::Bool(false));
308
+            }
309
+        }
310
+
311
+        if let Some(v) = value {
312
+            card.insert(k.to_owned(), v);
313
+        }
314
+    }
315
+
316
+    card
317
+}

+ 109 - 17
src/store/mod.rs View File

@@ -1,4 +1,4 @@
1
-use crate::store::model::Model;
1
+use crate::store::model::{Model, FieldKind};
2 2
 use indexmap::map::IndexMap;
3 3
 use serde::Serialize;
4 4
 use serde_json::Value;
@@ -6,7 +6,8 @@ use std::collections::HashMap;
6 6
 use std::fs::{File, OpenOptions};
7 7
 use std::io::{Read, Write};
8 8
 use std::path::{Path, PathBuf};
9
-use std::collections::BTreeMap;
9
+use std::collections::{BTreeMap, BTreeSet};
10
+use json_dotpath::DotPaths;
10 11
 
11 12
 pub mod form;
12 13
 pub mod model;
@@ -16,6 +17,7 @@ pub mod model;
16 17
 pub struct Store {
17 18
     path: PathBuf,
18 19
     pub model: Model,
20
+    freeform_fields : FreeformFieldsOfInterest,
19 21
     pub data: Cards,
20 22
     pub index: Indexes,
21 23
 }
@@ -23,8 +25,8 @@ pub struct Store {
23 25
 /// Indexes loaded from the indexes file
24 26
 #[derive(Serialize, Deserialize, Debug, Default)]
25 27
 pub struct Indexes {
26
-    pub free_enums: HashMap<String, Vec<String>>,
27
-    pub free_tags: HashMap<String, Vec<String>>,
28
+    pub free_enums: HashMap<String, BTreeSet<String>>,
29
+    pub free_tags: HashMap<String, BTreeSet<String>>,
28 30
 }
29 31
 
30 32
 /// Struct loaded from the repositroy config file
@@ -57,23 +59,30 @@ impl Store {
57 59
 
58 60
         Store {
59 61
             path: path.as_ref().into(),
62
+            freeform_fields: Self::get_fields_for_freeform_indexes(&repository_config.model),
60 63
             model: repository_config.model,
61 64
             data: serde_json::from_str(&items).expect("Error parsing data file."),
62 65
             index: serde_json::from_str(&indexes).unwrap_or_default(),
63 66
         }
64 67
     }
65 68
 
69
+    /// Handle a data change.
70
+    /// If a card was modified, selectively update the tags index
71
+    fn on_change(&mut self, changed_card : Option<usize>) {
72
+        if let Some(id) = changed_card {
73
+            // this needs to be so ugly because of lifetimes - we need a mutable reference to the index
74
+            Self::index_card(&mut self.index, &self.freeform_fields, self.data.cards.get(&id).unwrap());
75
+        }
76
+
77
+        self.persist();
78
+    }
79
+
66 80
     pub fn persist(&mut self) {
67
-        let mut file = OpenOptions::new()
68
-            .write(true)
69
-            .create(true)
70
-            .truncate(true)
71
-            .open(self.path.join(REPO_DATA_FILE))
72
-            .expect("Error opening data file for writing.");
73
-
74
-        let serialized = serde_json::to_string_pretty(&self.data).expect("Error serialize.");
75
-        file.write(serialized.as_bytes())
76
-            .expect("Error write data file");
81
+        let data_json = serde_json::to_string_pretty(&self.data).expect("Error serialize data");
82
+        write_file(self.path.join(REPO_DATA_FILE), data_json.as_bytes());
83
+
84
+        let index_json = serde_json::to_string_pretty(&self.index).expect("Error serialize index");
85
+        write_file(self.path.join(REPO_INDEX_FILE), index_json.as_bytes());
77 86
     }
78 87
 
79 88
     pub fn add_card(&mut self, values: IndexMap<String, Value>) -> usize {
@@ -82,7 +91,7 @@ impl Store {
82 91
             let id = self.data.counter;
83 92
             self.data.counter += 1;
84 93
             self.data.cards.insert(id, p);
85
-            self.persist();
94
+            self.on_change(Some(id));
86 95
             id
87 96
         } else {
88 97
             panic!("Packing did not produce a map.");
@@ -102,14 +111,97 @@ impl Store {
102 111
             panic!("Packing did not produce a map.");
103 112
         }
104 113
 
105
-        self.persist()
114
+        self.on_change(Some(id))
106 115
     }
107 116
 
108 117
     pub fn delete_card(&mut self, id: usize) {
109 118
         self.data.cards.remove(&id);
110 119
 
111
-        self.persist()
120
+        self.on_change(None)
121
+    }
122
+
123
+    /// Get a list of free_tags and free_enum fields
124
+    ///
125
+    /// Returns (free_tags, free_enums), where both members are vecs of (field_key, index_group)
126
+    fn get_fields_for_freeform_indexes(model : &Model) -> FreeformFieldsOfInterest {
127
+        // tuples (key, group)
128
+        let mut free_enum_fields : Vec<KeyAndGroup> = vec![];
129
+        let mut free_tag_fields : Vec<KeyAndGroup> = vec![];
130
+
131
+        for (key, field) in &model.fields {
132
+            match &field.kind {
133
+                FieldKind::FreeEnum { enum_group } => {
134
+                    let enum_group = enum_group.as_ref().unwrap_or(key);
135
+                    free_enum_fields.push(KeyAndGroup(key.to_string(), enum_group.to_string()));
136
+                },
137
+                FieldKind::FreeTags { tag_group } => {
138
+                    let tag_group = tag_group.as_ref().unwrap_or(key);
139
+                    free_tag_fields.push(KeyAndGroup(key.to_string(), tag_group.to_string()));
140
+                },
141
+                _ => {},
142
+            }
143
+        }
144
+
145
+        FreeformFieldsOfInterest {
146
+            free_tag_fields,
147
+            free_enum_fields,
148
+        }
112 149
     }
150
+
151
+    /// This is an associated function to split the lifetimes
152
+    fn index_card<'a>(index : &mut Indexes, freeform_fields : &FreeformFieldsOfInterest, card : &'a Value) {
153
+        for KeyAndGroup(key, group) in &freeform_fields.free_enum_fields {
154
+            if !index.free_enums.contains_key(key.as_str()) {
155
+                index.free_enums.insert(key.to_string(), Default::default());
156
+            }
157
+
158
+            let group = index.free_enums.get_mut(group.as_str()).unwrap();
159
+
160
+            if let Some(value) = card.dot_get::<String>(&key) {
161
+                if !value.is_empty() {
162
+                    group.insert(value.to_string());
163
+                }
164
+            }
165
+        }
166
+
167
+        for KeyAndGroup(key, group) in &freeform_fields.free_tag_fields {
168
+            if !index.free_tags.contains_key(key.as_str()) {
169
+                index.free_tags.insert(key.to_string(), Default::default());
170
+            }
171
+
172
+            let group = index.free_tags.get_mut(group.as_str()).unwrap();
173
+
174
+            group.extend(card.dot_get_or_default::<Vec<String>>(&key));
175
+        }
176
+    }
177
+
178
+    pub fn rebuild_indexes(&mut self) {
179
+        self.index.free_enums.clear();
180
+        self.index.free_tags.clear();
181
+
182
+        for (_, card) in &self.data.cards {
183
+            Self::index_card(&mut self.index, &self.freeform_fields, card);
184
+        }
185
+    }
186
+}
187
+
188
+#[derive(Debug)]
189
+struct KeyAndGroup(String, String);
190
+
191
+#[derive(Debug)]
192
+struct FreeformFieldsOfInterest {
193
+    pub free_tag_fields: Vec<KeyAndGroup>,
194
+    pub free_enum_fields: Vec<KeyAndGroup>,
195
+}
196
+
197
+fn write_file(path : impl AsRef<Path>, bytes : &[u8]) {
198
+    let mut file = OpenOptions::new()
199
+        .write(true).create(true).truncate(true)
200
+        .open(path)
201
+        .expect("Error opening data file for writing.");
202
+
203
+    file.write(bytes)
204
+        .expect("Error write data file");
113 205
 }
114 206
 
115 207
 fn load_file(path: impl AsRef<Path>) -> String {

+ 2 - 0
templates/_form_macros.html.tera View File

@@ -115,6 +115,8 @@
115 115
 			allowedTags: {% if not free %}{{field.all_tags_json | safe}}{% else %}[]{% endif %},
116 116
 			onTagAdd: onchange,
117 117
 			onTagRemove: onchange,
118
+			saveOnBlur: true,
119
+			clearOnBlur: false,
118 120
 		});
119 121
 
120 122
 		document.querySelector('#tag-input-{{field.key}} .taggle_input')

+ 2 - 1
templates/index.html.tera View File

@@ -7,6 +7,7 @@
7 7
 
8 8
 {% block nav -%}
9 9
 	<a href="/add">Add</a>
10
+	<a href="/maintenance/reindex" onclick="return confirm('Unused learned tags and options will be forgotten. Proceed?')">Re-index</a>
10 11
 {%- endblock %}
11 12
 
12 13
 {% block content -%}
@@ -24,7 +25,7 @@
24 25
 			<tr>
25 26
 				<td class="actions">
26 27
 					<a href="/edit/{{card.id}}">Edit</a>
27
-					<a href="/delete/{{card.id}}" onclick="return confirm('Confirm delete')">Delete</a>
28
+					<a href="/delete/{{card.id}}" onclick="return confirm('Delete card?')">Delete</a>
28 29
 				</td>
29 30
 			{%- for field in card.fields %}
30 31
 				<td>