Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add more advanced series filtering mechanism #485

Open
CollinHeist opened this issue Nov 6, 2024 · 2 comments
Open

Add more advanced series filtering mechanism #485

CollinHeist opened this issue Nov 6, 2024 · 2 comments
Assignees
Labels
enhancement New feature or request needs-merged Issue has been resolved on a development branch web-ui Part of the v2.0 Web Interface

Comments

@CollinHeist
Copy link
Owner

CollinHeist commented Nov 6, 2024

Allow more complex filters based on all available Series fields.

Field Name Field Attribute Data Type
ID id Numeric
Name name String
Year year Numeric
Monitored monitored Boolean
List of Libraries N/A List
Episode Data Source ID data_source_id Nullable Numeric
Font ID font_id Nullable Numeric
Sync ID sync_id Nullable Numeric
Card Directory directory Nullable String
Card Filename Format card_filename_format Nullable String
Enable Specials sync_specials Nullable Boolean
Skip Localized Images skip_localized_images Nullable Boolean
Has Missing Title Cards N/A Boolean

The filter conditions should probably be based on the data type of the attribute, so:

  • Numeric
    • is less than (input)
    • is less than or equal to (input)
    • equals (input)
    • is greater than (input)
    • is greater than or equal to (input)
  • String
    • equals (input)
    • does not Equal (input)
    • contains (input)
    • does not contain (input)
    • starts with (input)
    • does not start with (input)
    • ends with (input)
    • does not end with (input)
    • matches (input)
    • does not match (input)
  • Boolean
    • is true
    • is false
  • Nullable Boolean
    • is true
    • is false
    • is null
    • is not null
@CollinHeist CollinHeist converted this from a draft issue Nov 6, 2024
@CollinHeist
Copy link
Owner Author

Working Implementation

<template id="filter-template">
  <div class="three fields">
    <div class="field" data-label="field">
      <label>Field</label>
      <div class="ui selection dropdown">
        <input type="hidden" name="field" onchange="updateConditions(this);">
        <i class="dropdown icon"></i>
        <div class="default text">Field Name</div>
        <div class="menu"></div>
      </div>
    </div>

    <div class="field" data-label="condition">
      <label>Condition</label>
      <div class="ui selection dropdown">
        <input type="hidden" name="condition">
        <i class="dropdown icon"></i>
        <div class="default text">Condition Type</div>
        <div class="menu"></div>
      </div>
    </div>

    <div class="disabled field" data-label="reference">
      <label>Reference</label>
      <input type="text" name="reference" placeholder="Reference Value">
    </div>
  </div>
</template>

<div class="ui modal">
  <div class="header">Filter Settings</div>
  <div class="content">
    <form class="ui form">
      <div class="ui field">
        <label>Filter Name</label>
        <input type="text" placeholder="Name" value="My Filter">
      </div>
      <div class="ui divider"></div>
      <!-- Conditions added later -->
    </form>
    <div class="ui labeled icon button" onclick="addFilterCondition();">
      <i class="filter icon"></i>
      Add Filter
    </div>
  </div>
  <div class="actions">
    <button class="ui button" onclick="serializeForm();">
      Apply Filter
    </button>
  </div>
</div>
// Populate filters
const filterSettings = [
  // name                     value             type
  ['Series Name',             'name',           'string'          ],
  ['Series Year',             'year',           'numeric'         ],
  ['Monitored Status',        'monitored',      'boolean'         ],
  ['Series ID',               'id',             'numeric'         ],
  ['Sync ID',                 'sync_id',        'nullable numeric'],
  ['Font ID',                 'font_id',        'nullable numeric'],
  ['Card Directory',          'card_directory', 'nullable string' ],
  ['List of Libraries',       'libraries',      'list'            ],
  ['Has Missing Title Cards', 'missing_cards',  'boolean',        ],
].map(setting => {
  return { name: setting[0], value: setting[1], type: setting[2] };
});

const filterChoices = {
  'string': [
    {name: 'equals',              requiresInput: true},
    {name: 'does not equal',      requiresInput: true},
    {name: 'contains',            requiresInput: true},
    {name: 'does not contain',    requiresInput: true},
    {name: 'starts with',         requiresInput: true},
    {name: 'does not start with', requiresInput: true},
    {name: 'ends with',           requiresInput: true},
    {name: 'does not end with',   requiresInput: true},
    {name: 'matches',             requiresInput: true},
    {name: 'does not match',      requiresInput: true},
  ],
  'nullable string': [
    {name: 'equals',              requiresInput: true },
    {name: 'does not equal',      requiresInput: true },
    {name: 'contains',            requiresInput: true },
    {name: 'does not contain',    requiresInput: true },
    {name: 'starts with',         requiresInput: true },
    {name: 'does not start with', requiresInput: true },
    {name: 'ends with',           requiresInput: true },
    {name: 'does not end with',   requiresInput: true },
    {name: 'matches',             requiresInput: true },
    {name: 'does not match',      requiresInput: true },
    {name: 'is null',             requiresInput: false},
    {name: 'is not null',         requiresInput: false},
  ],
  'numeric': [
    {name: 'is less than',                requiresInput: true},
    {name: 'is less than or equal to',    requiresInput: true},
    {name: 'equals',                      requiresInput: true},
    {name: 'is greater than',             requiresInput: true},
    {name: 'is greater than or equal to', requiresInput: true},
  ],
  'nullable numeric': [
    {name: 'is less than',                requiresInput: true },
    {name: 'is less than or equal to',    requiresInput: true },
    {name: 'equals',                      requiresInput: true },
    {name: 'is greater than',             requiresInput: true },
    {name: 'is greater than or equal to', requiresInput: true },
    {name: 'is null',                     requiresInput: false},
    {name: 'is not null',                 requiresInput: false},
  ],
  'boolean': [
    {name: 'is true',  requiresInput: false},
    {name: 'is false', requiresInput: false},
  ],
  'nullable boolean': [
    {name: 'is true',     requiresInput: false},
    {name: 'is false',    requiresInput: false},
    {name: 'is null',     requiresInput: false},
    {name: 'is not null', requiresInput: false},
  ],
  'list': [
    {name: 'is empty',         requiresInput: false},
    {name: 'is not empty',     requiresInput: false},
    {name: 'includes',         requiresInput: true },
    {name: 'does not include', requiresInput: true },
  ],
};

function updateConditions(obj) {
  // Get the type of the newly selected filter field
  // const fieldType = filterSettings.filter(fltr => fltr.value === obj.value)[0].type;
  const fieldType = filterSettings.find(filter => filter.value === obj.value).type;

  // Initialize associated dropdown with condition choices for this type
  $(obj).closest('.fields').find('[data-label="condition"] .dropdown').dropdown({
    onChange: (condition, text, $selectedItem) => {
      // Enable/disable the dropdown based on the selected condition
      const requiresInput = filterChoices[fieldType].find(choice => choice.name === condition).requiresInput;
      $($selectedItem).closest('.fields').find('.field[data-label="reference"]').toggleClass('disabled', !requiresInput);
    },
    values: filterChoices[fieldType].map(choice => {
      return {
        name: choice.name,
        value: choice.name,
        selected: false,
      };
    }),
  });

  // Disable and clear reference field until condition is selected
  const referenceField = $(obj).closest('.fields').find('.field[data-label="reference"]');
  referenceField.toggleClass('disabled', true);
  referenceField.find('input').val('');
}

const template = document.getElementById('filter-template').content;
filterSettings.forEach(filter => {
  const item = document.createElement('div');
  item.className = 'item'; item.dataset.value = filter.value; item.innerText = filter.name;
  template.querySelector('.dropdown .menu').appendChild(item);
});

function addFilterCondition() {
  document.querySelector('.modal .form').appendChild(template.cloneNode(true));
  $('.dropdown').dropdown();
}

$('.modal').modal({blurring: true}).modal('show');

// Function to serialize form inputs into a list of objects
function serializeForm() {
  const data = [];

  // Loop through each group of fields and gather inputs
  document.querySelectorAll('.modal .fields').forEach((fieldDiv) => {
    // Get the values of 'name', 'condition', and 'reference' inputs
    const nameInput = fieldDiv.querySelector('input[name="field"]');
    const conditionInput = fieldDiv.querySelector('input[name="condition"]');
    const referenceInput = fieldDiv.querySelector('input[name="reference"]');

    const requiresInput = !fieldDiv.querySelector('.field[data-label="reference"]').className.includes('disabled');

    // Ensure all inputs exist and retrieve their values
    if (nameInput && conditionInput && referenceInput) {
      const  name = nameInput.value,
        condition = conditionInput.value,
        reference = referenceInput.value || null;

      if (name && condition && (reference || !requiresInput)) {
        data.push({
          name: name,
          condition: condition,
          reference: reference
        });
      }
    }
  });

  console.log(data);
  return data;
}

@CollinHeist
Copy link
Owner Author

CollinHeist commented Nov 7, 2024

More Progress..

HTML Templates

<template id="blank-tab-template">
  <div class="ui bottom attached tab segment">
    <form class="ui form">
      <div class="ui field">
        <label>Filter Name</label>
        <input type="text" name="filter_name" placeholder="Name" value="New Filter" oninput="updateTitle(this);">
      </div>
      <div class="ui divider"></div>
      <!-- Conditions added later -->
    </form>

    <div class="ui labeled icon button" onclick="addFilterCondition(this);">
      <i class="plus circle icon"></i>
      Add Condition
    </div>

    <div class="ui right floated red icon button" onclick="deleteFilter(this);">
      <i class="trash alternate outline icon"></i>
      Delete Filter
    </div>
  </div>
</template>

<template id="filter-template">
  <div class="three fields">
    <div class="field" data-label="field">
      <label>Field</label>
      <div class="ui selection clearable dropdown">
        <input type="hidden" name="field" onchange="updateConditions(this);">
        <i class="dropdown icon"></i>
        <div class="default text">Field Name</div>
        <div class="menu"></div>
      </div>
    </div>

    <div class="field" data-label="condition">
      <label>Condition</label>
      <div class="ui selection dropdown">
        <input type="hidden" name="condition">
        <i class="dropdown icon"></i>
        <div class="default text">Condition Type</div>
        <div class="menu"></div>
      </div>
    </div>

    <div class="disabled field" data-label="reference">
      <label>Reference</label>
      <input type="text" name="reference" placeholder="Reference Value">
    </div>
  </div>
</template>

HTML Modal

<div class="ui modal">
  <div class="header">Filter Settings</div>
  <div class="content">
    <div class="ui top attached tabular wrapping menu">
      <!-- Tab selector added later -->
      <div class="item add-tab" data-tab="add" onclick="addTab();"><i class="plus circle blue icon"></i></div>
    </div>
    <!-- Tabs added later -->
  </div>

  <div class="actions">
    <button class="ui blue button" onclick="serializeAllFilters();">
      <i class="check icon"></i>
      Apply Filter
    </button>
  </div>
</div>

JavaScript

<script type="text/javascript">
  /**
   * "Live" update the title of the tab containing this filter name input.
   **/
  function updateTitle(inputElement) {
    const tabName = inputElement.closest('.tab.segment').dataset.tab;
    document.querySelector(`.modal .tabular.menu .item[data-tab="${tabName}"]`).innerText = inputElement.value;
  }

  // Add a new tab to the filter modal
  function addTab(filter=null) {
    // Determine tab number - check for existence in case a middle tab was deleted
    let tabNumber = document.querySelectorAll('.modal .tabular.menu .item').length - 1;
    while (document.querySelector(`.modal .tabular.menu .item[data-tab="tab${tabNumber}"]`)) {
      tabNumber += 1;
    }

    // Add new tab selector to menu, just before add tab item
    const $tabHeader = $('<div>', {
      class: 'item',
      'data-tab': 'tab' + tabNumber,
      text: filter?.name || 'New Filter',
    });
    $('.modal .tabular.menu .item.add-tab').before($tabHeader);

    // Add blank tab
    const newTab = document.getElementById('blank-tab-template').content.cloneNode(true);
    newTab.querySelector('.tab').dataset.tab = 'tab' + tabNumber;
    if (filter) {
      newTab.querySelector('input[name="filter_name"]').value = filter.name;
    }
    document.querySelector('.modal .content').appendChild(newTab);
    $('.modal .tabular.menu .item').tab();

    const tabs = document.querySelectorAll('.modal .content .tab.segment');
    return tabs[tabs.length - 1];
  }

  function deleteFilter(deleteButton) {
    const tabID = deleteButton.closest('.tab.segment').dataset.tab;
    document.querySelectorAll(`.modal [data-tab="${tabID}"]`).forEach(tab => tab.remove());
    $('.modal .tabular.menu .item').tab('change tab', 'tab0');
  }
</script>

<script type="text/javascript">
  const filterSettings = [
    // name                       value                    type
    ['Card Directory',            'directory',              'nullable string' ],
    ['Series Name',               'name',                   'string'          ],
    ['Series Year',               'year',                   'numeric'         ],
    ['Monitored Status',          'monitored',              'boolean'         ],
    ['Series ID',                 'id',                     'numeric'         ],
    ['Sync ID',                   'sync_id',                'nullable numeric'],
    ['Font ID',                   'font_id',                'nullable numeric'],
    ['Episode Data Source ID',    'data_source_id',         'nullable numeric'],
    ['List of Libraries',         'libraries',              'list'            ],
    ['Has Missing Title Cards',   'missing_cards',          'boolean',        ],
    ['Card Filename Format',      'card_filename_format',   'nullable string' ],
    ['Enable Specials',           'sync_specials',          'nullable boolean'],
    ['Localized Image Rejection', 'skip_localized_images',  'nullable boolean'],
    ['List of Translations',      'translations',           'list'            ],
    ['Match Titles',              'match_titles',           'boolean'         ],
    ['Auto-Split Titles',         'auto_split_titles',      'boolean'         ],
    ['Per-Season Assets',         'use_per_season_assets',  'boolean'         ],
    ['Image Source Priority',     'image_source_priority',  'list'            ],
    ['Emby Database ID',          'emby_id',                'nullable string' ],
    ['IMDb Database ID',          'imdb_id',                'nullable string' ],
    ['Jellyfin Database ID',      'jellyfin_id',            'nullable string' ],
    ['Sonarr Database ID',        'sonarr_id',              'nullable string' ],
    ['TMDb Database ID',          'tmdb_id',                'nullable numeric'],
    ['TVDb Database ID',          'tvdb_id',                'nullable numeric'],
    ['TVRage Database ID',        'tvrage_id',              'nullable string' ],
    ['Font Color',                'font_color',             'nullable string' ],
    ['Font Title Case',           'font_title_case',        'nullable string' ],
    ['Font Size',                 'font_size',              'nullable numeric'],
    ['Font Kerning',              'font_kerning',           'nullable numeric'],
    ['Font Stroke Width',         'font_stroke_width',      'nullable numeric'],
    ['Font Interline Spacing',    'font_interline_spacing', 'nullable numeric'],
    ['Font Interword Spacing',    'font_interword_spacing', 'nullable numeric'],
    ['Font Vertical Shift',       'font_vertical_shift',    'nullable numeric'],
    ['Card Type',                 'card_type',              'nullable string' ],
    ['Hide Season Text',          'hide_season_text',       'nullable boolean'],
    ['Season Title List',         'season_titles',          'nullable list'   ],
    ['Hide Episode Text',         'hide_episode_text',      'nullable boolean'],
    ['Episode Text Format',       'episode_text_format',    'nullable string' ],
    ['Unwatched Card Style',      'unwatched_style',        'nullable string' ],
    ['Watched Card Style',        'watched_style',          'nullable string' ],
    ['Extras',                    'extras',                 'nullable list'   ],
  ].map(setting => {
    return { name: setting[0], value: setting[1], type: setting[2] };
  });

  const filterChoices = {
    'string': [
      {name: 'equals',              requiresInput: true},
      {name: 'does not equal',      requiresInput: true},
      {name: 'contains',            requiresInput: true},
      {name: 'does not contain',    requiresInput: true},
      {name: 'starts with',         requiresInput: true},
      {name: 'does not start with', requiresInput: true},
      {name: 'ends with',           requiresInput: true},
      {name: 'does not end with',   requiresInput: true},
      {name: 'matches',             requiresInput: true},
      {name: 'does not match',      requiresInput: true},
    ],
    'nullable string': [
      {name: 'equals',              requiresInput: true },
      {name: 'does not equal',      requiresInput: true },
      {name: 'contains',            requiresInput: true },
      {name: 'does not contain',    requiresInput: true },
      {name: 'starts with',         requiresInput: true },
      {name: 'does not start with', requiresInput: true },
      {name: 'ends with',           requiresInput: true },
      {name: 'does not end with',   requiresInput: true },
      {name: 'matches',             requiresInput: true },
      {name: 'does not match',      requiresInput: true },
      {name: 'is null',             requiresInput: false},
      {name: 'is not null',         requiresInput: false},
    ],
    'numeric': [
      {name: 'is less than',                requiresInput: true},
      {name: 'is less than or equal to',    requiresInput: true},
      {name: 'equals',                      requiresInput: true},
      {name: 'is greater than',             requiresInput: true},
      {name: 'is greater than or equal to', requiresInput: true},
    ],
    'nullable numeric': [
      {name: 'is less than',                requiresInput: true },
      {name: 'is less than or equal to',    requiresInput: true },
      {name: 'equals',                      requiresInput: true },
      {name: 'is greater than',             requiresInput: true },
      {name: 'is greater than or equal to', requiresInput: true },
      {name: 'is null',                     requiresInput: false},
      {name: 'is not null',                 requiresInput: false},
    ],
    'boolean': [
      {name: 'is true',  requiresInput: false},
      {name: 'is false', requiresInput: false},
    ],
    'nullable boolean': [
      {name: 'is true',     requiresInput: false},
      {name: 'is false',    requiresInput: false},
      {name: 'is null',     requiresInput: false},
      {name: 'is not null', requiresInput: false},
    ],
    'list': [
      {name: 'is empty',         requiresInput: false},
      {name: 'is not empty',     requiresInput: false},
      {name: 'includes',         requiresInput: true },
      {name: 'does not include', requiresInput: true },
    ],
    'nullable list': [
      {name: 'is empty',         requiresInput: false},
      {name: 'is not empty',     requiresInput: false},
      {name: 'includes',         requiresInput: true },
      {name: 'does not include', requiresInput: true },
      {name: 'is null',          requiresInput: false},
      {name: 'is not null',      requiresInput: false},
    ],
  };


  function updateConditions(obj) {
    // Disable and clear reference field until condition is selected
    const referenceField = $(obj).closest('.fields').find('.field[data-label="reference"]');
    referenceField.toggleClass('disabled', true);
    referenceField.find('input').val('');

    // No value means the field was cleared
    if (!obj.value) {
      // Clear condition dropdown
      $(obj).closest('.fields').find('[data-label="condition"] .ui.dropdown').dropdown({
        values: []
      });
      return;
    }

    // Get the type of the newly selected filter field
    const fieldType = filterSettings.find(filter => filter.value === obj.value).type;

    // Initialize associated dropdown with condition choices for this type
    $(obj).closest('.fields').find('[data-label="condition"] .ui.dropdown').dropdown({
      onChange: (condition, text, $selectedItem) => {
        // Enable/disable the dropdown based on the selected condition
        const requiresInput = filterChoices[fieldType].find(choice => choice.name === condition).requiresInput;
        $($selectedItem).closest('.fields').find('.field[data-label="reference"]').toggleClass('disabled', !requiresInput);
      },
      placeholder: 'Condition Type',
      values: filterChoices[fieldType].map(choice => {
        return {
          name: choice.name,
          value: choice.name,
          selected: false,
        };
      }),
    });
  }

  const template = document.getElementById('filter-template').content;
  filterSettings.forEach(filter => {
    const item = document.createElement('div');
    item.className = 'item'; item.dataset.value = filter.value; item.innerText = filter.name;
    template.querySelector('.dropdown .menu').appendChild(item);
  });

  function addFilterCondition(addButton, removeLabels=true) {
    const newFields = document.getElementById('filter-template').content.cloneNode(true);
    if (removeLabels) {
      newFields.querySelectorAll('.field label').forEach(label => label.remove());
    }

    // Add to page, initialize dropdowns
    addButton.closest('.tab.segment').querySelector('.form').appendChild(newFields);
    // document.querySelector('.modal .form').appendChild(newFields);
    $('.modal .form .dropdown').dropdown();
  }

  $('.modal').modal({blurring: true}).modal('show');
</script>

<script type="text/javascript">
  // Function to serialize form inputs into a list of objects
  function serializeAllFilters() {
    const filters = [];
    let activeTab = null;

    // Serialize each tab
    document.querySelectorAll('.modal .content .tab.segment').forEach((tab, index) => {
      const data = {
        name: tab.querySelector('input[name="filter_name"]').value,
        conditions: [],
      };

      // Update active tab number if needed
      if (activeTab === null && tab.classList.contains('active')) {
        activeTab = index;
      }

      // Loop through each group of fields and gather inputs
      tab.querySelectorAll('.fields').forEach((fieldDiv) => {
        // Get the values of all input fields
        const fieldInput = fieldDiv.querySelector('input[name="field"]');
        const conditionInput = fieldDiv.querySelector('input[name="condition"]');
        const referenceInput = fieldDiv.querySelector('input[name="reference"]');

        // An input is required if the reference field is not disabled
        const requiresInput = !fieldDiv.querySelector('.field[data-label="reference"]').className.includes('disabled');

        // Ensure all inputs exist and retrieve their values
        if (fieldInput && conditionInput && referenceInput) {
          const field = fieldInput.value,
            condition = conditionInput.value,
            reference = referenceInput.value || null;

          if (field && condition && (reference || !requiresInput)) {
            data.conditions.push({ field, condition, reference, });
          }
        }
      });

      filters.push(data);
    });
    console.log({ filters, activeTab });
    return { filters, activeTab };
  }
</script>

<script type="text/javascript">
  // Eventually will be passed via Jinja or AJAX call
  const existingFilters = [
    {
      name: 'Missing Cards (no Star)',
      conditions: [
        { name: "name",          condition: "does not contain", reference: "Star" },
        { name: "missing_cards", condition: "is true",          reference: null   },
      ],
    },
    {
      name: 'Unmonitored and Missing Cards',
      conditions: [
        { name: "monitored",     condition: "is false", reference: null },
        { name: "missing_cards", condition: "is true",  reference: null },
      ]
    }
  ];

  existingFilters.forEach(filter => {
    // Add blank tab for this filter
    const tab = addTab(filter);

    // Add each condition
    filter.conditions.forEach((condition, index) => {
      // Add new filter row for this condition
      const newFields = document.getElementById('filter-template').content.cloneNode(true);

      // Remove labels for all but the first condition
      if (index > 0) {
        newFields.querySelectorAll('.field label').forEach(label => label.remove());
      }

      // Add to page so dropdowns can be populated
      tab.querySelector('.form').appendChild(newFields);

      // Populate fields
      $(tab).find('.field[data-label="field"] div.dropdown').last().dropdown('set selected', condition.name);
      $(tab).find('.field[data-label="condition"] div.dropdown').last().dropdown('set selected', condition.condition);
      if (condition.reference !== null) {
        $(tab).find('.field[data-label="reference"] input').last().val(condition.reference);
      }
    });
  });
</script>

@CollinHeist CollinHeist self-assigned this Nov 15, 2024
@CollinHeist CollinHeist added enhancement New feature or request needs-merged Issue has been resolved on a development branch web-ui Part of the v2.0 Web Interface labels Nov 15, 2024
CollinHeist added a commit that referenced this issue Nov 15, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request needs-merged Issue has been resolved on a development branch web-ui Part of the v2.0 Web Interface
Projects
Status: In Progress
Development

No branches or pull requests

1 participant