Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,20 @@
```bash
jq -r '."Google YouTube Data API v3 API Key youtube"' google.json | pbcopy
```

## Column filters

All desktop and mobile column filters use the same case-insensitive syntax:

- `2022*Mentorship` or `2022&Mentorship`: match `2022` followed by `Mentorship`, with any text (or none) between them.
- `2022|Mentorship`: match either phrase.
- `2022 Mentorship|2016 Mentorship|2017 Mentorship`: match any of these exact phrases (ignoring case).
- `2022*Mentorship&Live|2016*Mentorship`: combine any number of operators. AND (`*`, `&`) binds more tightly than OR (`|`).

Spaces are literal, including spaces next to operators; they are not trimmed or treated as AND. Do not wrap queries in quotes. AND terms must occur in order, without overlapping. Empty operands are ignored while typing; an empty or operators-only filter imposes no restriction. Operators are reserved characters, with no escaping or parentheses syntax. Filters on different columns must all match.

### Tests

```bash
node --test tests/filters.test.js
```
29 changes: 29 additions & 0 deletions filters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Shared by every desktop and mobile column filter.
// OR separates alternatives; * and & join literal phrases in occurrence order.
function compileTextFilter(query) {
const alternatives = String(query ?? '').toLowerCase().split('|')
.map(alternative => alternative.split(/[*&]/).filter(term => term !== ''))
.filter(terms => terms.length > 0);

// Ignore empty operands while typing, rather than making an empty OR match all.
if (alternatives.length === 0) return () => true;

return value => {
const text = String(value ?? '').toLowerCase();
return alternatives.some(terms => {
let offset = 0;
return terms.every(term => {
const index = text.indexOf(term, offset);
if (index === -1) return false;
offset = index + term.length;
return true;
});
});
};
}

const FILTER_HELP = 'Case-insensitive literal phrases; spaces are literal. Use * or & for AND in order, | for OR. AND is evaluated before OR.';

if (typeof module !== 'undefined' && module.exports) {
module.exports = { compileTextFilter };
}
16 changes: 9 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
<body class="min-h-screen">
<div id="root"></div>

<script src="./filters.js"></script>
<script type="text/babel" data-presets="react">
const React = window.React || {};
const { useState, useEffect, useMemo, useRef, useCallback } = React;
Expand Down Expand Up @@ -918,7 +919,7 @@
link.click();
};

// --- Sort & Filter (unchanged) ---
// --- Sort & Filter ---
const requestSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') direction = 'desc';
Expand All @@ -936,11 +937,12 @@
let result = [...videos];

Object.keys(appliedFilters).forEach(key => {
const filterVal = appliedFilters[key].toLowerCase();
const filterVal = appliedFilters[key];
if (filterVal) {
const matches = compileTextFilter(filterVal);
result = result.filter(v => {
const cellVal = String(v[key] || (key === 'status' ? 'unwatched' : '')).toLowerCase();
return cellVal.includes(filterVal);
const cellVal = v[key] || (key === 'status' ? 'unwatched' : '');
return matches(cellVal);
});
}
});
Expand Down Expand Up @@ -1089,7 +1091,7 @@
<input
type="text"
placeholder="Filter..."
title={`Filter by ${label}`}
title={`Filter by ${label}. ${FILTER_HELP}`}
value={filters[columnKey]}
onChange={(e) => handleFilterChange(columnKey, e.target.value)}
className={`w-full px-2 py-1 text-[10px] rounded border focus:outline-none focus:border-emerald-500 transition-colors ${inpFilter}`}
Expand Down Expand Up @@ -1560,8 +1562,8 @@ <h3 className="font-bold text-emerald-500" style={{ fontSize: `${Math.round(16 *
{/* Mobile Cards — compact icons instead of large action buttons */}
<div className="block md:hidden p-3 space-y-2">
<div className="pb-1 flex gap-2">
<input type="text" placeholder="Filter Name..." title="Filter by Name" value={filters.name} onChange={e => handleFilterChange('name', e.target.value)} className={`flex-1 min-w-0 px-3 py-2 text-xs rounded border ${inpFilter}`} />
<input type="text" placeholder="Filter Status..." title="Filter by Status" value={filters.status} onChange={e => handleFilterChange('status', e.target.value)} className={`w-28 px-3 py-2 text-xs rounded border ${inpFilter}`} />
<input type="text" placeholder="Filter Name..." title={`Filter by Name. ${FILTER_HELP}`} value={filters.name} onChange={e => handleFilterChange('name', e.target.value)} className={`flex-1 min-w-0 px-3 py-2 text-xs rounded border ${inpFilter}`} />
<input type="text" placeholder="Filter Status..." title={`Filter by Status. ${FILTER_HELP}`} value={filters.status} onChange={e => handleFilterChange('status', e.target.value)} className={`w-28 px-3 py-2 text-xs rounded border ${inpFilter}`} />
</div>
{processedVideos.map((v, i) => {
const status = v.status || 'unwatched';
Expand Down
61 changes: 61 additions & 0 deletions tests/filters.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { compileTextFilter } = require('../filters.js');

test('plain phrases preserve spaces and match case-insensitively', () => {
const matches = compileTextFilter('2022 Mentorship');
assert.equal(matches('My 2022 MENTORSHIP session'), true);
for (const value of ['2022 great Mentorship', '2022 Mentorship', '2022Mentorship']) {
assert.equal(matches(value), false);
}
assert.equal(compileTextFilter(' Mentorship ')('Mentorship'), false);
assert.equal(compileTextFilter(' ')('no-spaces'), false);
});

test('both AND operators require non-overlapping occurrences in order', () => {
for (const operator of ['*', '&']) {
const matches = compileTextFilter(`2022${operator}Mentorship`);
assert.equal(matches('2022 wonderful Mentorship'), true);
assert.equal(matches('2022Mentorship'), true);
for (const value of ['Mentorship 2022', '2022 only', 'Mentorship only']) {
assert.equal(matches(value), false);
}
}
assert.equal(compileTextFilter('a*a')('a'), false);
assert.equal(compileTextFilter('a*a')('aba'), true);
});

test('OR matches any alternative, including complete phrases', () => {
const matches = compileTextFilter('2022 Mentorship|2016 Mentorship|2017 Mentorship');
for (const year of [2022, 2016, 2017]) assert.equal(matches(`${year} Mentorship session`), true);
assert.equal(matches('2020 Mentorship'), false);
assert.equal(matches('2016 amazing Mentorship'), false);
assert.equal(compileTextFilter('2022|Mentorship')('Mentorship alone'), true);
assert.equal(compileTextFilter('2022|Mentorship')('2022 alone'), true);
});

test('arbitrary combinations give ordered AND precedence over OR', () => {
const matches = compileTextFilter('2022*Mentorship&Live|2016&Mentorship|2017*Recorded');
for (const value of ['2022 Mentorship Live', '2016 great Mentorship', '2017 Recorded']) {
assert.equal(matches(value), true);
}
for (const value of ['2022 Mentorship', 'Live 2022 Mentorship', '2016 Recorded']) {
assert.equal(matches(value), false);
}
});

test('empty operands are ignored without turning trailing OR into match-all', () => {
for (const query of ['', '*&||']) assert.equal(compileTextFilter(query)('anything'), true);
for (const query of ['|foo|', 'foo*', '*foo', 'foo&&**']) {
assert.equal(compileTextFilter(query)('foo'), true);
assert.equal(compileTextFilter(query)('bar'), false);
}
});

test('non-operator punctuation stays literal; missing values are safe', () => {
assert.equal(compileTextFilter('[a].+?')('prefix [a].+? suffix'), true);
assert.equal(compileTextFilter('[a].+?')('aaaa'), false);
assert.equal(compileTextFilter('something')(null), false);
assert.equal(compileTextFilter('something')(undefined), false);
assert.equal(compileTextFilter('2022')(2022), true);
});
Loading