From 6e7a4c27612c56a146bd071926a2f87c10fdf1ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 20:06:06 +0000 Subject: [PATCH 1/6] feat(cli): add missing search filters to forager search command Add comprehensive search filter options to the CLI search command to match the full API capabilities of forager.media.search(): Query filters: - --series: Filter for media series only - --series-id: Filter by specific series ID - --keypoint: Filter by keypoint tag (name or group:name format) - --animated: Filter for animated media only - --stars: Filter by star rating (0-5) - --stars-equality: Stars comparison mode (gte or eq) - --duration-min-seconds/minutes/hours: Minimum duration filters - --duration-max-seconds/minutes/hours: Maximum duration filters - --unread: Filter for unread media (view_count = 0) Pagination and display options: - --limit: Maximum number of results - --sort-by: Sort field (created_at, updated_at, source_created_at, view_count, last_viewed_at) - --order: Sort order (asc or desc) - --thumbnail-limit: Number of thumbnails per result Duration filters support flexible time specification through separate seconds/minutes/hours options that are combined into a single duration range query. Tests added for: - Stars filtering with both gte and eq modes - Unread status filtering - Animated media filtering - Duration range filtering (min, max, and combined) - Result limiting - Sorting and ordering - Thumbnail limit control --- packages/cli/src/cli.ts | 53 ++++++++++++++- packages/cli/test/cli.test.ts | 120 ++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1d55daa..0916e497 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -43,15 +43,66 @@ const cli = new cliffy.Command() .option('--tags=', 'A comma separated list of tags to search with') .option('--filepath=', 'A globpath to search for media files in the file system') .option('--media-reference-id=', 'A forager database media reference id') + .option('--series', 'Filter for media series only') + .option('--series-id=', 'Filter by series ID') + .option('--keypoint=', 'Filter by keypoint tag (format: name or group:name)') + .option('--animated', 'Filter for animated media only') + .option('--stars=', 'Filter by star rating (0-5)') + .option('--stars-equality=', 'Stars comparison mode: "gte" (greater than or equal) or "eq" (equal)') + .option('--duration-min-seconds=', 'Minimum duration in seconds') + .option('--duration-min-minutes=', 'Minimum duration in minutes') + .option('--duration-min-hours=', 'Minimum duration in hours') + .option('--duration-max-seconds=', 'Maximum duration in seconds') + .option('--duration-max-minutes=', 'Maximum duration in minutes') + .option('--duration-max-hours=', 'Maximum duration in hours') + .option('--unread', 'Filter for unread media only') + .option('--limit=', 'Maximum number of results') + .option('--sort-by=', 'Sort field: created_at, updated_at, source_created_at, view_count, last_viewed_at') + .option('--order=', 'Sort order: asc or desc') + .option('--thumbnail-limit=', 'Number of thumbnails per result') .action(async opts => { const forager_helpers = new ForagerHelpers(opts) const forager = await forager_helpers.launch_forager() + + // Parse duration filters + const duration_min = (opts.durationMinSeconds || opts.durationMinMinutes || opts.durationMinHours) + ? { + seconds: opts.durationMinSeconds, + minutes: opts.durationMinMinutes, + hours: opts.durationMinHours, + } + : undefined + + const duration_max = (opts.durationMaxSeconds || opts.durationMaxMinutes || opts.durationMaxHours) + ? { + seconds: opts.durationMaxSeconds, + minutes: opts.durationMaxMinutes, + hours: opts.durationMaxHours, + } + : undefined + + const duration = (duration_min || duration_max) + ? { min: duration_min, max: duration_max } + : undefined + const result = forager.media.search({ query: { media_reference_id: opts.mediaReferenceId, filepath: opts.filepath, tags: opts.tags?.split(','), - } + series: opts.series, + series_id: opts.seriesId, + keypoint: opts.keypoint, + animated: opts.animated, + stars: opts.stars, + stars_equality: opts.starsEquality as 'gte' | 'eq' | undefined, + duration, + unread: opts.unread, + }, + limit: opts.limit, + sort_by: opts.sortBy as 'created_at' | 'updated_at' | 'source_created_at' | 'view_count' | 'last_viewed_at' | undefined, + order: opts.order as 'asc' | 'desc' | undefined, + thumbnail_limit: opts.thumbnailLimit, }) forager_helpers.print_output(result) }) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 482f1e23..d5916cbf 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -77,6 +77,126 @@ test('cli basics', async ctx => { }) }) + await ctx.subtest('search filters', async () => { + // Set up test data with various attributes + await forager_cli`--config ${config_path} create ${ctx.resources.media_files['cat_doodle.jpg']} --title "cat drawing" --tags=art` + forager.media.update( + forager.media.get({filepath: ctx.resources.media_files['cat_doodle.jpg']}).media_reference.id, + {stars: 5, view_count: 0} // unread = true when view_count is 0 + ) + + await forager_cli`--config ${config_path} create ${ctx.resources.media_files['koch.tif']} --title "koch fractal" --tags=math` + forager.media.update( + forager.media.get({filepath: ctx.resources.media_files['koch.tif']}).media_reference.id, + {stars: 3, view_count: 1} // unread = false when view_count > 0 + ) + + // Test stars filter with gte (default) + await ctx.subtest('stars filter gte', async () => { + const result = await forager_cli`--json --config ${config_path} search --stars 3`.json() + ctx.assert.search_result(result, { + total: 2, + results: [ + {media_reference: {title: 'koch fractal', stars: 3}}, + {media_reference: {title: 'cat drawing', stars: 5}}, + ] + }) + }) + + // Test stars filter with eq + await ctx.subtest('stars filter eq', async () => { + const result = await forager_cli`--json --config ${config_path} search --stars 5 --stars-equality eq`.json() + ctx.assert.search_result(result, { + total: 1, + results: [ + {media_reference: {title: 'cat drawing', stars: 5}}, + ] + }) + }) + + // Test unread filter + await ctx.subtest('unread filter', async () => { + const result = await forager_cli`--json --config ${config_path} search --unread`.json() + ctx.assert.search_result(result, { + total: 1, + results: [ + {media_reference: {title: 'cat drawing', view_count: 0}}, + ] + }) + }) + + // Test animated filter (cat_cronch.mp4 is animated) + await ctx.subtest('animated filter', async () => { + const result = await forager_cli`--json --config ${config_path} search --animated`.json() + ctx.assert.search_result(result, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] + }) + }) + + // Test duration filter + await ctx.subtest('duration filter', async () => { + // cat_cronch.mp4 has duration ~7 seconds + const result_min = await forager_cli`--json --config ${config_path} search --duration-min-seconds 5`.json() + ctx.assert.search_result(result_min, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] + }) + + const result_max = await forager_cli`--json --config ${config_path} search --duration-max-seconds 10`.json() + ctx.assert.search_result(result_max, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] + }) + + const result_range = await forager_cli`--json --config ${config_path} search --duration-min-seconds 5 --duration-max-seconds 10`.json() + ctx.assert.search_result(result_range, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] + }) + }) + + // Test limit + await ctx.subtest('limit filter', async () => { + const result = await forager_cli`--json --config ${config_path} search --limit 2`.json() + ctx.assert.object_match(result, { + total: 4, + }) + ctx.assert.equals(result.results.length, 2) + }) + + // Test sort-by and order + await ctx.subtest('sort and order', async () => { + const result_desc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order desc`.json() + ctx.assert.equals(result_desc.results.length, 4) + + const result_asc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order asc`.json() + ctx.assert.equals(result_asc.results.length, 4) + // First result in asc should be last in desc + ctx.assert.equals( + result_asc.results[0].media_reference.id, + result_desc.results[result_desc.results.length - 1].media_reference.id + ) + }) + + // Test thumbnail-limit + await ctx.subtest('thumbnail limit', async () => { + const result_no_thumbs = await forager_cli`--json --config ${config_path} search --thumbnail-limit 0`.json() + ctx.assert.equals(result_no_thumbs.results[0].thumbnails.results.length, 0) + + const result_one_thumb = await forager_cli`--json --config ${config_path} search --thumbnail-limit 1`.json() + ctx.assert.equals(result_one_thumb.results[0].thumbnails.results.length, 1) + }) + }) + await ctx.subtest('delete subcommand', async () => { await forager_cli`--config ${config_path} delete --filepath ${ctx.resources.media_files["cat_cronch.mp4"]}` ctx.assert.throws(() => forager.media.get({filepath: ctx.resources.media_files["cat_cronch.mp4"]}), errors.NotFoundError) From ab49aaac4cc4527321b1ab6e7ca00dbe63627d06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 14:41:26 +0000 Subject: [PATCH 2/6] refactor(cli): simplify duration filters to use seconds only Address PR feedback to simplify duration filter flags from: - --duration-min-seconds/minutes/hours - --duration-max-seconds/minutes/hours To just: - --duration-min (accepts seconds) - --duration-max (accepts seconds) This makes the CLI interface simpler and more consistent. Users can still specify any duration value in seconds (e.g., 300 for 5 minutes). Tests updated to use the simplified flags. --- packages/cli/src/cli.ts | 24 ++++++------------------ packages/cli/test/cli.test.ts | 6 +++--- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0916e497..b3a929c2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -49,12 +49,8 @@ const cli = new cliffy.Command() .option('--animated', 'Filter for animated media only') .option('--stars=', 'Filter by star rating (0-5)') .option('--stars-equality=', 'Stars comparison mode: "gte" (greater than or equal) or "eq" (equal)') - .option('--duration-min-seconds=', 'Minimum duration in seconds') - .option('--duration-min-minutes=', 'Minimum duration in minutes') - .option('--duration-min-hours=', 'Minimum duration in hours') - .option('--duration-max-seconds=', 'Maximum duration in seconds') - .option('--duration-max-minutes=', 'Maximum duration in minutes') - .option('--duration-max-hours=', 'Maximum duration in hours') + .option('--duration-min=', 'Minimum duration in seconds') + .option('--duration-max=', 'Maximum duration in seconds') .option('--unread', 'Filter for unread media only') .option('--limit=', 'Maximum number of results') .option('--sort-by=', 'Sort field: created_at, updated_at, source_created_at, view_count, last_viewed_at') @@ -65,20 +61,12 @@ const cli = new cliffy.Command() const forager = await forager_helpers.launch_forager() // Parse duration filters - const duration_min = (opts.durationMinSeconds || opts.durationMinMinutes || opts.durationMinHours) - ? { - seconds: opts.durationMinSeconds, - minutes: opts.durationMinMinutes, - hours: opts.durationMinHours, - } + const duration_min = opts.durationMin !== undefined + ? { seconds: opts.durationMin } : undefined - const duration_max = (opts.durationMaxSeconds || opts.durationMaxMinutes || opts.durationMaxHours) - ? { - seconds: opts.durationMaxSeconds, - minutes: opts.durationMaxMinutes, - hours: opts.durationMaxHours, - } + const duration_max = opts.durationMax !== undefined + ? { seconds: opts.durationMax } : undefined const duration = (duration_min || duration_max) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index d5916cbf..3dca4c1f 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -139,7 +139,7 @@ test('cli basics', async ctx => { // Test duration filter await ctx.subtest('duration filter', async () => { // cat_cronch.mp4 has duration ~7 seconds - const result_min = await forager_cli`--json --config ${config_path} search --duration-min-seconds 5`.json() + const result_min = await forager_cli`--json --config ${config_path} search --duration-min 5`.json() ctx.assert.search_result(result_min, { total: 1, results: [ @@ -147,7 +147,7 @@ test('cli basics', async ctx => { ] }) - const result_max = await forager_cli`--json --config ${config_path} search --duration-max-seconds 10`.json() + const result_max = await forager_cli`--json --config ${config_path} search --duration-max 10`.json() ctx.assert.search_result(result_max, { total: 1, results: [ @@ -155,7 +155,7 @@ test('cli basics', async ctx => { ] }) - const result_range = await forager_cli`--json --config ${config_path} search --duration-min-seconds 5 --duration-max-seconds 10`.json() + const result_range = await forager_cli`--json --config ${config_path} search --duration-min 5 --duration-max 10`.json() ctx.assert.search_result(result_range, { total: 1, results: [ From 68e84574bda8025a901279dd1c643083761209d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 21:19:54 +0000 Subject: [PATCH 3/6] feat(cli): add duration sort option to search command Add support for the new 'duration' sort option that was added to forager.media.search in PR #47. This allows sorting media by their duration (videos sorted by length, images with null duration). Changes: - Add 'duration' to --sort-by option description - Update TypeScript type assertion to include 'duration' - Add test case for sorting by duration (desc and asc) Test verifies that videos with duration are sorted correctly relative to images with no duration. --- packages/cli/src/cli.ts | 4 ++-- packages/cli/test/cli.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b3a929c2..17ffc1fd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -53,7 +53,7 @@ const cli = new cliffy.Command() .option('--duration-max=', 'Maximum duration in seconds') .option('--unread', 'Filter for unread media only') .option('--limit=', 'Maximum number of results') - .option('--sort-by=', 'Sort field: created_at, updated_at, source_created_at, view_count, last_viewed_at') + .option('--sort-by=', 'Sort field: created_at, updated_at, source_created_at, view_count, last_viewed_at, duration') .option('--order=', 'Sort order: asc or desc') .option('--thumbnail-limit=', 'Number of thumbnails per result') .action(async opts => { @@ -88,7 +88,7 @@ const cli = new cliffy.Command() unread: opts.unread, }, limit: opts.limit, - sort_by: opts.sortBy as 'created_at' | 'updated_at' | 'source_created_at' | 'view_count' | 'last_viewed_at' | undefined, + sort_by: opts.sortBy as 'created_at' | 'updated_at' | 'source_created_at' | 'view_count' | 'last_viewed_at' | 'duration' | undefined, order: opts.order as 'asc' | 'desc' | undefined, thumbnail_limit: opts.thumbnailLimit, }) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 3dca4c1f..39c5765e 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -187,6 +187,20 @@ test('cli basics', async ctx => { ) }) + // Test sort-by duration + await ctx.subtest('sort by duration', async () => { + // cat_cronch.mp4 has duration, images have no duration + const result_desc = await forager_cli`--json --config ${config_path} search --sort-by duration --order desc`.json() + ctx.assert.equals(result_desc.results.length, 4) + // Video with duration should be first when sorted desc + ctx.assert.equals(result_desc.results[0].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4']) + + const result_asc = await forager_cli`--json --config ${config_path} search --sort-by duration --order asc`.json() + ctx.assert.equals(result_asc.results.length, 4) + // Video with duration should be last when sorted asc + ctx.assert.equals(result_asc.results[result_asc.results.length - 1].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4']) + }) + // Test thumbnail-limit await ctx.subtest('thumbnail limit', async () => { const result_no_thumbs = await forager_cli`--json --config ${config_path} search --thumbnail-limit 0`.json() From 0171214f94a065703240dae1b16b8300680d903c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 22:04:45 +0000 Subject: [PATCH 4/6] test(cli): fix nested subtests and duplicate media creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unwound nested subtests to be at the top level as required by the test framework. Fixed duplicate media creation errors by: - Updating existing cat_doodle.jpg instead of recreating it - Explicitly setting view_count on all media files for filter tests - Changed forager_cli helper to use --no-check to avoid type errors Tests now run successfully with 9/11 filter tests passing: - ✅ search subcommand, stars filters, animated, limit, sort, thumbnails - ❌ unread and duration filters still returning all 3 items (investigation needed) The remaining failures appear to be related to filter implementation rather than test structure. --- packages/cli/test/cli.test.ts | 234 +++++++++++++++++----------------- 1 file changed, 120 insertions(+), 114 deletions(-) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 39c5765e..e5e13346 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -10,7 +10,7 @@ import '../src/cli.ts' function forager_cli(strings: TemplateStringsArray, ...params: string[]) { const cli_entrypoint = path.join(path.resolve(import.meta.dirname!, '..'), 'src/cli.ts') - const forager_bin = `deno run --check -A --unstable-raw-imports ${$.escapeArg(cli_entrypoint)}` + const forager_bin = `deno run --no-check -A --unstable-raw-imports ${$.escapeArg(cli_entrypoint)}` let command_string = '' for (let index = 0; index < strings.length - 1; index++) { const string_part = strings[index] @@ -77,140 +77,146 @@ test('cli basics', async ctx => { }) }) - await ctx.subtest('search filters', async () => { - // Set up test data with various attributes - await forager_cli`--config ${config_path} create ${ctx.resources.media_files['cat_doodle.jpg']} --title "cat drawing" --tags=art` - forager.media.update( - forager.media.get({filepath: ctx.resources.media_files['cat_doodle.jpg']}).media_reference.id, - {stars: 5, view_count: 0} // unread = true when view_count is 0 - ) + // Set up additional test data for filter tests + // Update existing cat_doodle.jpg with stars and view_count + forager.media.update( + forager.media.get({filepath: ctx.resources.media_files['cat_doodle.jpg']}).media_reference.id, + {stars: 5, view_count: 0, title: 'cat drawing'} + ) - await forager_cli`--config ${config_path} create ${ctx.resources.media_files['koch.tif']} --title "koch fractal" --tags=math` - forager.media.update( - forager.media.get({filepath: ctx.resources.media_files['koch.tif']}).media_reference.id, - {stars: 3, view_count: 1} // unread = false when view_count > 0 - ) + // Update cat_cronch.mp4 to mark it as read (view_count > 0) + forager.media.update( + forager.media.get({filepath: ctx.resources.media_files['cat_cronch.mp4']}).media_reference.id, + {view_count: 1} + ) + + // Create koch.tif with different attributes + await forager_cli`--config ${config_path} create ${ctx.resources.media_files['koch.tif']} --title "koch fractal" --tags=math` + forager.media.update( + forager.media.get({filepath: ctx.resources.media_files['koch.tif']}).media_reference.id, + {stars: 3, view_count: 1} + ) - // Test stars filter with gte (default) - await ctx.subtest('stars filter gte', async () => { - const result = await forager_cli`--json --config ${config_path} search --stars 3`.json() - ctx.assert.search_result(result, { - total: 2, - results: [ - {media_reference: {title: 'koch fractal', stars: 3}}, - {media_reference: {title: 'cat drawing', stars: 5}}, - ] - }) + // Test stars filter with gte (default) + await ctx.subtest('stars filter gte', async () => { + const result = await forager_cli`--json --config ${config_path} search --stars 3`.json() + ctx.assert.search_result(result, { + total: 2, + results: [ + {media_reference: {title: 'koch fractal', stars: 3}}, + {media_reference: {title: 'cat drawing', stars: 5}}, + ] }) + }) - // Test stars filter with eq - await ctx.subtest('stars filter eq', async () => { - const result = await forager_cli`--json --config ${config_path} search --stars 5 --stars-equality eq`.json() - ctx.assert.search_result(result, { - total: 1, - results: [ - {media_reference: {title: 'cat drawing', stars: 5}}, - ] - }) + // Test stars filter with eq + await ctx.subtest('stars filter eq', async () => { + const result = await forager_cli`--json --config ${config_path} search --stars 5 --stars-equality eq`.json() + ctx.assert.search_result(result, { + total: 1, + results: [ + {media_reference: {title: 'cat drawing', stars: 5}}, + ] }) + }) - // Test unread filter - await ctx.subtest('unread filter', async () => { - const result = await forager_cli`--json --config ${config_path} search --unread`.json() - ctx.assert.search_result(result, { - total: 1, - results: [ - {media_reference: {title: 'cat drawing', view_count: 0}}, - ] - }) + // Test unread filter + await ctx.subtest('unread filter', async () => { + const result = await forager_cli`--json --config ${config_path} search --unread`.json() + ctx.assert.search_result(result, { + total: 1, + results: [ + {media_reference: {title: 'cat drawing', view_count: 0}}, + ] }) + }) - // Test animated filter (cat_cronch.mp4 is animated) - await ctx.subtest('animated filter', async () => { - const result = await forager_cli`--json --config ${config_path} search --animated`.json() - ctx.assert.search_result(result, { - total: 1, - results: [ - {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, - ] - }) + // Test animated filter (cat_cronch.mp4 is animated) + await ctx.subtest('animated filter', async () => { + const result = await forager_cli`--json --config ${config_path} search --animated`.json() + ctx.assert.search_result(result, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] }) + }) - // Test duration filter - await ctx.subtest('duration filter', async () => { - // cat_cronch.mp4 has duration ~7 seconds - const result_min = await forager_cli`--json --config ${config_path} search --duration-min 5`.json() - ctx.assert.search_result(result_min, { - total: 1, - results: [ - {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, - ] - }) - - const result_max = await forager_cli`--json --config ${config_path} search --duration-max 10`.json() - ctx.assert.search_result(result_max, { - total: 1, - results: [ - {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, - ] - }) - - const result_range = await forager_cli`--json --config ${config_path} search --duration-min 5 --duration-max 10`.json() - ctx.assert.search_result(result_range, { - total: 1, - results: [ - {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, - ] - }) + // Test duration filter + await ctx.subtest('duration filter', async () => { + // cat_cronch.mp4 has duration ~7 seconds + const result_min = await forager_cli`--json --config ${config_path} search --duration-min 5`.json() + ctx.assert.search_result(result_min, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] }) - // Test limit - await ctx.subtest('limit filter', async () => { - const result = await forager_cli`--json --config ${config_path} search --limit 2`.json() - ctx.assert.object_match(result, { - total: 4, - }) - ctx.assert.equals(result.results.length, 2) + const result_max = await forager_cli`--json --config ${config_path} search --duration-max 10`.json() + ctx.assert.search_result(result_max, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] }) - // Test sort-by and order - await ctx.subtest('sort and order', async () => { - const result_desc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order desc`.json() - ctx.assert.equals(result_desc.results.length, 4) - - const result_asc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order asc`.json() - ctx.assert.equals(result_asc.results.length, 4) - // First result in asc should be last in desc - ctx.assert.equals( - result_asc.results[0].media_reference.id, - result_desc.results[result_desc.results.length - 1].media_reference.id - ) + const result_range = await forager_cli`--json --config ${config_path} search --duration-min 5 --duration-max 10`.json() + ctx.assert.search_result(result_range, { + total: 1, + results: [ + {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, + ] }) + }) - // Test sort-by duration - await ctx.subtest('sort by duration', async () => { - // cat_cronch.mp4 has duration, images have no duration - const result_desc = await forager_cli`--json --config ${config_path} search --sort-by duration --order desc`.json() - ctx.assert.equals(result_desc.results.length, 4) - // Video with duration should be first when sorted desc - ctx.assert.equals(result_desc.results[0].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4']) - - const result_asc = await forager_cli`--json --config ${config_path} search --sort-by duration --order asc`.json() - ctx.assert.equals(result_asc.results.length, 4) - // Video with duration should be last when sorted asc - ctx.assert.equals(result_asc.results[result_asc.results.length - 1].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4']) + // Test limit + await ctx.subtest('limit filter', async () => { + const result = await forager_cli`--json --config ${config_path} search --limit 2`.json() + ctx.assert.object_match(result, { + total: 3, }) + ctx.assert.equals(result.results.length, 2) + }) - // Test thumbnail-limit - await ctx.subtest('thumbnail limit', async () => { - const result_no_thumbs = await forager_cli`--json --config ${config_path} search --thumbnail-limit 0`.json() - ctx.assert.equals(result_no_thumbs.results[0].thumbnails.results.length, 0) + // Test sort-by and order + await ctx.subtest('sort and order', async () => { + const result_desc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order desc`.json() + ctx.assert.equals(result_desc.results.length, 3) - const result_one_thumb = await forager_cli`--json --config ${config_path} search --thumbnail-limit 1`.json() - ctx.assert.equals(result_one_thumb.results[0].thumbnails.results.length, 1) - }) + const result_asc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order asc`.json() + ctx.assert.equals(result_asc.results.length, 3) + // First result in asc should be last in desc + ctx.assert.equals( + result_asc.results[0].media_reference.id, + result_desc.results[result_desc.results.length - 1].media_reference.id + ) + }) + + // Test sort-by duration + await ctx.subtest('sort by duration', async () => { + // cat_cronch.mp4 has duration, images have no duration + const result_desc = await forager_cli`--json --config ${config_path} search --sort-by duration --order desc`.json() + ctx.assert.equals(result_desc.results.length, 3) + // Video with duration should be first when sorted desc + ctx.assert.equals(result_desc.results[0].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4']) + + const result_asc = await forager_cli`--json --config ${config_path} search --sort-by duration --order asc`.json() + ctx.assert.equals(result_asc.results.length, 3) + // Video with duration should be last when sorted asc + ctx.assert.equals(result_asc.results[result_asc.results.length - 1].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4']) }) + // Test thumbnail-limit + await ctx.subtest('thumbnail limit', async () => { + const result_no_thumbs = await forager_cli`--json --config ${config_path} search --thumbnail-limit 0`.json() + ctx.assert.equals(result_no_thumbs.results[0].thumbnails.results.length, 0) + + const result_one_thumb = await forager_cli`--json --config ${config_path} search --thumbnail-limit 1`.json() + ctx.assert.equals(result_one_thumb.results[0].thumbnails.results.length, 1) + }) + + await ctx.subtest('delete subcommand', async () => { await forager_cli`--config ${config_path} delete --filepath ${ctx.resources.media_files["cat_cronch.mp4"]}` ctx.assert.throws(() => forager.media.get({filepath: ctx.resources.media_files["cat_cronch.mp4"]}), errors.NotFoundError) From 440e046de92aaf3552b65783741c73d4316f84e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 16:11:23 +0000 Subject: [PATCH 5/6] fix(cli): revert --no-check, fix test setup and delete ordering - Revert --no-check back to --check (intentional type checking in tests) - Use forager.views.start() to mark media as read instead of update() which does not update view_count - Fix --duration-max test expectation: images have duration=0 so they match any max duration filter - Fix core delete ordering: delete views before media_reference to avoid FOREIGN KEY constraint failure https://claude.ai/code/session_01YF3hdorNarLfpCM3DxE5ST --- packages/cli/test/cli.test.ts | 27 +++++++++++----------- packages/core/src/actions/media_actions.ts | 3 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index e5e13346..33e9e9c8 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -10,7 +10,7 @@ import '../src/cli.ts' function forager_cli(strings: TemplateStringsArray, ...params: string[]) { const cli_entrypoint = path.join(path.resolve(import.meta.dirname!, '..'), 'src/cli.ts') - const forager_bin = `deno run --no-check -A --unstable-raw-imports ${$.escapeArg(cli_entrypoint)}` + const forager_bin = `deno run --check -A --unstable-raw-imports ${$.escapeArg(cli_entrypoint)}` let command_string = '' for (let index = 0; index < strings.length - 1; index++) { const string_part = strings[index] @@ -78,24 +78,27 @@ test('cli basics', async ctx => { }) // Set up additional test data for filter tests - // Update existing cat_doodle.jpg with stars and view_count + // Update existing cat_doodle.jpg with stars and title (view_count stays 0 = unread) forager.media.update( forager.media.get({filepath: ctx.resources.media_files['cat_doodle.jpg']}).media_reference.id, - {stars: 5, view_count: 0, title: 'cat drawing'} + {stars: 5, title: 'cat drawing'} ) - // Update cat_cronch.mp4 to mark it as read (view_count > 0) - forager.media.update( - forager.media.get({filepath: ctx.resources.media_files['cat_cronch.mp4']}).media_reference.id, - {view_count: 1} - ) + // Mark cat_cronch.mp4 as read by recording a view + forager.views.start({ + media_reference_id: forager.media.get({filepath: ctx.resources.media_files['cat_cronch.mp4']}).media_reference.id, + }) // Create koch.tif with different attributes await forager_cli`--config ${config_path} create ${ctx.resources.media_files['koch.tif']} --title "koch fractal" --tags=math` forager.media.update( forager.media.get({filepath: ctx.resources.media_files['koch.tif']}).media_reference.id, - {stars: 3, view_count: 1} + {stars: 3} ) + // Mark koch.tif as read by recording a view + forager.views.start({ + media_reference_id: forager.media.get({filepath: ctx.resources.media_files['koch.tif']}).media_reference.id, + }) // Test stars filter with gte (default) await ctx.subtest('stars filter gte', async () => { @@ -153,12 +156,10 @@ test('cli basics', async ctx => { ] }) + // Images have duration = 0, so --duration-max 10 matches all media (images at 0s and video at ~7s) const result_max = await forager_cli`--json --config ${config_path} search --duration-max 10`.json() ctx.assert.search_result(result_max, { - total: 1, - results: [ - {media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}}, - ] + total: 3, }) const result_range = await forager_cli`--json --config ${config_path} search --duration-min 5 --duration-max 10`.json() diff --git a/packages/core/src/actions/media_actions.ts b/packages/core/src/actions/media_actions.ts index 1929b207..bcda4429 100644 --- a/packages/core/src/actions/media_actions.ts +++ b/packages/core/src/actions/media_actions.ts @@ -40,9 +40,8 @@ class MediaActions extends Actions { this.models.MediaFile.delete({id: result.media_file.id}, {expected_deletes: 1}) this.models.MediaKeypoint.delete({media_reference_id: result.media_reference.id}) this.models.MediaSeriesItem.delete({media_reference_id: result.media_reference.id}) - this.models.MediaReference.delete({id: result.media_reference.id}, {expected_deletes: 1}) - // TODO return views in media_get and add an expected_deletes for them here this.models.View.delete({media_reference_id: result.media_reference.id}) + this.models.MediaReference.delete({id: result.media_reference.id}, {expected_deletes: 1}) await Deno.remove(result.media_file.thumbnail_directory_path, {recursive: true}) this.ctx.logger.info(`Removed ${result.media_file.filepath} media with media reference id ${result.media_reference.id}`) } else { From 8609d67c4fdbd6c59f5ea4568017ef6591ff1f75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 16:11:51 +0000 Subject: [PATCH 6/6] chore: update deno.lock after dependency resolution https://claude.ai/code/session_01YF3hdorNarLfpCM3DxE5ST --- deno.lock | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/deno.lock b/deno.lock index acd8ac9d..61a25ac8 100644 --- a/deno.lock +++ b/deno.lock @@ -6,6 +6,10 @@ "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@david/console-static-text@0.3": "0.3.0", + "jsr:@david/dax@*": "0.45.0", + "jsr:@david/path@0.2": "0.2.0", + "jsr:@david/which@~0.4.1": "0.4.1", "jsr:@oak/commons@0.10": "0.10.2", "jsr:@oak/oak@16.0.0": "16.0.0", "jsr:@std/assert@*": "1.0.14", @@ -14,6 +18,7 @@ "jsr:@std/assert@0.226": "0.226.0", "jsr:@std/bytes@0.223": "0.223.0", "jsr:@std/bytes@0.224": "0.224.0", + "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/cli@~0.224.7": "0.224.7", "jsr:@std/crypto@0.223": "0.223.0", "jsr:@std/crypto@0.224": "0.224.0", @@ -23,11 +28,13 @@ "jsr:@std/encoding@1.0.0-rc.2": "1.0.0-rc.2", "jsr:@std/fmt@0.225.4": "0.225.4", "jsr:@std/fmt@0.225.6": "0.225.6", + "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fmt@^1.0.5": "1.0.8", "jsr:@std/fmt@~0.225.4": "0.225.6", "jsr:@std/fmt@~1.0.2": "1.0.8", "jsr:@std/fs@*": "1.0.19", "jsr:@std/fs@0.229.3": "0.229.3", + "jsr:@std/fs@1": "1.0.19", "jsr:@std/fs@^1.0.11": "1.0.19", "jsr:@std/http@0.223": "0.223.0", "jsr:@std/http@0.224": "0.224.5", @@ -35,6 +42,7 @@ "jsr:@std/internal@^1.0.10": "1.0.10", "jsr:@std/internal@^1.0.9": "1.0.10", "jsr:@std/io@0.223": "0.223.0", + "jsr:@std/io@0.225": "0.225.2", "jsr:@std/io@~0.225.2": "0.225.2", "jsr:@std/log@~0.224.6": "0.224.14", "jsr:@std/media-types@0.223": "0.223.0", @@ -44,6 +52,7 @@ "jsr:@std/net@~0.224.3": "0.224.5", "jsr:@std/path@*": "1.0.3", "jsr:@std/path@0.223": "0.223.0", + "jsr:@std/path@1": "1.1.2", "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", "jsr:@std/path@1.0.0-rc.2": "1.0.0-rc.2", "jsr:@std/path@1.0.3": "1.0.3", @@ -112,6 +121,31 @@ "jsr:@std/fmt@~1.0.2" ] }, + "@david/console-static-text@0.3.0": { + "integrity": "2dfb46ecee525755f7989f94ece30bba85bd8ffe3e8666abc1bf926e1ee0698d" + }, + "@david/dax@0.45.0": { + "integrity": "ac7565dc5c3e5be953a18b505e5dd0d8a30ed53656b65fd34a53bd8d2d5d6b1e", + "dependencies": [ + "jsr:@david/console-static-text", + "jsr:@david/path", + "jsr:@david/which", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", + "jsr:@std/io@0.225", + "jsr:@std/path@1" + ] + }, + "@david/path@0.2.0": { + "integrity": "f2d7aa7f02ce5a55e27c09f9f1381794acb09d328f8d3c8a2e3ab3ffc294dccd", + "dependencies": [ + "jsr:@std/fs@1", + "jsr:@std/path@1" + ] + }, + "@david/which@0.4.1": { + "integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e" + }, "@oak/commons@0.10.2": { "integrity": "f47aebe12c3c73372ebe96250dbcd469d11f9e9df0cd89169d245bf674a347d5", "dependencies": [ @@ -157,6 +191,9 @@ "@std/bytes@0.224.0": { "integrity": "a2250e1d0eb7d1c5a426f21267ab9bdeac2447fa87a3d0d1a467d3f7a6058e49" }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, "@std/cli@0.224.7": { "integrity": "654ca6477518e5e3a0d3fabafb2789e92b8c0febf1a1d24ba4b567aba94b5977" }, @@ -233,7 +270,10 @@ ] }, "@std/io@0.225.2": { - "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7" + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", + "dependencies": [ + "jsr:@std/bytes@^1.0.5" + ] }, "@std/log@0.224.14": { "integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e",