filters.mjs 22.6 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 { showContent } from "./content.mjs";
7
import { isToday, isPast, isFuture } from "./date.mjs";
ransome1's avatar
ransome1 committed
8
9
import * as filterlang from "./filterlang.mjs";
import { runQuery } from "./filterquery.mjs";
10

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

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

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

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

36
function saveFilter(newFilter, oldFilter, category) {
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
  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);
  }
58
59
}
function deleteFilter(filter, category) {
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
  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);
  }
82
}
83
function filterItems(items) {
84
85
86
  try {
    // selected filters are empty, unless they were persisted
    if(userData.selectedFilters && userData.selectedFilters.length>0) {
87
      selectedFilters = JSON.parse(userData.selectedFilters);
88
    } else {
89
      selectedFilters = new Array;
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
      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]);
            }
          });
        }
      });
    }
115
116
117
118
119
120
121
122
123
    // 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
124
125
    if (todoTableSearch.value) { // assume that this is an advanced search expr
      let queryString = todoTableSearch.value;
126
      try {
ransome1's avatar
ransome1 committed
127
        let query = filterlang.parse(queryString);
128
129
130
131
        if (query.length > 0) {
          items = items.filter(function(item) {
            return runQuery(item, query);
          });
ransome1's avatar
ransome1 committed
132
133
          lastFilterQueryString = queryString;
          lastFilterItems = items;
zerodat's avatar
zerodat committed
134
135
          todoTableSearch.classList.add("is-valid-query");
          todoTableSearch.classList.remove("is-previous-query");
136
137
        }
      } catch(e) {
ransome1's avatar
ransome1 committed
138
        // oops, that wasn't a syntactically correct search expression
zerodat's avatar
zerodat committed
139
        todoTableSearch.classList.remove("is-valid-query");
ransome1's avatar
ransome1 committed
140
141
142
143
        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
144
          todoTableSearch.classList.add("is-previous-query");
ransome1's avatar
ransome1 committed
145
146
147
148
149
150
151
        } 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
152
          todoTableSearch.classList.remove("is-previous-query");
ransome1's avatar
ransome1 committed
153
        }
154
155
      }
    }
ransome1's avatar
ransome1 committed
156
    // apply filters
157
    items = items.filter(function(item) {
158
      if(!item.text && !item.h) return false;
ransome1's avatar
ransome1 committed
159
      if(!userData.showHidden && item.h) return false;
160
      if(!userData.showCompleted && item.complete) return false;
161
162
163
      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;
164
      if(!userData.deferredTodos && item.t && isFuture(item.t)) return false;
165
166
167
168
169
170
171
172
      return true;
    });
    return Promise.resolve(items);
  } catch(error) {
    error.functionName = filterItems.name;
    return Promise.reject(error);
  }
}
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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229

function generateCategoryContainer(category, autoCompletePrefix, filterFragment) {
  try {
    let hideFilterCategories = userData.hideFilterCategories;
    selectedFilters = new Array;
    if(userData.selectedFilters && userData.selectedFilters.length>0) selectedFilters = JSON.parse(userData.selectedFilters);
    // creates a div for the specific filter section
    let todoFiltersContainer = document.createElement("section");
    todoFiltersContainer.setAttribute("class", category);
    // translate headline
    if(category=="contexts") {
      headline = translations.contexts;
    } else if(category=="projects"){
      headline = translations.projects;
    } else if(category=="priority"){
      headline = translations.priority;
    }
    // if filters are available and if container is not the autocomplete one
    let todoFilterHeadline = document.createElement("h4");
    // show suggestion box when prefix is present
    if(autoCompletePrefix!==undefined) {
      autoCompleteContainer.classList.add("is-active");
      autoCompleteContainer.focus();
      todoFilterHeadline.innerHTML = headline;
    } else {
      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
      todoFilterHeadline.onclick = function() {
        document.getElementById("todoTableWrapper").scrollTo(0,0);
        if(hideFilterCategories.includes(category)) {
          hideFilterCategories.splice(hideFilterCategories.indexOf(category),1)
        } else {
          hideFilterCategories.push(category);
          hideFilterCategories = [...new Set(hideFilterCategories.join(",").split(","))];
        }
        setUserData("hideFilterCategories", hideFilterCategories)
        startBuilding();
      }
    }
    // add the headline before category container
    todoFiltersContainer.appendChild(todoFilterHeadline);

    // add filter fragment
    if(filterFragment.childElementCount > 0) {
      todoFiltersContainer.appendChild(filterFragment);
    // if no filter fragment is available a empty filter container will be shown
    } else {
      let todoFilterHint = document.createElement("div");
      todoFilterHint.setAttribute("class", "todoFilterHint");
230
      todoFilterHint.innerHTML = translations.noFiltersFound + " <a href=\"#\"><i class=\"fas fa-question-circle\"></i></a>";
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
      todoFilterHint.onclick = function() {
        showContent("modalHelp");
        // trigger matomo event
        if(userData.matomoEvents) _paq.push(["trackEvent", "Drawer", "Click on Help"]);
      }
      todoFiltersContainer.appendChild(todoFilterHint);
    }
    // add filter fragment if it is available
    if(filterFragment) {
      todoFiltersContainer.appendChild(filterFragment);
    }
    // return the container
    return Promise.resolve(todoFiltersContainer);
  } catch (error) {
    error.functionName = generateCategoryContainer.name;
    return Promise.reject(error);
  }
}

250
251
function generateFilterData(autoCompleteCategory, autoCompleteValue, autoCompletePrefix, caretPosition) {
  try {
ransome1's avatar
ransome1 committed
252
253
    // reset filter counter
    filterCounter = 0;
254
255
    // select the container (filter drawer or autocomplete) in which filters will be shown
    if(autoCompleteCategory) {
256
      container = autoCompleteContainer;
257
258
259
260
261
262
263
264
265
266
      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
267
      container = todoFilters;
268
269
270
271
      container.innerHTML = "";
      // needs to be reset every run, because it can be overwritten by previous autocomplete
      categories = ["priority", "contexts", "projects"];
    }
272

273
    let todoFiltersContainer;
274

275
    categories.forEach(async (category) => {
276
277
      // array to collect all the available filters in the data
      let filters = new Array();
278
      let filterArray;
279
      // run the array and collect all possible filters, duplicates included
280
      if(userData.showEmptyFilters) {
281
        filterArray = items.objects;
282
      } else {
283
        filterArray = items.filtered;
284
      }
285
      filterArray.forEach((item) => {
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
        // 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
309
        if(filter[1] && (filter[0] in filters)) {
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
          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(","))];
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
      // 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;
        }
361
        if(filters!==null) {
362
363
364
          return filters;
        }
      }, {});
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
      // TODO can this be done above already?
      // remove empty filter entries
      filters = filters.filter(function(filter) {
        //console.log(filter[0]);
        if(filter[0]) return filter;
      });
      // build filter buttons and add them to a fragment
      let filterFragment = await generateFilterButtons(category, autoCompleteValue, autoCompletePrefix, caretPosition).then(response => {
        return response;
      }).catch (error => {
        handleError(error);
      });
      // build and configure the category container and finally append the fragments
      todoFiltersContainer = await generateCategoryContainer(category, autoCompletePrefix, filterFragment).then(response => {
        return response;
      }).catch (error => {
        handleError(error);
      });
      container.appendChild(todoFiltersContainer);
384
385
386
387
388
389
390
391
392
    });
    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
393
  if(selectedFilters.length > 0) {
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
    // 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 {
413
414
    // create a fragment to collect the filters in
    let filterFragment = document.createDocumentFragment();
415
416
417
418
    // 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
419
      let todoFiltersItem = document.createElement("button");
420
      if(category==="priority") todoFiltersItem.classList.add(filter);
421
422
      todoFiltersItem.setAttribute("data-filter", filter);
      todoFiltersItem.setAttribute("data-category", category);
423
      if(autoCompletePrefix===undefined) { todoFiltersItem.setAttribute("tabindex", 0) } else { todoFiltersItem.setAttribute("tabindex", 0) }
424
425
426
427
428
429
      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")
        });
430
        // add context menu
431
        todoFiltersItem.addEventListener("contextmenu", event => {
ransome1's avatar
ransome1 committed
432
433
          // jail the modal
          createModalJail(filterContext);
434
435
436
437
438
439
440
          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");
441
            if(event.key === "Enter") {
442
443
              if(filterContextInput.value!==filter && filterContextInput.value) {
                saveFilter(filterContextInput.value, filter, category).then(function(response) {
444
445
446
447
                  console.info(response);
                }).catch(function(error) {
                  handleError(error);
                });
448
              } else {
449
                filterContext.classList.remove("is-active");
450
451
              }
            }
452
          }
453
454
455
          filterContextSave.onclick = function() {
            if(filterContextInput.value!==filter && filterContextInput.value) {
              saveFilter(filterContextInput.value, filter, category).then(function(response) {
456
457
458
459
                console.info(response);
              }).catch(function(error) {
                handleError(error);
              });
460
            } else {
461
              filterContext.classList.remove("is-active");
462
463
            }
          }
464
          filterContextDelete.onclick = function() {
ransome1's avatar
ransome1 committed
465
            getConfirmation(deleteFilter, translations.deleteCategoryPrompt, filter, category);
466
          }
467
        });
468
469
470
471
        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", () => {
472
            document.getElementById("todoTableWrapper").scrollTo(0,0);
473
474
475
476
477
            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
478
          todoFiltersItem.disabled = true;
479
480
481
          todoFiltersItem.classList.add("is-greyed-out");
          todoFiltersItem.innerHTML += " <span class=\"tag is-rounded\">0</span>";
        }
482
483
484
      // autocomplete container
      } else {
        // add filter to input
ransome1's avatar
ransome1 committed
485
486
        todoFiltersItem.addEventListener("click", (event) => {
          if(autoCompletePrefix && autoCompleteValue) {
487
488
489
490
491
492
493
494
495
496
497
498
            // split string into elements
            let inputElements = document.getElementById("modalFormInput").value.split(" ");
            let i;
            let x = 0;
            for(i = 0; i < inputElements.length; i++) {
              x += inputElements[i].length + 1;
              // once caret position is found inside element the index is persisted
              if(x > caretPosition) break;
            }
            // replace value at index with prefix and data attribute of filter
            inputElements.splice(i, 1, autoCompletePrefix + todoFiltersItem.getAttribute("data-filter"));
            document.getElementById("modalFormInput").value = inputElements.join(" ");
ransome1's avatar
ransome1 committed
499
          } else if(autoCompletePrefix) {
500
            // add button data value to the exact caret position
501
            document.getElementById("modalFormInput").value = [document.getElementById("modalFormInput").value.slice(0, caretPosition), todoFiltersItem.getAttribute('data-filter'), document.getElementById("modalFormInput").value.slice(caretPosition)].join('') + " ";
502
          }
ransome1's avatar
ransome1 committed
503
504
505
          // empty autoCompleteValue to prevent multiple inputs using multiple Enter presses
          autoCompleteValue = null;
          autoCompletePrefix = null;
506
507
508
509
          // 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
510
          document.getElementById("modalFormInput").focus();
511
512
513
514
          // trigger matomo event
          if(userData.matomoEvents) _paq.push(["trackEvent", "Suggestion-box", "Click on filter tag", category]);
        });
      }
ransome1's avatar
ransome1 committed
515
      filterCounter++;
516
      filterFragment.appendChild(todoFiltersItem);
517
    }
518
    return Promise.resolve(filterFragment);
519
520
521
522
523
  } catch (error) {
    error.functionName = generateFilterButtons.name;
    return Promise.reject(error);
  }
}
524

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