"use strict";
import { userData, handleError, translations, setUserData, startBuilding, getConfirmation } from "../render.js";
import { createModalJail } from "../configs/modal.config.mjs";
import { _paq } from "./matomo.mjs";
import { items } from "./todos.mjs";
import { isToday, isPast, isFuture } from "./date.mjs";
import * as filterlang from "./filterlang.mjs";
import { runQuery } from "./filterquery.mjs";
const todoTableSearch = document.getElementById("todoTableSearch");
const autoCompleteContainer = document.getElementById("autoCompleteContainer");
const todoFilters = document.getElementById("todoFilters");
const filterContext = document.getElementById("filterContext");
const filterContextInput = document.getElementById("filterContextInput");
const filterContextSave = document.getElementById("filterContextSave");
const filterContextDelete = document.getElementById("filterContextDelete");
let categories,
filterCounter = 0,
filtersCounted,
filtersCountedReduced,
selectedFilters,
lastFilterQueryString = null,
lastFilterItems = null,
container,
headline;
filterContextSave.innerHTML = translations.save;
filterContextDelete.innerHTML = translations.delete;
filterContextInput.addEventListener("keydown", (event) => {
if(event.code==="Space") event.preventDefault();
})
function saveFilter(newFilter, oldFilter, category) {
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);
}
}
function deleteFilter(filter, category) {
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);
}
}
function filterItems(items) {
try {
// selected filters are empty, unless they were persisted
if(userData.selectedFilters && userData.selectedFilters.length>0) {
selectedFilters = JSON.parse(userData.selectedFilters);
} else {
selectedFilters = new Array;
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 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;
});
});
}
if (todoTableSearch.value) { // assume that this is an advanced search expr
let queryString = todoTableSearch.value;
try {
let query = filterlang.parse(queryString);
if (query.length > 0) {
items = items.filter(function(item) {
return runQuery(item, query);
});
lastFilterQueryString = queryString;
lastFilterItems = items;
todoTableSearch.classList.add("is-valid-query");
todoTableSearch.classList.remove("is-previous-query");
}
} catch(e) {
// oops, that wasn't a syntactically correct search expression
todoTableSearch.classList.remove("is-valid-query");
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;
todoTableSearch.classList.add("is-previous-query");
} 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;
});
todoTableSearch.classList.remove("is-previous-query");
}
}
}
// apply filters
items = items.filter(function(item) {
if(!item.text) return false
if(!userData.showHidden && item.h) return false;
if(!userData.showCompleted && item.complete) return false;
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;
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 {
// reset filter counter
filterCounter = 0;
// select the container (filter drawer or autocomplete) in which filters will be shown
if(autoCompleteCategory) {
container = autoCompleteContainer;
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
container = todoFilters;
container.innerHTML = "";
// needs to be reset every run, because it can be overwritten by previous autocomplete
categories = ["priority", "contexts", "projects"];
}
categories.forEach((category) => {
console.log(category);
// array to collect all the available filters in the data
let filters = new Array();
let filterArray;
// run the array and collect all possible filters, duplicates included
if(userData.showEmptyFilters) {
filterArray = items.objects;
} else {
filterArray = items.filtered;
}
filterArray.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) {
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(","))];
// 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;
}
}, {});
// build the filter buttons
if(filters[0]!="" && filters.length>0) {
// add category length to total filter count
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
if(selectedFilters.length > 0) {
// 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 {
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(autoCompletePrefix===undefined && userData.showEmptyFilters) {
// create a sub headline element
let todoFilterHeadline = document.createElement("h4");
todoFilterHeadline.setAttribute("class", "is-4 clickable");
// setup greyed out state
if(hideFilterCategories.includes(category)) {
todoFilterHeadline.innerHTML = " " + headline;
todoFilterHeadline.classList.add("is-greyed-out");
} else {
todoFilterHeadline.innerHTML = " " + 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);
} else {
let todoFilterHeadline = document.createElement("h4");
// 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
todoFilterHeadline.setAttribute("class", "is-4");
// no need for tab index if the headline is in suggestion box
//if(autoCompletePrefix==undefined)
todoFilterHeadline.innerHTML = headline;
// add the headline before category container
todoFiltersContainer.appendChild(todoFilterHeadline);
}
// to figure out how many buttons with filters behind them have been build in the end
// build one button each
for (let filter in filtersCounted) {
// skip this loop if no filters are present
if(!filter) continue;
let todoFiltersItem = document.createElement("button");
if(category==="priority") todoFiltersItem.classList.add(filter);
todoFiltersItem.setAttribute("data-filter", filter);
todoFiltersItem.setAttribute("data-category", category);
if(autoCompletePrefix===undefined) { todoFiltersItem.setAttribute("tabindex", 0) } else { todoFiltersItem.setAttribute("tabindex", 0) }
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")
});
// add context menu
todoFiltersItem.addEventListener("contextmenu", event => {
// jail the modal
createModalJail(filterContext);
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");
if(event.key === "Enter") {
if(filterContextInput.value!==filter && filterContextInput.value) {
saveFilter(filterContextInput.value, filter, category).then(function(response) {
console.info(response);
}).catch(function(error) {
handleError(error);
});
} else {
filterContext.classList.remove("is-active");
}
}
}
filterContextSave.onclick = function() {
if(filterContextInput.value!==filter && filterContextInput.value) {
saveFilter(filterContextInput.value, filter, category).then(function(response) {
console.info(response);
}).catch(function(error) {
handleError(error);
});
} else {
filterContext.classList.remove("is-active");
}
}
filterContextDelete.onclick = function() {
getConfirmation(deleteFilter, translations.deleteCategoryPrompt, filter, category);
}
});
if(filtersCountedReduced[filter]) {
todoFiltersItem.innerHTML += " " + filtersCountedReduced[filter] + "";
// create the event listener for filter selection by user
todoFiltersItem.addEventListener("click", () => {
document.getElementById("todoTableWrapper").scrollTo(0,0);
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.disabled = true;
todoFiltersItem.classList.add("is-greyed-out");
todoFiltersItem.innerHTML += " 0";
}
// autocomplete container
} else {
// add filter to input
todoFiltersItem.addEventListener("click", (event) => {
if(autoCompletePrefix && autoCompleteValue) {
// remove composed filter first, then add selected filter
document.getElementById("modalFormInput").value = document.getElementById("modalFormInput").value.replace(autoCompletePrefix + autoCompleteValue, autoCompletePrefix + todoFiltersItem.getAttribute("data-filter") + " ");
} else if(autoCompletePrefix) {
// add button data value to the exact caret position
document.getElementById("modalFormInput").value = [document.getElementById("modalFormInput").value.slice(0, caretPosition), todoFiltersItem.getAttribute('data-filter'), document.getElementById("modalFormInput").value.slice(caretPosition)].join('') + " ";
}
// empty autoCompleteValue to prevent multiple inputs using multiple Enter presses
autoCompleteValue = null;
autoCompletePrefix = null;
// 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
document.getElementById("modalFormInput").focus();
// trigger matomo event
if(userData.matomoEvents) _paq.push(["trackEvent", "Suggestion-box", "Click on filter tag", category]);
});
}
filterCounter++;
todoFiltersContainer.appendChild(todoFiltersItem);
}
return Promise.resolve(todoFiltersContainer);
} catch (error) {
error.functionName = generateFilterButtons.name;
return Promise.reject(error);
}
}
export { filterItems, generateFilterData, selectFilter, categories, filterCounter };