mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-17 23:53:58 -06:00
feat(content): server-side search, type filter, and sort
Content discovery was client-side only, scoped to the items already rendered
on the current page — searching "logo" on page 1 couldn't find logos on page
2 or in another folder.
Server (GET /api/content):
- ?q= text search on filename (LIKE, workspace-wide — a search ignores the
open folder so nothing is missed). LIKE metacharacters are escaped so a
filename with % or _ matches literally.
- ?type=video|image|youtube|web — youtube (video/youtube) and web (other
remote_url) are split from plain uploaded video/image so the four UI buckets
map cleanly.
- ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user
input into ORDER BY); default keeps the legacy newest-first ordering.
Frontend (content-library):
- Type filter + sort dropdowns; search debounced (300ms) and now hits the
server instead of filtering the DOM.
- Result count shown while a search/type filter is active.
- en/es i18n.
api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while
searching to match the server's workspace-wide behaviour.
Test: content-search-filter-sort.test.js mounts the real router and covers
substring match, LIKE-escape (literal %), the type buckets, name/size sort,
the ORDER BY injection guard, and combined filters. Suite 541/541.
Closes #214
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7483dfc24
commit
60da126e72
|
|
@ -54,11 +54,19 @@ export const api = {
|
|||
}),
|
||||
|
||||
// Content
|
||||
getContent: (folderId, includeExpired = false) => {
|
||||
const exp = includeExpired ? '&include_expired=1' : '';
|
||||
if (folderId === undefined) return request(`/content${exp ? '?' + exp.slice(1) : ''}`);
|
||||
const q = folderId === null ? 'root' : encodeURIComponent(folderId);
|
||||
return request(`/content?folder_id=${q}${exp}`);
|
||||
getContent: (folderId, includeExpired = false, opts = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
// #214: a text search spans the whole workspace, so folder_id is only sent when
|
||||
// NOT searching (the server also ignores folder_id when q is present, but keeping
|
||||
// the client in sync avoids a misleading URL).
|
||||
const searching = opts.q && opts.q.trim();
|
||||
if (!searching && folderId !== undefined) p.set('folder_id', folderId === null ? 'root' : folderId);
|
||||
if (includeExpired) p.set('include_expired', '1');
|
||||
if (searching) p.set('q', opts.q.trim());
|
||||
if (opts.type && opts.type !== 'all') p.set('type', opts.type);
|
||||
if (opts.sort) p.set('sort', opts.sort);
|
||||
const qs = p.toString();
|
||||
return request(`/content${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getContentItem: (id) => request(`/content/${id}`),
|
||||
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
|
||||
|
|
|
|||
|
|
@ -186,6 +186,16 @@ export default {
|
|||
'content.youtube_add_btn': 'Add YouTube Video',
|
||||
// Search / folders
|
||||
'content.search_placeholder': 'Search content...',
|
||||
'content.filter_type_all': 'All types',
|
||||
'content.filter_type_video': 'Videos',
|
||||
'content.filter_type_image': 'Images',
|
||||
'content.filter_type_youtube': 'YouTube',
|
||||
'content.filter_type_web': 'Web / remote',
|
||||
'content.sort_newest': 'Newest first',
|
||||
'content.sort_oldest': 'Oldest first',
|
||||
'content.sort_name': 'Name A–Z',
|
||||
'content.sort_size': 'Largest first',
|
||||
'content.result_count': '{count} result(s)',
|
||||
'content.new_folder_btn': '+ New Folder',
|
||||
'content.breadcrumb_root': 'All Content',
|
||||
'content.rename_btn': 'Rename',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@ export default {
|
|||
'content.youtube_name_placeholder': 'Nombre para mostrar (opcional)',
|
||||
'content.youtube_add_btn': 'Agregar video de YouTube',
|
||||
'content.search_placeholder': 'Buscar contenido...',
|
||||
'content.filter_type_all': 'Todos los tipos',
|
||||
'content.filter_type_video': 'Videos',
|
||||
'content.filter_type_image': 'Imágenes',
|
||||
'content.filter_type_youtube': 'YouTube',
|
||||
'content.filter_type_web': 'Web / remoto',
|
||||
'content.sort_newest': 'Más recientes primero',
|
||||
'content.sort_oldest': 'Más antiguos primero',
|
||||
'content.sort_name': 'Nombre A–Z',
|
||||
'content.sort_size': 'Más grandes primero',
|
||||
'content.result_count': '{count} resultado(s)',
|
||||
'content.new_folder_btn': '+ Nueva carpeta',
|
||||
'content.breadcrumb_root': 'Todo el contenido',
|
||||
'content.rename_btn': 'Renombrar',
|
||||
|
|
|
|||
|
|
@ -96,7 +96,21 @@ export function render(container) {
|
|||
</div>
|
||||
|
||||
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
|
||||
<input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%">
|
||||
<input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%" value="${esc(state.search)}">
|
||||
<select id="contentTypeFilter" class="input btn-sm" style="width:auto;background:var(--bg-input)">
|
||||
<option value="all" ${state.type === 'all' ? 'selected' : ''}>${t('content.filter_type_all')}</option>
|
||||
<option value="video" ${state.type === 'video' ? 'selected' : ''}>${t('content.filter_type_video')}</option>
|
||||
<option value="image" ${state.type === 'image' ? 'selected' : ''}>${t('content.filter_type_image')}</option>
|
||||
<option value="youtube" ${state.type === 'youtube' ? 'selected' : ''}>${t('content.filter_type_youtube')}</option>
|
||||
<option value="web" ${state.type === 'web' ? 'selected' : ''}>${t('content.filter_type_web')}</option>
|
||||
</select>
|
||||
<select id="contentSort" class="input btn-sm" style="width:auto;background:var(--bg-input)">
|
||||
<option value="date_desc" ${state.sort === 'date_desc' ? 'selected' : ''}>${t('content.sort_newest')}</option>
|
||||
<option value="date_asc" ${state.sort === 'date_asc' ? 'selected' : ''}>${t('content.sort_oldest')}</option>
|
||||
<option value="name" ${state.sort === 'name' ? 'selected' : ''}>${t('content.sort_name')}</option>
|
||||
<option value="size" ${state.sort === 'size' ? 'selected' : ''}>${t('content.sort_size')}</option>
|
||||
</select>
|
||||
<span id="contentResultCount" style="font-size:13px;color:var(--text-muted)"></span>
|
||||
<button class="btn btn-secondary btn-sm" id="newFolderBtn">${t('content.new_folder_btn')}</button>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);cursor:pointer;margin-left:auto">
|
||||
<input type="checkbox" id="showExpiredToggle" ${state.showExpired ? 'checked' : ''}> ${t('content.show_expired')}
|
||||
|
|
@ -174,19 +188,17 @@ export function render(container) {
|
|||
}
|
||||
});
|
||||
|
||||
// Content search filters items currently shown in the grid.
|
||||
function filterContent() {
|
||||
const q = document.getElementById('contentSearch').value.toLowerCase();
|
||||
document.querySelectorAll('.content-item').forEach(item => {
|
||||
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
|
||||
item.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.folder-card').forEach(card => {
|
||||
const name = card.dataset.name?.toLowerCase() || '';
|
||||
card.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
document.getElementById('contentSearch').oninput = filterContent;
|
||||
// #214: search/type/sort now query the server so results span the whole workspace,
|
||||
// not just the items already rendered on the current page. Search is debounced to
|
||||
// avoid a request per keystroke.
|
||||
let searchTimer = null;
|
||||
document.getElementById('contentSearch').oninput = (e) => {
|
||||
clearTimeout(searchTimer);
|
||||
const v = e.target.value;
|
||||
searchTimer = setTimeout(() => { state.search = v.trim(); loadContent(); }, 300);
|
||||
};
|
||||
document.getElementById('contentTypeFilter').onchange = (e) => { state.type = e.target.value; loadContent(); };
|
||||
document.getElementById('contentSort').onchange = (e) => { state.sort = e.target.value; loadContent(); };
|
||||
|
||||
// #157: "Show expired" — reloads the grid including deactivated / past-expiry items so
|
||||
// they can be inspected and restored (clear/extend expiry in the edit modal).
|
||||
|
|
@ -215,6 +227,9 @@ const state = {
|
|||
currentFolderId: null, // null = root
|
||||
folders: [], // all folders for this user (flat tree)
|
||||
showExpired: false, // #157: include is_active=0 / past-expiry items in the library view
|
||||
search: '', // #214: server-side text search (spans the whole workspace)
|
||||
type: 'all', // #214: type filter — all | video | image | youtube | web
|
||||
sort: 'date_desc', // #214: sort order — date_desc | date_asc | name | size
|
||||
};
|
||||
|
||||
async function handleFiles(files) {
|
||||
|
|
@ -250,11 +265,23 @@ async function loadContent() {
|
|||
|
||||
try {
|
||||
const [content, folders] = await Promise.all([
|
||||
api.getContent(state.currentFolderId === null ? null : state.currentFolderId, state.showExpired),
|
||||
api.getContent(state.currentFolderId === null ? null : state.currentFolderId, state.showExpired, {
|
||||
q: state.search, type: state.type, sort: state.sort,
|
||||
}),
|
||||
api.getFolders(),
|
||||
]);
|
||||
state.folders = folders;
|
||||
|
||||
// #214: while a search or type filter is active, results span the whole workspace,
|
||||
// so surface a count and note the folder scope no longer applies.
|
||||
const countEl = document.getElementById('contentResultCount');
|
||||
if (countEl) {
|
||||
const filtering = state.search || (state.type && state.type !== 'all');
|
||||
countEl.textContent = filtering
|
||||
? t('content.result_count', { count: content.length })
|
||||
: '';
|
||||
}
|
||||
|
||||
// Breadcrumb path: walk parent_id chain from current folder up to root.
|
||||
const folderById = new Map(folders.map(f => [f.id, f]));
|
||||
const path = [];
|
||||
|
|
|
|||
|
|
@ -69,7 +69,11 @@ router.get('/', (req, res) => {
|
|||
sql += " AND is_active = 1 AND (expires_at IS NULL OR expires_at > strftime('%s','now'))";
|
||||
}
|
||||
if (folder) { sql += ' AND folder = ?'; params.push(folder); }
|
||||
if (folderId !== undefined) {
|
||||
// #214: a text search (?q=) spans the whole workspace, not just the open folder —
|
||||
// "searching for a logo on page 1 shouldn't miss logos in another folder". When q is
|
||||
// absent we keep the folder-scoped browse behaviour.
|
||||
const q = (req.query.q || '').trim();
|
||||
if (!q && folderId !== undefined) {
|
||||
if (folderId === 'root' || folderId === '') {
|
||||
sql += ' AND folder_id IS NULL';
|
||||
} else {
|
||||
|
|
@ -77,7 +81,31 @@ router.get('/', (req, res) => {
|
|||
params.push(folderId);
|
||||
}
|
||||
}
|
||||
sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?';
|
||||
if (q) {
|
||||
// Leading-wildcard LIKE (no index) — fine for the library's scale. Escape the LIKE
|
||||
// metacharacters so a filename with % or _ is matched literally.
|
||||
const esc = q.replace(/[\\%_]/g, (m) => '\\' + m);
|
||||
sql += " AND filename LIKE ? ESCAPE '\\'";
|
||||
params.push('%' + esc + '%');
|
||||
}
|
||||
// #214: type filter. youtube (video/youtube) and web (any other remote_url) are split
|
||||
// out from plain uploaded video/image so the UI's four buckets map cleanly.
|
||||
switch (req.query.type) {
|
||||
case 'image': sql += " AND mime_type LIKE 'image/%'"; break;
|
||||
case 'video': sql += " AND mime_type LIKE 'video/%' AND mime_type != 'video/youtube'"; break;
|
||||
case 'youtube': sql += " AND mime_type = 'video/youtube'"; break;
|
||||
case 'web': sql += " AND remote_url IS NOT NULL AND mime_type != 'video/youtube'"; break;
|
||||
// default / 'all' / unknown: no type constraint
|
||||
}
|
||||
// #214: whitelisted sort (never interpolate user input into ORDER BY). Default keeps the
|
||||
// legacy newest-first ordering.
|
||||
const SORTS = {
|
||||
date_desc: 'created_at DESC',
|
||||
date_asc: 'created_at ASC',
|
||||
name: 'filename COLLATE NOCASE ASC',
|
||||
size: 'file_size DESC',
|
||||
};
|
||||
sql += ' ORDER BY ' + (SORTS[req.query.sort] || SORTS.date_desc) + ' LIMIT ? OFFSET ?';
|
||||
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
|
||||
const content = db.prepare(sql).all(...params);
|
||||
res.json(content);
|
||||
|
|
|
|||
91
server/test/content-search-filter-sort.test.js
Normal file
91
server/test/content-search-filter-sort.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use strict';
|
||||
|
||||
// #214 server-side search / type filter / sort on GET /api/content. Mounts the real
|
||||
// router behind a stub that injects a workspace, so the SQL query building is exercised
|
||||
// end-to-end over HTTP (the whitelisted sort + escaped LIKE are the parts worth guarding).
|
||||
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-search-' + crypto.randomBytes(4).toString('hex'));
|
||||
process.env.SELF_HOSTED = 'true';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
|
||||
const WS = 'ws-search';
|
||||
let server, base;
|
||||
|
||||
function get(qs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`${base}/${qs}`, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => (body += c));
|
||||
res.on('end', () => resolve(JSON.parse(body)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
const names = (rows) => rows.map((r) => r.filename);
|
||||
|
||||
before(async () => {
|
||||
// Content with workspace_id NULL is visible to any workspace via the GET's
|
||||
// "workspace_id = ? OR workspace_id IS NULL" clause — enough to exercise the query
|
||||
// building without standing up a full workspace/org row.
|
||||
const mk = (name, mime, size, remote) =>
|
||||
db.prepare('INSERT INTO content (id, filename, mime_type, file_size, remote_url) VALUES (?,?,?,?,?)')
|
||||
.run(crypto.randomBytes(6).toString('hex'), name, mime, size, remote);
|
||||
// created_at defaults to now for all; insert in a known order and lean on id/size/name for assertions.
|
||||
mk('alpha-logo.png', 'image/png', 100, null);
|
||||
mk('beta clip.mp4', 'video/mp4', 900, null);
|
||||
mk('gamma-100%-off.png', 'image/png', 300, null); // literal % — must survive LIKE escaping
|
||||
mk('promo short', 'video/youtube', 0, 'https://youtu.be/aaaaaaaaaaa');
|
||||
mk('news feed', 'text/html', 0, 'https://example.com/feed');
|
||||
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.workspaceId = WS; req.user = { id: 'u', role: 'admin' }; next(); });
|
||||
app.use('/', require('../routes/content'));
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => new Promise((r) => server.close(r)));
|
||||
|
||||
test('text search matches filename substring across the workspace', async () => {
|
||||
const r = await get('?q=logo');
|
||||
assert.deepEqual(names(r), ['alpha-logo.png']);
|
||||
});
|
||||
|
||||
test('LIKE metacharacters in the query are matched literally, not as wildcards', async () => {
|
||||
// A naive %q% would make "100%" match everything; the escape keeps it literal.
|
||||
const r = await get('?q=' + encodeURIComponent('100%'));
|
||||
assert.deepEqual(names(r), ['gamma-100%-off.png']);
|
||||
});
|
||||
|
||||
test('type filter buckets: video excludes youtube; youtube and web are their own', async () => {
|
||||
assert.deepEqual(names(await get('?type=image')).sort(), ['alpha-logo.png', 'gamma-100%-off.png']);
|
||||
assert.deepEqual(names(await get('?type=video')), ['beta clip.mp4']);
|
||||
assert.deepEqual(names(await get('?type=youtube')), ['promo short']);
|
||||
assert.deepEqual(names(await get('?type=web')), ['news feed']);
|
||||
});
|
||||
|
||||
test('sort=name is case-insensitive A-Z; sort=size is largest-first', async () => {
|
||||
const byName = names(await get('?sort=name'));
|
||||
assert.deepEqual(byName, [...byName].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())));
|
||||
const bySize = await get('?sort=size');
|
||||
assert.equal(bySize[0].filename, 'beta clip.mp4'); // 900, the largest
|
||||
});
|
||||
|
||||
test('unknown sort falls back to the default ordering (no SQL injection into ORDER BY)', async () => {
|
||||
const r = await get('?sort=filename;DROP TABLE content');
|
||||
assert.ok(Array.isArray(r) && r.length === 5); // table intact, request succeeded
|
||||
});
|
||||
|
||||
test('combining search + type + sort works together', async () => {
|
||||
const r = await get('?type=image&sort=name&q=' + encodeURIComponent(''));
|
||||
assert.deepEqual(names(r), ['alpha-logo.png', 'gamma-100%-off.png']);
|
||||
});
|
||||
Loading…
Reference in a new issue