mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
fix(stats): reject malformed resource IDs before mutations (#259)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run.
|
||||||
@@ -37,6 +37,10 @@ The same immersion data powers the stats dashboard.
|
|||||||
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
||||||
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
||||||
|
|
||||||
|
### Stats API resource IDs
|
||||||
|
|
||||||
|
Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds.
|
||||||
|
|
||||||
### Dashboard tabs
|
### Dashboard tabs
|
||||||
|
|
||||||
#### Overview
|
#### Overview
|
||||||
|
|||||||
@@ -1004,6 +1004,23 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(seenLimit, 500);
|
assert.equal(seenLimit, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/vocabulary floors fractional pagination limits', async () => {
|
||||||
|
let seenLimit = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getVocabularyStats: async (limit?: number) => {
|
||||||
|
seenLimit = limit ?? 0;
|
||||||
|
return VOCABULARY_STATS;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/vocabulary?limit=12.9');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenLimit, 12);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/vocabulary passes excludePos to tracker', async () => {
|
it('GET /api/stats/vocabulary passes excludePos to tracker', async () => {
|
||||||
let seenArgs: unknown[] = [];
|
let seenArgs: unknown[] = [];
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
@@ -1351,7 +1368,7 @@ describe('stats server API routes', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) {
|
for (const anilistId of [-1, 0, 1.5, 9_007_199_254_740_992, '12', true, undefined]) {
|
||||||
const res = await app.request('/api/stats/anime/1/anilist', {
|
const res = await app.request('/api/stats/anime/1/anilist', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1449,6 +1466,74 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resource routes reject fractional ids before calling dependencies', async () => {
|
||||||
|
const dependencyCalls: string[] = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
dependencyCalls.push('fetch');
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getWordDetail: async () => {
|
||||||
|
dependencyCalls.push('getWordDetail');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getSessionEvents: async () => {
|
||||||
|
dependencyCalls.push('getSessionEvents');
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getEpisodeSessions: async () => {
|
||||||
|
dependencyCalls.push('getEpisodeSessions');
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getAnimeCoverArt: async () => {
|
||||||
|
dependencyCalls.push('getAnimeCoverArt');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
ensureAnimeCoverArt: async () => {
|
||||||
|
dependencyCalls.push('ensureAnimeCoverArt');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
setVideoWatched: async () => {
|
||||||
|
dependencyCalls.push('setVideoWatched');
|
||||||
|
},
|
||||||
|
reassignAnimeAnilist: async () => {
|
||||||
|
dependencyCalls.push('reassignAnimeAnilist');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all([
|
||||||
|
app.request('/api/stats/vocabulary/1.9/detail'),
|
||||||
|
app.request('/api/stats/sessions/1.9/events'),
|
||||||
|
app.request('/api/stats/episode/1.9/detail'),
|
||||||
|
app.request('/api/stats/anime/1.9/cover'),
|
||||||
|
app.request('/api/stats/media/1.9/watched', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"watched":true}',
|
||||||
|
}),
|
||||||
|
app.request('/api/stats/anime/1.9/anilist', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"anilistId":21858}',
|
||||||
|
}),
|
||||||
|
app.request('/api/stats/anki/browse?noteId=1.9', { method: 'POST' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
responses.map((response) => response.status),
|
||||||
|
[400, 400, 400, 400, 400, 400, 400],
|
||||||
|
);
|
||||||
|
assert.deepEqual(dependencyCalls, []);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => {
|
it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => {
|
||||||
let ensureCoverArtCalls = 0;
|
let ensureCoverArtCalls = 0;
|
||||||
const ensureAnimeCoverArtCalls: number[] = [];
|
const ensureAnimeCoverArtCalls: number[] = [];
|
||||||
@@ -1505,6 +1590,58 @@ describe('stats server API routes', () => {
|
|||||||
assert.deepEqual(ensureAnimeCoverArtCalls, [99999]);
|
assert.deepEqual(ensureAnimeCoverArtCalls, [99999]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('JSON id lists reject malformed members before side effects', async () => {
|
||||||
|
const dependencyCalls: string[] = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
dependencyCalls.push('fetch');
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteSessions: async () => {
|
||||||
|
dependencyCalls.push('deleteSessions');
|
||||||
|
},
|
||||||
|
mergeAnime: async () => {
|
||||||
|
dependencyCalls.push('mergeAnime');
|
||||||
|
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
|
||||||
|
},
|
||||||
|
getAnimeCoverArt: async () => {
|
||||||
|
dependencyCalls.push('getAnimeCoverArt');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
ensureAnimeCoverArt: async () => {
|
||||||
|
dependencyCalls.push('ensureAnimeCoverArt');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const request = async (path: string, body: string, method = 'POST'): Promise<Response> =>
|
||||||
|
await app.request(path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'),
|
||||||
|
request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'),
|
||||||
|
request('/api/stats/covers', '{"animeIds":[1,1.9]}'),
|
||||||
|
request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
responses.map((response) => response.status),
|
||||||
|
[400, 400, 400, 400],
|
||||||
|
);
|
||||||
|
assert.deepEqual(dependencyCalls, []);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => {
|
it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => {
|
||||||
let activeBackfills = 0;
|
let activeBackfills = 0;
|
||||||
let maxActiveBackfills = 0;
|
let maxActiveBackfills = 0;
|
||||||
@@ -3316,6 +3453,46 @@ Aligned English subtitle
|
|||||||
assert.equal(deleteCalls, 0);
|
assert.equal(deleteCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/sessions rejects a partly invalid id list without deleting', async () => {
|
||||||
|
let deleteCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteSessions: async () => {
|
||||||
|
deleteCalls += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/sessions', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"sessionIds":[4,1.9,7]}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(deleteCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/sessions deduplicates valid ids', async () => {
|
||||||
|
let deletedSessionIds: number[] = [];
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteSessions: async (sessionIds: number[]) => {
|
||||||
|
deletedSessionIds = sessionIds;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/sessions', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"sessionIds":[4,4,7]}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(deletedSessionIds, [4, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
|
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
|
||||||
let deletedAnimeId: number | null = null;
|
let deletedAnimeId: number | null = null;
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
@@ -3349,6 +3526,33 @@ Aligned English subtitle
|
|||||||
assert.equal(deleteCalls, 0);
|
assert.equal(deleteCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/anime/:animeId rejects malformed anime ids before deleting', async () => {
|
||||||
|
let deletedAnimeId: number | null = null;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteAnime: async (animeId: number) => {
|
||||||
|
deletedAnimeId = animeId;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const animeId of [
|
||||||
|
'1.9',
|
||||||
|
'1.0',
|
||||||
|
'1e2',
|
||||||
|
'9007199254740992',
|
||||||
|
'1%0A',
|
||||||
|
'%201',
|
||||||
|
'01',
|
||||||
|
'+1',
|
||||||
|
'0x1',
|
||||||
|
]) {
|
||||||
|
const res = await app.request(`/api/stats/anime/${animeId}`, { method: 'DELETE' });
|
||||||
|
assert.equal(res.status, 400, `accepted malformed anime id: ${animeId}`);
|
||||||
|
}
|
||||||
|
assert.equal(deletedAnimeId, null);
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
|
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
|
||||||
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
|
|||||||
@@ -3,37 +3,13 @@ import type { Hono } from 'hono';
|
|||||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||||
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
||||||
import type { StatsCoverImage } from '../../types/stats-wire.js';
|
import type { StatsCoverImage } from '../../types/stats-wire.js';
|
||||||
|
import { parsePositiveId, parsePositiveIdList } from './stats-server/route-support.js';
|
||||||
|
|
||||||
type StatsCoverImagePayload = StatsCoverImage | null;
|
type StatsCoverImagePayload = StatsCoverImage | null;
|
||||||
type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
|
type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
|
||||||
|
|
||||||
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
|
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
|
||||||
|
|
||||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
|
||||||
if (raw === undefined) return fallback;
|
|
||||||
const n = Number(raw);
|
|
||||||
if (!Number.isFinite(n) || n < 0) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
const parsed = Math.floor(n);
|
|
||||||
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
|
|
||||||
if (!Array.isArray(raw)) return [];
|
|
||||||
|
|
||||||
const ids = new Set<number>();
|
|
||||||
for (const rawId of raw) {
|
|
||||||
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
|
|
||||||
if (Number.isFinite(id) && id > 0) {
|
|
||||||
ids.add(Math.floor(id));
|
|
||||||
if (ids.size >= maxItems) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(ids).sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function coverImagePayload(
|
function coverImagePayload(
|
||||||
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
||||||
): StatsCoverImagePayload {
|
): StatsCoverImagePayload {
|
||||||
@@ -129,8 +105,11 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
|||||||
|
|
||||||
app.post('/api/stats/covers', async (c) => {
|
app.post('/api/stats/covers', async (c) => {
|
||||||
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
||||||
const animeIds = parsePositiveIdList(body?.animeIds);
|
const animeIds = body?.animeIds === undefined ? [] : parsePositiveIdList(body.animeIds, 100);
|
||||||
const videoIds = parsePositiveIdList(body?.videoIds);
|
const videoIds = body?.videoIds === undefined ? [] : parsePositiveIdList(body.videoIds, 100);
|
||||||
|
if (!animeIds || !videoIds) return c.body(null, 400);
|
||||||
|
animeIds.sort((a, b) => a - b);
|
||||||
|
videoIds.sort((a, b) => a - b);
|
||||||
const anime: Record<number, StatsCoverImagePayload> = {};
|
const anime: Record<number, StatsCoverImagePayload> = {};
|
||||||
const media: Record<number, StatsCoverImagePayload> = {};
|
const media: Record<number, StatsCoverImagePayload> = {};
|
||||||
|
|
||||||
@@ -155,8 +134,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
if (animeId <= 0) return c.body(null, 404);
|
if (animeId === null) return c.body(null, 400);
|
||||||
let art = await tracker.getAnimeCoverArt(animeId);
|
let art = await tracker.getAnimeCoverArt(animeId);
|
||||||
if (!art?.coverBlob) {
|
if (!art?.coverBlob) {
|
||||||
await tracker.ensureAnimeCoverArt(animeId);
|
await tracker.ensureAnimeCoverArt(animeId);
|
||||||
@@ -167,8 +146,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/media/:videoId/cover', async (c) => {
|
app.get('/api/stats/media/:videoId/cover', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parsePositiveId(c.req.param('videoId'));
|
||||||
if (videoId <= 0) return c.body(null, 404);
|
if (videoId === null) return c.body(null, 400);
|
||||||
let art = await tracker.getCoverArt(videoId);
|
let art = await tracker.getCoverArt(videoId);
|
||||||
if (!art?.coverBlob) {
|
if (!art?.coverBlob) {
|
||||||
await tracker.ensureCoverArt(videoId);
|
await tracker.ensureCoverArt(videoId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
loadKnownWordsSet,
|
loadKnownWordsSet,
|
||||||
parseEventTypesQuery,
|
parseEventTypesQuery,
|
||||||
parseIntQuery,
|
parseIntQuery,
|
||||||
|
parsePositiveId,
|
||||||
parseTrendFillEmpty,
|
parseTrendFillEmpty,
|
||||||
parseTrendGroupBy,
|
parseTrendGroupBy,
|
||||||
parseTrendRange,
|
parseTrendRange,
|
||||||
@@ -83,8 +84,8 @@ export function registerStatsAnalyticsRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
||||||
const id = parseIntQuery(c.req.param('id'), 0);
|
const id = parsePositiveId(c.req.param('id'));
|
||||||
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
|
if (id === null) return c.json(statsJson('sessionTimeline', []), 400);
|
||||||
const rawLimit = c.req.query('limit');
|
const rawLimit = c.req.query('limit');
|
||||||
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
|
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
|
||||||
const timeline = await tracker.getSessionTimeline(id, limit);
|
const timeline = await tracker.getSessionTimeline(id, limit);
|
||||||
@@ -92,8 +93,8 @@ export function registerStatsAnalyticsRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions/:id/events', async (c) => {
|
app.get('/api/stats/sessions/:id/events', async (c) => {
|
||||||
const id = parseIntQuery(c.req.param('id'), 0);
|
const id = parsePositiveId(c.req.param('id'));
|
||||||
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
|
if (id === null) return c.json(statsJson('sessionEvents', []), 400);
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
||||||
const eventTypes = parseEventTypesQuery(c.req.query('types'));
|
const eventTypes = parseEventTypesQuery(c.req.query('types'));
|
||||||
const events = await tracker.getSessionEvents(id, limit, eventTypes);
|
const events = await tracker.getSessionEvents(id, limit, eventTypes);
|
||||||
@@ -101,8 +102,8 @@ export function registerStatsAnalyticsRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
|
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
|
||||||
const id = parseIntQuery(c.req.param('id'), 0);
|
const id = parsePositiveId(c.req.param('id'));
|
||||||
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
|
if (id === null) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
|
||||||
|
|
||||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
buildAnkiNotePreview,
|
buildAnkiNotePreview,
|
||||||
countKnownWords,
|
countKnownWords,
|
||||||
enrichSessionsWithKnownWordMetrics,
|
enrichSessionsWithKnownWordMetrics,
|
||||||
|
isPositiveSafeInteger,
|
||||||
loadKnownWordsSet,
|
loadKnownWordsSet,
|
||||||
parseIntQuery,
|
parsePositiveId,
|
||||||
|
parsePositiveIdList,
|
||||||
} from './route-support.js';
|
} from './route-support.js';
|
||||||
|
|
||||||
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||||
@@ -87,8 +89,8 @@ export function registerStatsIntegrationRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
|
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
if (animeId <= 0) {
|
if (animeId === null) {
|
||||||
return c.json(
|
return c.json(
|
||||||
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
400,
|
400,
|
||||||
@@ -105,8 +107,8 @@ export function registerStatsIntegrationRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
|
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parsePositiveId(c.req.param('videoId'));
|
||||||
if (videoId <= 0) {
|
if (videoId === null) {
|
||||||
return c.json(
|
return c.json(
|
||||||
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
400,
|
400,
|
||||||
@@ -123,14 +125,10 @@ export function registerStatsIntegrationRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId === null) return c.body(null, 400);
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
if (
|
if (!isPositiveSafeInteger(body?.anilistId)) {
|
||||||
typeof body?.anilistId !== 'number' ||
|
|
||||||
!Number.isInteger(body.anilistId) ||
|
|
||||||
body.anilistId <= 0
|
|
||||||
) {
|
|
||||||
return c.body(null, 400);
|
return c.body(null, 400);
|
||||||
}
|
}
|
||||||
await tracker.reassignAnimeAnilist(animeId, body);
|
await tracker.reassignAnimeAnilist(animeId, body);
|
||||||
@@ -140,8 +138,8 @@ export function registerStatsIntegrationRoutes(
|
|||||||
registerStatsCoverRoutes(app, tracker);
|
registerStatsCoverRoutes(app, tracker);
|
||||||
|
|
||||||
app.get('/api/stats/episode/:videoId/detail', async (c) => {
|
app.get('/api/stats/episode/:videoId/detail', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parsePositiveId(c.req.param('videoId'));
|
||||||
if (videoId <= 0) return c.body(null, 400);
|
if (videoId === null) return c.body(null, 400);
|
||||||
const rawSessions = await tracker.getEpisodeSessions(videoId);
|
const rawSessions = await tracker.getEpisodeSessions(videoId);
|
||||||
const words = await tracker.getEpisodeWords(videoId);
|
const words = await tracker.getEpisodeWords(videoId);
|
||||||
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
|
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
|
||||||
@@ -154,8 +152,8 @@ export function registerStatsIntegrationRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/stats/anki/browse', async (c) => {
|
app.post('/api/stats/anki/browse', async (c) => {
|
||||||
const noteId = parseIntQuery(c.req.query('noteId'), 0);
|
const noteId = parsePositiveId(c.req.query('noteId'));
|
||||||
if (noteId <= 0) return c.body(null, 400);
|
if (noteId === null) return c.body(null, 400);
|
||||||
const ankiConfig = getAnkiConnectConfig();
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||||
@@ -177,19 +175,14 @@ export function registerStatsIntegrationRoutes(
|
|||||||
|
|
||||||
app.post('/api/stats/anki/notesInfo', async (c) => {
|
app.post('/api/stats/anki/notesInfo', async (c) => {
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const noteIds: number[] = Array.isArray(body?.noteIds)
|
const noteIds = parsePositiveIdList(body?.noteIds);
|
||||||
? body.noteIds.filter(
|
if (!noteIds) return c.body(null, 400);
|
||||||
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
|
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
|
||||||
const resolvedNoteIds = Array.from(
|
const resolvedNoteIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
noteIds.map((noteId) => {
|
noteIds.map((noteId) => {
|
||||||
const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId);
|
const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId);
|
||||||
return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0
|
return isPositiveSafeInteger(resolvedNoteId) ? resolvedNoteId : noteId;
|
||||||
? (resolvedNoteId as number)
|
|
||||||
: noteId;
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
|||||||
import {
|
import {
|
||||||
buildSentenceSearchOptions,
|
buildSentenceSearchOptions,
|
||||||
enrichSessionsWithKnownWordMetrics,
|
enrichSessionsWithKnownWordMetrics,
|
||||||
|
isPositiveSafeInteger,
|
||||||
|
loadKnownWordsSet,
|
||||||
parseBooleanQuery,
|
parseBooleanQuery,
|
||||||
parseDuplicateLineCleanupBody,
|
parseDuplicateLineCleanupBody,
|
||||||
parseExcludedWordsBody,
|
parseExcludedWordsBody,
|
||||||
parseIntQuery,
|
parseIntQuery,
|
||||||
|
parsePositiveId,
|
||||||
parsePositiveIdList,
|
parsePositiveIdList,
|
||||||
loadKnownWordsSet,
|
|
||||||
} from './route-support.js';
|
} from './route-support.js';
|
||||||
|
|
||||||
export function registerStatsLibraryRoutes(
|
export function registerStatsLibraryRoutes(
|
||||||
@@ -116,8 +118,8 @@ export function registerStatsLibraryRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
|
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
|
||||||
const wordId = parseIntQuery(c.req.param('wordId'), 0);
|
const wordId = parsePositiveId(c.req.param('wordId'));
|
||||||
if (wordId <= 0) return c.body(null, 400);
|
if (wordId === null) return c.body(null, 400);
|
||||||
const detail = await tracker.getWordDetail(wordId);
|
const detail = await tracker.getWordDetail(wordId);
|
||||||
if (!detail) return c.body(null, 404);
|
if (!detail) return c.body(null, 404);
|
||||||
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
|
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
|
||||||
@@ -126,8 +128,8 @@ export function registerStatsLibraryRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
|
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
|
||||||
const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0);
|
const kanjiId = parsePositiveId(c.req.param('kanjiId'));
|
||||||
if (kanjiId <= 0) return c.body(null, 400);
|
if (kanjiId === null) return c.body(null, 400);
|
||||||
const detail = await tracker.getKanjiDetail(kanjiId);
|
const detail = await tracker.getKanjiDetail(kanjiId);
|
||||||
if (!detail) return c.body(null, 404);
|
if (!detail) return c.body(null, 404);
|
||||||
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
|
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
|
||||||
@@ -141,8 +143,8 @@ export function registerStatsLibraryRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/media/:videoId', async (c) => {
|
app.get('/api/stats/media/:videoId', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parsePositiveId(c.req.param('videoId'));
|
||||||
if (videoId <= 0) return c.json(statsJson('error', null), 400);
|
if (videoId === null) return c.json(statsJson('error', null), 400);
|
||||||
const [detail, rawSessions, rollups] = await Promise.all([
|
const [detail, rawSessions, rollups] = await Promise.all([
|
||||||
tracker.getMediaDetail(videoId),
|
tracker.getMediaDetail(videoId),
|
||||||
tracker.getMediaSessions(videoId, 100),
|
tracker.getMediaSessions(videoId, 100),
|
||||||
@@ -167,16 +169,16 @@ export function registerStatsLibraryRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => {
|
app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => {
|
||||||
const recommendationId = parseIntQuery(c.req.param('recommendationId'), 0);
|
const recommendationId = parsePositiveId(c.req.param('recommendationId'));
|
||||||
if (recommendationId <= 0) return c.body(null, 400);
|
if (recommendationId === null) return c.body(null, 400);
|
||||||
const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId);
|
const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId);
|
||||||
if (!dismissed) return c.body(null, 404);
|
if (!dismissed) return c.body(null, 404);
|
||||||
return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true }));
|
return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId', async (c) => {
|
app.get('/api/stats/anime/:animeId', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId === null) return c.body(null, 400);
|
||||||
const detail = await tracker.getAnimeDetail(animeId);
|
const detail = await tracker.getAnimeDetail(animeId);
|
||||||
if (!detail) return c.body(null, 404);
|
if (!detail) return c.body(null, 404);
|
||||||
const [episodes, anilistEntries] = await Promise.all([
|
const [episodes, anilistEntries] = await Promise.all([
|
||||||
@@ -187,22 +189,22 @@ export function registerStatsLibraryRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/words', async (c) => {
|
app.get('/api/stats/anime/:animeId/words', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
|
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId === null) return c.body(null, 400);
|
||||||
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
|
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
|
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId === null) return c.body(null, 400);
|
||||||
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
|
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/stats/media/:videoId/watched', async (c) => {
|
app.patch('/api/stats/media/:videoId/watched', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parsePositiveId(c.req.param('videoId'));
|
||||||
if (videoId <= 0) return c.body(null, 400);
|
if (videoId === null) return c.body(null, 400);
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
|
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
|
||||||
await tracker.setVideoWatched(videoId, watched);
|
await tracker.setVideoWatched(videoId, watched);
|
||||||
@@ -211,42 +213,40 @@ export function registerStatsLibraryRoutes(
|
|||||||
|
|
||||||
app.delete('/api/stats/sessions', async (c) => {
|
app.delete('/api/stats/sessions', async (c) => {
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const ids = Array.isArray(body?.sessionIds)
|
const ids = parsePositiveIdList(body?.sessionIds);
|
||||||
? body.sessionIds.filter(
|
if (!ids || ids.length === 0) return c.body(null, 400);
|
||||||
(id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
if (ids.length === 0) return c.body(null, 400);
|
|
||||||
await tracker.deleteSessions(ids);
|
await tracker.deleteSessions(ids);
|
||||||
return c.json(statsJson('deleteSessions', { ok: true }));
|
return c.json(statsJson('deleteSessions', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
||||||
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
const sessionId = parsePositiveId(c.req.param('sessionId'));
|
||||||
if (sessionId <= 0) return c.body(null, 400);
|
if (sessionId === null) return c.body(null, 400);
|
||||||
await tracker.deleteSession(sessionId);
|
await tracker.deleteSession(sessionId);
|
||||||
return c.json(statsJson('deleteSession', { ok: true }));
|
return c.json(statsJson('deleteSession', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stats/media/:videoId', async (c) => {
|
app.delete('/api/stats/media/:videoId', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parsePositiveId(c.req.param('videoId'));
|
||||||
if (videoId <= 0) return c.body(null, 400);
|
if (videoId === null) return c.body(null, 400);
|
||||||
await tracker.deleteVideo(videoId);
|
await tracker.deleteVideo(videoId);
|
||||||
return c.json(statsJson('deleteVideo', { ok: true }));
|
return c.json(statsJson('deleteVideo', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stats/anime/:animeId', async (c) => {
|
app.delete('/api/stats/anime/:animeId', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId === null) return c.body(null, 400);
|
||||||
await tracker.deleteAnime(animeId);
|
await tracker.deleteAnime(animeId);
|
||||||
return c.json(statsJson('deleteAnime', { ok: true }));
|
return c.json(statsJson('deleteAnime', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/stats/anime/:animeId/merge', async (c) => {
|
app.post('/api/stats/anime/:animeId/merge', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parsePositiveId(c.req.param('animeId'));
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId === null) return c.body(null, 400);
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId);
|
const parsedSourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds);
|
||||||
|
if (!parsedSourceAnimeIds) return c.body(null, 400);
|
||||||
|
const sourceAnimeIds = parsedSourceAnimeIds.filter((id) => id !== animeId);
|
||||||
if (sourceAnimeIds.length === 0) return c.body(null, 400);
|
if (sourceAnimeIds.length === 0) return c.body(null, 400);
|
||||||
let summary: Awaited<ReturnType<typeof tracker.mergeAnime>>;
|
let summary: Awaited<ReturnType<typeof tracker.mergeAnime>>;
|
||||||
try {
|
try {
|
||||||
@@ -271,11 +271,11 @@ export function registerStatsLibraryRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/stats/media/:videoId/anime', async (c) => {
|
app.patch('/api/stats/media/:videoId/anime', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parsePositiveId(c.req.param('videoId'));
|
||||||
if (videoId <= 0) return c.body(null, 400);
|
if (videoId === null) return c.body(null, 400);
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const animeId = Number.isSafeInteger(body?.animeId) ? (body.animeId as number) : 0;
|
const animeId = body?.animeId;
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (!isPositiveSafeInteger(animeId)) return c.body(null, 400);
|
||||||
try {
|
try {
|
||||||
const summary = await tracker.moveVideoToAnime(videoId, animeId);
|
const summary = await tracker.moveVideoToAnime(videoId, animeId);
|
||||||
return c.json(
|
return c.json(
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ export function parseIntQuery(
|
|||||||
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPositiveSafeInteger(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePositiveId(raw: string | undefined): number | null {
|
||||||
|
if (raw === undefined) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return isPositiveSafeInteger(value) && String(value) === raw ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
||||||
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
||||||
? raw
|
? raw
|
||||||
@@ -199,16 +209,16 @@ export async function enrichSessionsWithKnownWordMetrics<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deduplicated positive integer ids from an untrusted JSON body field. */
|
/** Deduplicated positive safe integer ids from an untrusted JSON body field. */
|
||||||
export function parsePositiveIdList(raw: unknown): number[] {
|
export function parsePositiveIdList(raw: unknown, maxItems?: number): number[] | null {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return null;
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
for (const value of raw) {
|
for (const value of raw) {
|
||||||
if (Number.isSafeInteger(value) && (value as number) > 0) {
|
if (!isPositiveSafeInteger(value)) return null;
|
||||||
ids.add(value as number);
|
ids.add(value);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [...ids];
|
const parsed = [...ids];
|
||||||
|
return maxItems === undefined ? parsed : parsed.slice(0, maxItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
|
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user