filters.mjs 18.4 KB
Newer Older
1
"use strict";
2
import { userData, handleError, translations, setUserData, startBuilding, _paq } from "../render.js";
3
import { items, generateGroups, generateTable } from "./todos.mjs";
4
import { isToday, isPast, isFuture } from "./date.mjs";
5

6
//const modalFormInput = document.getElementById("modalFormInput");
7
8
9
const todoTableSearch = document.getElementById("todoTableSearch");
const autoCompleteContainer = document.getElementById("autoCompleteContainer");
const todoFilters = document.getElementById("todoFilters");
10
11
12
13
const filterMenu = document.getElementById("filterMenu");
const filterMenuInput = document.getElementById("filterMenuInput");
const filterMenuSave = document.getElementById("filterMenuSave");
const filterMenuDelete = document.getElementById("filterMenuDelete");
14
15
16
17
18
19
20
21
22

let categories,
    filtersCounted,
    filtersCountedReduced,
    selectedFilters,
    container,
    headline;

filterMenuSave.innerHTML = translations.save;
23
filterMenuDelete.innerHTML = translations.delete;
24

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function saveFilter(newFilter, oldFilter, category) {
  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]);
}
function deleteFilter(filter, category) {
  items.objects.forEach((item) => {
42
    if(category!=="priority" && item[category]) {
43
44
45
      const index = item[category].indexOf(filter);
      if(index!==-1) item[category].splice(index, 1);
      if(item[category].length===0) item[category] = null;
46
47
    } else if(category==="priority" && item[category]===filter) {
      item[category] = null;
48
49
50
51
52
53
54
55
    }
  });
  // 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]);
}
56
57
58
59
function filterItems(items, searchString) {
  try {
    // selected filters are empty, unless they were persisted
    if(userData.selectedFilters && userData.selectedFilters.length>0) {
60
      selectedFilters = JSON.parse(userData.selectedFilters);
61
    } else {
62
      selectedFilters = new Array;
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
      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]);
            }
          });
        }
      });
    }
    // apply filters or filter by search string
    items = items.filter(function(item) {
      if(todoTableSearch.value) searchString = todoTableSearch.value;
      if((searchString || todoTableSearch.value) && item.toString().toLowerCase().indexOf(searchString.toLowerCase()) === -1) return false;
      if(!userData.showCompleted && item.complete) return false;
93
94
95
      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;
96
97
98
99
100
101
102
103
104
105
106
107
108
      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 {
    // select the container (filter drawer or autocomplete) in which filters will be shown
    if(autoCompleteCategory) {
109
      container = autoCompleteContainer;
110
111
112
113
114
115
116
117
118
119
      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
120
      container = todoFilters;
121
122
123
124
125
126
127
128
      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();
      // run the array and collect all possible filters, duplicates included
129
      items.objects.forEach((item) => {
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
        // 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
        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;
        }
      }, {});
      // 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(","))];
      // filter persisted filters
175
      /*if(userData.selectedFilters && userData.selectedFilters.length>0) {
176
177
        selectedFilters = JSON.parse(userData.selectedFilters);
        // check if selected filters is still part of all available filters
178
        selectedFilters.forEach(function(selectedFilter,index){
179
180
181
182
183
184
185
186
187
188
          if(selectedFilter[1]==category) {
            // category found, but the selected filter is not part of available filters
            if(!filters.includes(selectedFilter[0])) {
              // delete persisted filters
              selectedFilters.splice(index, 1);
              // persist the change
              setUserData("selectedFilters", JSON.stringify(selectedFilters));
            }
          }
        });
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
      }*/

      // TODO: basically a duplicate
      // 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;
        }
      }, {});

228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
      // build the filter buttons
      if(filters[0]!="" && filters.length>0) {
        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
252
  if(selectedFilters.length > 0) {
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
    // 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 {
    selectedFilters = new Array;

274
    if(userData.selectedFilters && userData.selectedFilters.length>0) selectedFilters = JSON.parse(userData.selectedFilters);
275
276
277
278
279
    // creates a div for the specific filter section
    let todoFiltersContainer = document.createElement("div");
    todoFiltersContainer.setAttribute("class", "dropdown-item " + category);
    // translate headline
    if(category=="contexts") {
280
      headline = translations.contexts;
281
    } else if(category=="projects"){
282
      headline = translations.projects;
283
    } else if(category=="priority"){
284
      headline = translations.priority;
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
    }
    if(autoCompletePrefix===undefined) {
      // create a sub headline element
      let todoFilterHeadline = document.createElement("h4");
      todoFilterHeadline.setAttribute("class", "is-4 title");
      todoFilterHeadline.innerHTML = "<i class=\"far fa-eye-slash\" tabindex=\"-1\"></i>&nbsp;" + headline;
      // add click event
      todoFilterHeadline.onclick = function() {
        let hideFilterCategories = userData.hideFilterCategories;
        if(hideFilterCategories.includes(category)) {
          hideFilterCategories.splice(hideFilterCategories.indexOf(category),1)
          this.parentElement.classList.remove("is-greyed-out");
          this.innerHTML = "<i class=\"far fa-eye-slash\" tabindex=\"-1\"></i>&nbsp;" + headline;
        } else {
          hideFilterCategories.push(category);
          this.parentElement.classList.add("is-greyed-out");
          this.innerHTML = "<i class=\"far fa-eye\" tabindex=\"-1\"></i>&nbsp;" + headline;
          hideFilterCategories = [...new Set(hideFilterCategories.join(",").split(","))];
        }
        setUserData("hideFilterCategories", hideFilterCategories)
        generateGroups(items.filtered).then(function(groups) {
          generateTable(groups);
        });
      }
      // add the headline before category container
      todoFiltersContainer.appendChild(todoFilterHeadline);
    } else {
      // show suggestion box
      autoCompleteContainer.classList.add("is-active");
      autoCompleteContainer.focus();
      // create a sub headline element
      let todoFilterHeadline = document.createElement("h4");
      todoFilterHeadline.setAttribute("class", "is-4 title headline " + category);
      // no need for tab index if the headline is in suggestion box
      if(autoCompletePrefix==undefined) todoFilterHeadline.setAttribute("tabindex", -1);
      todoFilterHeadline.innerHTML = headline;
      // add the headline before category container
      todoFiltersContainer.appendChild(todoFilterHeadline);
    }
    // build one button each
    for (let filter in filtersCounted) {
      // skip this loop if no filters are present
      if(!filter) continue;
      let todoFiltersItem = document.createElement("a");
      todoFiltersItem.setAttribute("class", "button " + filter);
      todoFiltersItem.setAttribute("data-filter", filter);
      todoFiltersItem.setAttribute("data-category", category);
      if(autoCompletePrefix===undefined) { todoFiltersItem.setAttribute("tabindex", 0) } else { todoFiltersItem.setAttribute("tabindex", 301) }
      todoFiltersItem.setAttribute("href", "#");
      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")
        });
340
        // add context menu
341
342
343
344
345
346
        todoFiltersItem.addEventListener("contextmenu", event => {
          filterMenu.style.left = event.x + "px";
          filterMenu.style.top = event.y + "px";
          filterMenu.classList.add("is-active");
          filterMenuInput.value = filter;
          filterMenuInput.focus();
347
348
349
350
351
352
353
354
355
356
          filterMenuInput.addEventListener("keyup", function(event) {
            if(event.key === "Escape") filterMenu.classList.remove("is-active");
            if(event.key === "Enter") {
              if(filterMenuInput.value!==filter && filterMenuInput.value) {
                saveFilter(filterMenuInput.value, filter, category);
              } else {
                filterMenu.classList.remove("is-active");
              }
            }
          });
357
          filterMenuSave.onclick = function() {
358
            if(filterMenuInput.value!==filter && filterMenuInput.value) {
359
360
361
362
363
364
365
366
              saveFilter(filterMenuInput.value, filter, category);
            } else {
              filterMenu.classList.remove("is-active");
            }
          }
          filterMenuDelete.onclick = function() {
            deleteFilter(filter, category);
          }
367
        });
368
369
370
371
372
373
374
375
376
377
378
379
        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", () => {
            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 {
          todoFiltersItem.classList.add("is-greyed-out");
          todoFiltersItem.innerHTML += " <span class=\"tag is-rounded\">0</span>";
        }
380
381
382
383
384
      // autocomplete container
      } else {
        // add filter to input
        todoFiltersItem.addEventListener("click", () => {
          if(autoCompleteValue) {
385
            // remove composed filter first, then add selected filter
386
            document.getElementById("modalFormInput").value = document.getElementById("modalFormInput").value.slice(0, caretPosition-autoCompleteValue.length-1) + autoCompletePrefix + todoFiltersItem.getAttribute("data-filter") + document.getElementById("modalFormInput").value.slice(caretPosition) + " ";
387
388
          } else {
            // add button data value to the exact caret position
389
            document.getElementById("modalFormInput").value = [document.getElementById("modalFormInput").value.slice(0, caretPosition), todoFiltersItem.getAttribute('data-filter'), document.getElementById("modalFormInput").value.slice(caretPosition)].join('') + " ";
390
391
392
393
394
          }
          // 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
395
          document.getElementById("modalFormInput").focus();
396
397
398
399
400
401
402
403
404
405
406
407
          // trigger matomo event
          if(userData.matomoEvents) _paq.push(["trackEvent", "Suggestion-box", "Click on filter tag", category]);
        });
      }
      todoFiltersContainer.appendChild(todoFiltersItem);
    }
    return Promise.resolve(todoFiltersContainer);
  } catch (error) {
    error.functionName = generateFilterButtons.name;
    return Promise.reject(error);
  }
}
408

409
export { filterItems, generateFilterData, selectFilter, categories };