Improved our conditional filter

This commit is contained in:
WardPearce
2026-03-07 23:25:07 +13:00
parent a489a2ce25
commit e9cee8d00b
3 changed files with 154 additions and 99 deletions
@@ -52,7 +52,7 @@
function filtersAsJSON() {
return JSON.stringify(
{
version: 'v1',
version: 'v2',
createdFor: 'materialious',
filterBy: contentFilters
},
@@ -98,6 +98,14 @@
<article class="error-container">
<p>{$_('layout.backendEngine.warning')}</p>
<p>
Need Help? Check out our <a
class="link"
href="https://github.com/Materialious/Materialious/blob/main/docs/CONTENT-FILTERS.md"
target="_blank"
referrerpolicy="no-referrer">guide</a
>.
</p>
</article>
<form onsubmit={loadFilterList}>
@@ -179,9 +187,9 @@
<hr />
{#if filter.conditions}
<ul class="list">
{#each filter.conditions as condition (condition)}
{#each filter.conditions as condition, conditionIndex (condition)}
<li style="display: block;">
<nav class="right-align no-margin">
<nav class="no-margin">
<button
onclick={() => {
filter.conditions = filter.conditions.filter((item) => condition !== item);
@@ -191,6 +199,7 @@
class="surface-container-highest"
>
<i>close</i>
<span>Delete conditional</span>
</button>
</nav>
@@ -234,66 +243,102 @@
<i>arrow_drop_down</i>
</div>
{#if schema[filter.type][condition.field] === 'boolean'}
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.value = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="boolean-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
<option selected={condition.value === 'true'} value="true">true</option>
<option selected={condition.value === 'false'} value="false">false</option>
</select>
<label for="boolean-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else if Array.isArray(schema[filter.type][condition.field])}
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.value = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="array-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
{#each schema[filter.type][condition.field] as value (value)}
<option selected={condition.value === value} {value}
>{camelCaseToHuman(value)}</option
{#each condition.values as conditionValue, index (index)}
<nav>
{#if schema[filter.type][condition.field] === 'boolean'}
<div class="field label suffix surface-container-highest max">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.values[index] = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="boolean-options"
>
{/each}
</select>
<label for="array-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else}
<div class="field label border">
<input
oninput={(event: Event & { currentTarget: HTMLInputElement }) => {
condition.value =
schema[filter.type][condition.field] === 'number'
? Number(event.currentTarget.value)
: event.currentTarget.value;
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
<option selected={conditionValue === 'true'} value="true">true</option>
<option selected={conditionValue === 'false'} value="false">false</option>
</select>
<label for="boolean-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else if Array.isArray(schema[filter.type][condition.field])}
<div class="field label suffix surface-container-highest max">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.values[index] = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="array-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
{#each schema[filter.type][condition.field] as value (value)}
<option selected={conditionValue === value} {value}
>{camelCaseToHuman(value)}</option
>
{/each}
</select>
<label for="array-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else}
<div class="field label surface-container-highest max">
<input
oninput={(event: Event & { currentTarget: HTMLInputElement }) => {
condition.values[index] =
schema[filter.type][condition.field] === 'number'
? Number(event.currentTarget.value)
: event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="value"
type={schema[filter.type][condition.field] === 'string' ? 'text' : 'number'}
value={conditionValue}
/>
<label for="value">{$_('layout.filter.value')}</label>
</div>
{/if}
<button
onclick={() => {
condition.values = condition.values.filter((item) => conditionValue !== item);
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="value"
type={schema[filter.type][condition.field] === 'string' ? 'text' : 'number'}
value={condition.value}
/>
<label for="value">{$_('layout.filter.value')}</label>
</div>
{/if}
class="circle surface-container-highest"
>
<i>close</i>
<div class="tooltip">Delete Value</div>
</button>
</nav>
{#if condition.values.length > 0 && index < condition.values.length - 1}
<h6 class="center-align">OR</h6>
{/if}
{/each}
<div class="space"></div>
<button
onclick={() => {
condition.values.push('');
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
class="surface-container-highest"
>
<i>add</i>
<span>Add OR</span>
</button>
</li>
{#if filter.conditions.length > 0 && conditionIndex < filter.conditions.length - 1}
<h6 class="center-align">AND</h6>
{/if}
{/each}
</ul>
{/if}
@@ -303,8 +348,9 @@
filter.conditions.push({
operator: 'equals',
field: 'author',
value: ''
values: ['']
});
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
class="surface-container-highest"
+44 -39
View File
@@ -23,14 +23,17 @@ const zFilterCondition = z.object({
message: 'Invalid field'
}), // Field to filter
operator: zFilterOperatorEnum, // Operator
value: z.union([
// Value to compare against
z.string(),
z.number(),
z.array(z.string()),
z.array(z.number()),
z.string().regex(/.*/)
])
values: z.array(
z.union([
// Value to compare against
z.string(),
z.number(),
z.array(z.string()),
z.array(z.number()),
z.string().regex(/.*/)
])
),
note: z.string().optional()
});
// Logical grouping operator
@@ -42,7 +45,7 @@ export const zFilterGroup = z.object({
export const zFilterSchema = z.array(zFilterGroup);
const zFilterRootSchema = z.object({
version: z.literal('v1'),
version: z.literal('v2'),
createdFor: z.literal('materialious'),
filterBy: zFilterSchema
});
@@ -66,45 +69,47 @@ export function isItemFiltered(item: FeedItem): boolean {
const fieldValue = item[condition.field as keyof FeedItem];
switch (condition.operator) {
case 'equals':
return fieldValue.toString() === condition.value.toString();
return condition.values.some((conditionValue) => {
switch (condition.operator) {
case 'equals':
return fieldValue.toString() === conditionValue.toString();
case 'in':
return Array.isArray(condition.value) && (condition.value as any[]).includes(fieldValue);
case 'in':
return Array.isArray(conditionValue) && (conditionValue as any[]).includes(fieldValue);
case 'like':
return (
typeof fieldValue === 'string' &&
typeof condition.value === 'string' &&
fieldValue.toLowerCase().includes(condition.value.toLowerCase())
);
case 'like':
return (
typeof fieldValue === 'string' &&
typeof conditionValue === 'string' &&
fieldValue.toLowerCase().includes(conditionValue.toLowerCase())
);
case 'gt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue > condition.value
);
case 'gt':
return (
typeof fieldValue === 'number' &&
typeof conditionValue === 'number' &&
fieldValue > conditionValue
);
case 'lt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue < condition.value
);
case 'lt':
return (
typeof fieldValue === 'number' &&
typeof conditionValue === 'number' &&
fieldValue < conditionValue
);
case 'regex':
if (typeof condition.value !== 'string' || !isSafeRegex(condition.value)) return false;
case 'regex':
if (typeof conditionValue !== 'string' || !isSafeRegex(conditionValue)) return false;
return typeof fieldValue === 'string' && new RegExp(condition.value).test(fieldValue);
return typeof fieldValue === 'string' && new RegExp(conditionValue).test(fieldValue);
default:
return false;
}
default:
return false;
}
});
};
return filterGroup.conditions.every(evaluateCondition);
return filterGroup.conditions.some(evaluateCondition);
});
}
+5 -1
View File
@@ -13,7 +13,11 @@ export const VideoSchema: SchemaStructure = {
type: ['video', 'shortVideo', 'stream'],
videoId: 'string',
viewCount: 'number',
viewCountText: 'string'
viewCountText: 'string',
lengthSeconds: 'number',
premiereTimestamp: 'number',
isUpcoming: 'boolean',
premium: 'boolean'
};
export const ChannelSchema: SchemaStructure = {