filters.mjs 21.3 KB
Newer Older
1
"use strict";
ransome1's avatar
ransome1 committed
2
import { userData, handleError, translations, setUserData, startBuilding, getConfirmation } from "../render.js";
ransome1's avatar
ransome1 committed
3
import { createModalJail } from "../configs/modal.config.mjs";
4
5
import { _paq } from "./matomo.mjs";
import { items } from "./todos.mjs";
6
import { isToday, isPast, isFuture } from "./date.mjs";
ransome1's avatar
ransome1 committed
7
8
import * as filterlang from "./filterlang.mjs";
import { runQuery } from "./filterquery.mjs";
9

10
11
12
const todoTableSearch = document.getElementById("todoTableSearch");
const autoCompleteContainer = document.getElementById("autoCompleteContainer");
const todoFilters = document.getElementById("todoFilters");
13
14
15
16
const filterContext = document.getElementById("filterContext");
const filterContextInput = document.getElementById("filterContextInput");
const filterContextSave = document.getElementById("filterContextSave");
const filterContextDelete = document.getElementById("filterContextDelete");
17
18

let categories,
ransome1's avatar
ransome1 committed
19
    filterCounter = 0,
20
21
22
    filtersCounted,
    filtersCountedReduced,
    selectedFilters,
ransome1's avatar
ransome1 committed
23
24
    lastFilterQueryString = null,
    lastFilterItems = null,
25
26
27
    container,
    headline;

28
29
filterContextSave.innerHTML = translations.save;
filterContextDelete.innerHTML = translations.delete;
30

31
32
33
34
filterContextInput.addEventListener("keydown", (event) => {
  if(event.code==="Space") event.preventDefault();
})

35
function saveFilter(newFilter, oldFilter, category) {
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
  try {
    items.objects.forEach((item) => {
      if(category!=="priority" && item[category]) {
        const index = item[category].findIndex((el) => el === oldFilter);
        item[category][index] = newFilter;
      } else if(category==="priority" && item[category]===oldFilter) {
        item[category] = newFilter.toUpperCase();
      }
    });
    // persisted filters will be removed
    setUserData("selectedFilters", []);
    //write the data to the file
    // a newline character is added to prevent other todo.txt apps to append new todos to the last line
    window.api.send("writeToFile", [items.objects.join("\n").toString() + "\n", userData.file]);
    // trigger matomo event
    if(userData.matomoEvents) _paq.push(["trackEvent", "Filter-Drawer", "Filter renamed"]);
    return Promise.resolve("Success: Filter renamed");
  } catch(error) {
    error.functionName = saveFilter.name;
    return Promise.reject(error);
  }
57
58
}
function deleteFilter(filter, category) {
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
  try {
    items.objects.forEach((item) => {
      if(category!=="priority" && item[category]) {
        const index = item[category].indexOf(filter);
        if(index!==-1) item[category].splice(index, 1);
        if(item[category].length===0) item[category] = null;
      } else if(category==="priority" && item[category]===filter) {
        item[category] = null;
      }
    });
    // persisted filters will be removed
    setUserData("selectedFilters", []);
    //write the data to the file
    // a newline character is added to prevent other todo.txt apps to append new todos to the last line
    window.api.send("writeToFile", [items.objects.join("\n").toString() + "\n", userData.file]);
    // trigger matomo event
    if(userData.matomoEvents) _paq.push(["trackEvent", "Filter-Drawer", "Filter deleted"]);
    return Promise.resolve("Success: Filter deleted");
  } catch(error) {
    error.functionName = deleteFilter.name;
    return Promise.reject(error);
  }
81
}
82
function filterItems(items) {
83
84
85
  try {
    // selected filters are empty, unless they were persisted
    if(userData.selectedFilters && userData.selectedFilters.length>0) {
86
      selectedFilters = JSON.parse(userData.selectedFilters);
87
    } else {
88
      selectedFilters = new Array;
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
      userData.selectedFilters = selectedFilters;
    }
    // apply persisted contexts and projects
    if(selectedFilters.length > 0) {
      // we iterate through the filters in the order they got selected
      selectedFilters.forEach(filter => {
        if(filter[1]=="projects") {
          items = items.filter(function(item) {
            if(item.projects) return item.projects.includes(filter[0]);
          });
        } else if(filter[1]=="contexts") {
          items = items.filter(function(item) {
            if(item.contexts) {
              return item.contexts.includes(filter[0]);
            }
          });
        } else if(filter[1]=="priority") {
          items = items.filter(function(item) {
            if(item.priority) {
              return item.priority.includes(filter[0]);
            }
          });
        }
      });
    }
114
115
116
117
118
119
120
121
122
    // apply persisted excluded categories filter
    if(userData.hideFilterCategories.length > 0) {
      // we iterate through the filters in the order they got selected
      userData.hideFilterCategories.forEach(filter => {
        items = items.filter(function(item) {
          if(!item[filter]) return item;
        });
      });
    }
ransome1's avatar
ransome1 committed
123
124
    if (todoTableSearch.value) { // assume that this is an advanced search expr
      let queryString = todoTableSearch.value;
125
      try {
ransome1's avatar
ransome1 committed
126
        let query = filterlang.parse(queryString);
127
128
129
130
        if (query.length > 0) {
          items = items.filter(function(item) {
            return runQuery(item, query);
          });
ransome1's avatar
ransome1 committed
131
132
          lastFilterQueryString = queryString;
          lastFilterItems = items;
zerodat's avatar
zerodat committed
133
134
          todoTableSearch.classList.add("is-valid-query");
          todoTableSearch.classList.remove("is-previous-query");
135
136
        }
      } catch(e) {
ransome1's avatar
ransome1 committed
137
        // oops, that wasn't a syntactically correct search expression
zerodat's avatar
zerodat committed
138
        todoTableSearch.classList.remove("is-valid-query");
ransome1's avatar
ransome1 committed
139
140
141
142
        if (lastFilterQueryString && queryString.startsWith(lastFilterQueryString)) {
          // keep table more stable by using the previous valid query while
          // user continues to type additional query syntax.
          items = lastFilterItems;
zerodat's avatar
zerodat committed
143
          todoTableSearch.classList.add("is-previous-query");
ransome1's avatar
ransome1 committed
144
145
146
147
148
149
150
        } else {
          // the query is not syntactically correct and isn't a longer version
          // of the last working query, so let's assume that it is a
          // plain-text query.
          items = items.filter(function(item) {
            return item.toString().toLowerCase().indexOf(queryString.toLowerCase()) !== -1;
          });
zerodat's avatar
zerodat committed
151
          todoTableSearch.classList.remove("is-previous-query");
ransome1's avatar
ransome1 committed
152
        }
153
154
      }
    }
ransome1's avatar
ransome1 committed
155
    // apply filters
156
    items = items.filter(function(item) {
ransome1's avatar
ransome1 committed
157
158
      if(!item.text) return false
      if(!userData.showHidden && item.h) return false;
159
      if(!userData.showCompleted && item.complete) return false;
160
161
162
      if(!userData.showDueIsToday && item.due && isToday(item.due)) return false;
      if(!userData.showDueIsPast && item.due && isPast(item.due)) return false;
      if(!userData.showDueIsFuture && item.due && isFuture(item.due)) return false;
163
164
165
166
167
168
169
170
171
172
173
      if(item.text==="") return false;
      return true;
    });
    return Promise.resolve(items);
  } catch(error) {
    error.functionName = filterItems.name;
    return Promise.reject(error);
  }
}
function generateFilterData(autoCompleteCategory, autoCompleteValue, autoCompletePrefix, caretPosition) {
  try {
ransome1's avatar
ransome1 committed
174
175
    // reset filter counter
    filterCounter = 0;
176
177
    // select the container (filter drawer or autocomplete) in which filters will be shown
    if(autoCompleteCategory) {
178
      container = autoCompleteContainer;
179
180
181
182
183
184
185
186
187
188
      container.innerHTML = "";
      // empty default categories
      categories = [];
      // fill only with selected autocomplete category
      categories.push(autoCompleteCategory);
      // for the suggestion container, so all filters will be shown
      items.filtered = items.objects;
    // in filter drawer, filters are adaptive to the shown items
    } else {
      // empty filter container first
189
      container = todoFilters;
190
191
192
193
194
195
196
      container.innerHTML = "";
      // needs to be reset every run, because it can be overwritten by previous autocomplete
      categories = ["priority", "contexts", "projects"];
    }
    categories.forEach((category) => {
      // array to collect all the available filters in the data
      let filters = new Array();
197
      let filterArray;
198
      // run the array and collect all possible filters, duplicates included
199
      if(userData.showEmptyFilters) {
200
        filterArray = items.objects;
201
      } else {
202
        filterArray = items.filtered;
203
      }
204
      filterArray.forEach((item) => {
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
        // check if the object has values in either the project or contexts field
        if(item[category]) {
          // push all filters found so far into an array
          for (let filter in item[category]) {
            // if user has not opted for showComplete we skip the filter of this particular item
            if(userData.showCompleted==false && item.complete==true) {
              continue;
            // if task is hidden the filter will be marked
            } else if(item.h) {
              filters.push([item[category][filter],0]);
            } else {
              filters.push([item[category][filter],1]);
            }
          }
        }
      });
      // search within filters according to autoCompleteValue
      if(autoCompletePrefix) {
        filters = filters.filter(function(el) { return el.toString().toLowerCase().includes(autoCompleteValue.toLowerCase()); });
      }
      // remove duplicates, create the count object and avoid counting filters of hidden todos
      filtersCounted = filters.reduce(function(filters, filter) {
        // if filter is already in object and should be counted
ransome1's avatar
ransome1 committed
228
        if(filter[1] && (filter[0] in filters)) {
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
          filters[filter[0]]++;
        // new filter in object and should be counted
        } else if(filter[1]) {
          filters[filter[0]] = 1;
        // do not count if filter is suppose to be hidden
        // only overwrite value with 0 if the filter doesn't already exist in object
        } else if(!filter[1] && !(filter[0] in filters)) {
          filters[filter[0]] = 0;
        }
        if(filters!=null) {
          return filters;
        }
      }, {});
      // sort filter alphanummerically (https://stackoverflow.com/a/54427214)
      filtersCounted = Object.fromEntries(
        Object.entries(filtersCounted).sort(new Intl.Collator('en',{numeric:true, sensitivity:'accent'}).compare)
      );
      // remove duplicates from available filters
      // https://wsvincent.com/javascript-remove-duplicates-array/
      filters = [...new Set(filters.join(",").split(","))];
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
      // count reduced filter when persisted filters are present
      let filtersReduced = new Array();
      items.filtered.forEach((item) => {
        // check if the object has values in either the project or contexts field
        if(item[category]) {
          // push all filters found so far into an array
          for (let filter in item[category]) {
            // if user has not opted for showComplete we skip the filter of this particular item
            if(userData.showCompleted==false && item.complete==true) {
              continue;
              // if task is hidden the filter will be marked
            } else if(item.h) {
              filtersReduced.push([item[category][filter],0]);
            } else {
              filtersReduced.push([item[category][filter],1]);
            }
          }
        }
      });
      filtersCountedReduced = filtersReduced.reduce(function(filters, filter) {
        // if filter is already in object and should be counted
        if (filter[1] && (filter[0] in filters)) {
          filters[filter[0]]++;
          // new filter in object and should be counted
        } else if(filter[1]) {
          filters[filter[0]] = 1;
          // do not count if filter is suppose to be hidden
          // only overwrite value with 0 if the filter doesn't already exist in object
        } else if(!filter[1] && !(filter[0] in filters)) {
          filters[filter[0]] = 0;
        }
        if(filters!=null) {
          return filters;
        }
      }, {});
284
285
      // build the filter buttons
      if(filters[0]!="" && filters.length>0) {
286
        // add category length to total filter count
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
        generateFilterButtons(category, autoCompleteValue, autoCompletePrefix, caretPosition).then(response => {
          if(userData.hideFilterCategories.includes(category)) {
            response.classList.add("is-greyed-out");
          }
          container.appendChild(response);
        }).catch (error => {
          handleError(error);
        });
      } else {
        autoCompleteContainer.classList.remove("is-active");
        autoCompleteContainer.blur();
        console.log("Info: No " + category + " found in todo.txt data, so no filters will be generated");
      }
    });
    return Promise.resolve("Success: Filter data generated");
  } catch (error) {
    error.functionName = generateFilterData.name;
    return Promise.reject(error);
  }
}
function selectFilter(filter, category) {
  // if no filters are selected, add a first one
309
  if(selectedFilters.length > 0) {
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
    // get the index of the item that matches the data values the button click provided
    let index = selectedFilters.findIndex(item => JSON.stringify(item) === JSON.stringify([filter, category]));
    if(index != -1) {
      // remove the item at the index where it matched
      selectedFilters.splice(index, 1);
    } else {
      // if the item is not already in the array, push it into
      selectedFilters.push([filter, category]);
    }
  } else {
    // this is the first push
    selectedFilters.push([filter, category]);
  }
  // convert the collected filters to JSON and save it to store.js
  setUserData("selectedFilters", JSON.stringify(selectedFilters));
  startBuilding();
}
function generateFilterButtons(category, autoCompleteValue, autoCompletePrefix, caretPosition) {
  try {
ransome1's avatar
ransome1 committed
329
    let hideFilterCategories = userData.hideFilterCategories;
330
    selectedFilters = new Array;
331
    if(userData.selectedFilters && userData.selectedFilters.length>0) selectedFilters = JSON.parse(userData.selectedFilters);
332
    // creates a div for the specific filter section
333
334
    let todoFiltersContainer = document.createElement("section");
    todoFiltersContainer.setAttribute("class", category);
335
336
    // translate headline
    if(category=="contexts") {
337
      headline = translations.contexts;
338
    } else if(category=="projects"){
339
      headline = translations.projects;
340
    } else if(category=="priority"){
341
      headline = translations.priority;
342
    }
343
    if(autoCompletePrefix===undefined && userData.showEmptyFilters) {
344
345
      // create a sub headline element
      let todoFilterHeadline = document.createElement("h4");
ransome1's avatar
ransome1 committed
346
347
348
349
350
351
352
353
354
355
      todoFilterHeadline.setAttribute("class", "is-4 clickable");
      // setup greyed out state
      if(hideFilterCategories.includes(category)) {
        todoFilterHeadline.innerHTML = "<i class=\"far fa-eye\" tabindex=\"-1\"></i>&nbsp;" + headline;
        todoFilterHeadline.classList.add("is-greyed-out");
      } else {
        todoFilterHeadline.innerHTML = "<i class=\"far fa-eye-slash\" tabindex=\"-1\"></i>&nbsp;" + headline;
        todoFilterHeadline.classList.remove("is-greyed-out");
      }
      // add click event
356
      todoFilterHeadline.onclick = function() {
357
        document.getElementById("todoTableWrapper").scrollTo(0,0);
358
359
360
361
362
363
364
        if(hideFilterCategories.includes(category)) {
          hideFilterCategories.splice(hideFilterCategories.indexOf(category),1)
        } else {
          hideFilterCategories.push(category);
          hideFilterCategories = [...new Set(hideFilterCategories.join(",").split(","))];
        }
        setUserData("hideFilterCategories", hideFilterCategories)
ransome1's avatar
ransome1 committed
365
        startBuilding();
366
367
368
369
370
      }
      // add the headline before category container
      todoFiltersContainer.appendChild(todoFilterHeadline);
    } else {
      let todoFilterHeadline = document.createElement("h4");
371
372
373
374
375
376
377
      // show suggestion box when prefix is present
      if(autoCompletePrefix!==undefined) {
        autoCompleteContainer.classList.add("is-active");
        autoCompleteContainer.focus();
      }
      todoFilterHeadline.setAttribute("tabindex", -1);
      // create a sub headline element
ransome1's avatar
ransome1 committed
378
      todoFilterHeadline.setAttribute("class", "is-4");
379
      // no need for tab index if the headline is in suggestion box
380
      //if(autoCompletePrefix==undefined)
381
382
383
384
      todoFilterHeadline.innerHTML = headline;
      // add the headline before category container
      todoFiltersContainer.appendChild(todoFilterHeadline);
    }
385
    // to figure out how many buttons with filters behind them have been build in the end
386
387
388
389
    // build one button each
    for (let filter in filtersCounted) {
      // skip this loop if no filters are present
      if(!filter) continue;
ransome1's avatar
ransome1 committed
390
      let todoFiltersItem = document.createElement("button");
391
      if(category==="priority") todoFiltersItem.classList.add(filter);
392
393
      todoFiltersItem.setAttribute("data-filter", filter);
      todoFiltersItem.setAttribute("data-category", category);
394
      if(autoCompletePrefix===undefined) { todoFiltersItem.setAttribute("tabindex", 0) } else { todoFiltersItem.setAttribute("tabindex", 0) }
395
396
397
398
399
400
      todoFiltersItem.innerHTML = filter;
      if(autoCompletePrefix==undefined) {
        // set highlighting if filter/category combination is on selected filters array
        selectedFilters.forEach(function(item) {
          if(JSON.stringify(item) === '["'+filter+'","'+category+'"]') todoFiltersItem.classList.toggle("is-dark")
        });
401
        // add context menu
402
        todoFiltersItem.addEventListener("contextmenu", event => {
ransome1's avatar
ransome1 committed
403
404
405
          // jail the modal
          createModalJail(filterContext);

406
407
408
409
410
411
412
          filterContext.style.left = event.x + "px";
          filterContext.style.top = event.y + "px";
          filterContext.classList.add("is-active");
          filterContextInput.value = filter;
          filterContextInput.focus();
          filterContextInput.onkeyup = function(event) {
            if(event.key === "Escape") filterContext.classList.remove("is-active");
413
            if(event.key === "Enter") {
414
415
              if(filterContextInput.value!==filter && filterContextInput.value) {
                saveFilter(filterContextInput.value, filter, category).then(function(response) {
416
417
418
419
                  console.info(response);
                }).catch(function(error) {
                  handleError(error);
                });
420
              } else {
421
                filterContext.classList.remove("is-active");
422
423
              }
            }
424
          }
425
426
427
          filterContextSave.onclick = function() {
            if(filterContextInput.value!==filter && filterContextInput.value) {
              saveFilter(filterContextInput.value, filter, category).then(function(response) {
428
429
430
431
                console.info(response);
              }).catch(function(error) {
                handleError(error);
              });
432
            } else {
433
              filterContext.classList.remove("is-active");
434
435
            }
          }
436
          filterContextDelete.onclick = function() {
ransome1's avatar
ransome1 committed
437
            getConfirmation(deleteFilter, translations.deleteCategoryPrompt, filter, category);
438
          }
439
        });
440
441
442
443
        if(filtersCountedReduced[filter]) {
          todoFiltersItem.innerHTML += " <span class=\"tag is-rounded\">" + filtersCountedReduced[filter] + "</span>";
          // create the event listener for filter selection by user
          todoFiltersItem.addEventListener("click", () => {
444
            document.getElementById("todoTableWrapper").scrollTo(0,0);
445
446
447
448
449
            selectFilter(todoFiltersItem.getAttribute('data-filter'), todoFiltersItem.getAttribute('data-category'))
            // trigger matomo event
            if(userData.matomoEvents) _paq.push(["trackEvent", "Filter-Drawer", "Click on filter tag", category]);
          });
        } else {
ransome1's avatar
ransome1 committed
450
          todoFiltersItem.disabled = true;
451
452
453
          todoFiltersItem.classList.add("is-greyed-out");
          todoFiltersItem.innerHTML += " <span class=\"tag is-rounded\">0</span>";
        }
454
455
456
      // autocomplete container
      } else {
        // add filter to input
ransome1's avatar
ransome1 committed
457
458
        todoFiltersItem.addEventListener("click", (event) => {
          if(autoCompletePrefix && autoCompleteValue) {
459
            // remove composed filter first, then add selected filter
460
            document.getElementById("modalFormInput").value = document.getElementById("modalFormInput").value.replace(autoCompletePrefix + autoCompleteValue, autoCompletePrefix + todoFiltersItem.getAttribute("data-filter") + " ");
ransome1's avatar
ransome1 committed
461
          } else if(autoCompletePrefix) {
462
            // add button data value to the exact caret position
463
            document.getElementById("modalFormInput").value = [document.getElementById("modalFormInput").value.slice(0, caretPosition), todoFiltersItem.getAttribute('data-filter'), document.getElementById("modalFormInput").value.slice(caretPosition)].join('') + " ";
464
          }
ransome1's avatar
ransome1 committed
465
466
467
          // empty autoCompleteValue to prevent multiple inputs using multiple Enter presses
          autoCompleteValue = null;
          autoCompletePrefix = null;
468
469
470
471
          // hide the suggestion container after the filter has been selected
          autoCompleteContainer.blur();
          autoCompleteContainer.classList.remove("is-active");
          // put focus back into input so user can continue writing
472
          document.getElementById("modalFormInput").focus();
473
474
475
476
          // trigger matomo event
          if(userData.matomoEvents) _paq.push(["trackEvent", "Suggestion-box", "Click on filter tag", category]);
        });
      }
ransome1's avatar
ransome1 committed
477
      filterCounter++;
478
479
480
481
482
483
484
485
      todoFiltersContainer.appendChild(todoFiltersItem);
    }
    return Promise.resolve(todoFiltersContainer);
  } catch (error) {
    error.functionName = generateFilterButtons.name;
    return Promise.reject(error);
  }
}
486

ransome1's avatar
ransome1 committed
487
export { filterItems, generateFilterData, selectFilter, categories, filterCounter };