Skip to content

Commit 4f2b860

Browse files
committed
Add search pagination metadata
1 parent 982de63 commit 4f2b860

16 files changed

Lines changed: 692 additions & 126 deletions

PLAN.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@ papers-cli config set <key> <value>
3131
キーワードで各ソースから論文を検索し、Spectre.Console Table で表示。
3232

3333
```
34-
papers-cli search <query> [--source <sources>] [--author <name>] [--from <year>] [--to <year>] [--category <cat>] [--sort <field>] [--limit <n>] [--json]
34+
papers-cli search <query> [--source <source>] [--author <name>] [--from <year>] [--to <year>] [--category <cat>] [--sort <field>] [--limit <n>] [--page <n>] [--json]
3535
```
3636

37-
- `--source`: カンマ区切りで複数ソース同時検索可能 (`arxiv,jstage,cinii`)。未指定時は config の `default-source` を使用。
38-
- `--sort`: `relevance` (default) / `date` / `title` / `author`
39-
- `--limit`: デフォルト 20
40-
- `--json`: JSON 出力(パイプ連携用)
37+
- `--source`: 単一ソース指定 (`arxiv`, `jstage`, `irdb`)。未指定時は config の `default-source` を使用。
38+
- `--sort`: `relevance` (default) / `date``jstage` のみ `title` も対応。
39+
- `--limit`: 1ページあたりの件数。デフォルト 20。
40+
- `--page`: 1始まりのページ番号。デフォルト 1。
41+
- `--json`: 総件数メタデータと `results` 配列を含む JSON オブジェクトを出力(`download --stdin` 連携対応)
4142
- テーブルカラム: Source:ID / Title / Authors / Year / Categories / DL (DL済みフォーマット表示)
4243

4344
### download
@@ -109,8 +110,8 @@ papers-cli config set <key> <value>
109110
| Source | API | 対応フォーマット |
110111
|--------|-----|-----------------|
111112
| arXiv | arXiv API (Atom Feed) | PDF, LaTeX source 等 |
112-
| J-STAGE | REST API | PDF 等 |
113-
| CiNii Research | OpenSearch API (https://support.nii.ac.jp/ja/cir/r_opensearch) | PDF 等 |
113+
| J-STAGE | J-STAGE WebAPI | PDF 等 |
114+
| IRDB | CiNii Research OpenSearch API (dataSourceType=IRDB) | PDF 等 |
114115

115116
各ソースがサポートするフォーマットはすべて対応する。ただし HTML+画像のように複数ファイルで構成されるものは MVP では対応しない。
116117

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A CLI tool for searching, downloading, and managing academic papers from arXiv,
44

55
## Features
66

7-
- Cross-source search (arXiv, J-STAGE, IRDB)
7+
- Single-source search with paging (arXiv, J-STAGE, IRDB)
88
- PDF download with SQLite metadata management
99
- Rich terminal UI via Spectre.Console (tables, panels, progress bars)
1010
- `--json` output for scripting
@@ -25,7 +25,7 @@ dotnet publish src/PapersCli.Cli -c Release -r linux-x64
2525
### Search
2626

2727
```bash
28-
# Search across all sources (default)
28+
# Search the configured default source (arxiv by default)
2929
papers-cli search "attention mechanism"
3030

3131
# Search a specific source
@@ -35,7 +35,10 @@ papers-cli search "transformer" --source arxiv --category cs.AI --from 2023
3535
# Filter options
3636
papers-cli search "reinforcement learning" --author "Yamada" --from 2020 --to 2024 --sort date --limit 10
3737

38-
# JSON output
38+
# Paging
39+
papers-cli search "attention" --source arxiv --limit 10 --page 2
40+
41+
# JSON output includes result metadata and a results array
3942
papers-cli search "attention" --source arxiv --json
4043
```
4144

@@ -96,7 +99,7 @@ papers-cli config set download-dir ~/my-papers
9699
| Source | Target | API |
97100
|--------|--------|-----|
98101
| `arxiv` | arXiv preprints | arXiv API (Atom Feed) |
99-
| `jstage` | J-STAGE articles | J-STAGE WebAPI + CiNii Research |
102+
| `jstage` | J-STAGE articles | J-STAGE WebAPI |
100103
| `irdb` | Institutional repositories | CiNii Research (IRDB) |
101104

102105
## Storage
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
using PapersCli.Cli.Commands;
2+
using PapersCli.Cli.Config;
3+
using PapersCli.Cli.Data;
4+
using PapersCli.Cli.Models;
5+
using PapersCli.Cli.Sources;
6+
7+
namespace PapersCli.Cli.Tests.Commands;
8+
9+
public class SearchCommandTests
10+
{
11+
[Test]
12+
[NotInParallel("Console")]
13+
public async Task Search_UsesConfigDefaultSourceAndPassesPagingOptions()
14+
{
15+
var source = new FakeSource("arxiv");
16+
var command = CreateCommand(new AppConfig { DefaultSource = "arxiv" }, source);
17+
18+
var (_, output, _) = await CaptureConsoleAsync(() =>
19+
command.Search("attention", limit: 10, page: 2, json: true));
20+
21+
await Assert.That(source.Calls).IsEqualTo(1);
22+
await Assert.That(source.LastQuery).IsEqualTo("attention");
23+
await Assert.That(source.LastLimit).IsEqualTo(10);
24+
await Assert.That(source.LastPage).IsEqualTo(2);
25+
await Assert.That(output.Contains("\"total_results\": 30")).IsTrue();
26+
await Assert.That(output.Contains("\"total_pages\": 3")).IsTrue();
27+
}
28+
29+
[Test]
30+
[NotInParallel("Console")]
31+
public async Task Search_RejectsMultipleSources()
32+
{
33+
var source = new FakeSource("arxiv");
34+
var command = CreateCommand(new AppConfig { DefaultSource = "arxiv" }, source);
35+
36+
var (error, _, exitCode) = await CaptureConsoleAsync(() =>
37+
command.Search("attention", source: "arxiv,jstage", json: true));
38+
39+
await Assert.That(exitCode).IsEqualTo(1);
40+
await Assert.That(source.Calls).IsEqualTo(0);
41+
await Assert.That(error.Contains("single source")).IsTrue();
42+
}
43+
44+
[Test]
45+
[NotInParallel("Console")]
46+
public async Task Search_RejectsUnknownSource()
47+
{
48+
var source = new FakeSource("arxiv");
49+
var command = CreateCommand(new AppConfig { DefaultSource = "arxiv" }, source);
50+
51+
var (error, _, exitCode) = await CaptureConsoleAsync(() =>
52+
command.Search("attention", source: "jstage", json: true));
53+
54+
await Assert.That(exitCode).IsEqualTo(1);
55+
await Assert.That(source.Calls).IsEqualTo(0);
56+
await Assert.That(error.Contains("Unknown source: jstage")).IsTrue();
57+
}
58+
59+
[Test]
60+
[NotInParallel("Console")]
61+
public async Task Search_RejectsUnsupportedSort()
62+
{
63+
var source = new FakeSource("arxiv");
64+
var command = CreateCommand(new AppConfig { DefaultSource = "arxiv" }, source);
65+
66+
var (error, _, exitCode) = await CaptureConsoleAsync(() =>
67+
command.Search("attention", sort: "author", json: true));
68+
69+
await Assert.That(exitCode).IsEqualTo(1);
70+
await Assert.That(source.Calls).IsEqualTo(0);
71+
await Assert.That(error.Contains("Sort 'author' is not supported by arxiv.")).IsTrue();
72+
}
73+
74+
[Test]
75+
[NotInParallel("Console")]
76+
public async Task Search_RejectsInvalidLimit()
77+
{
78+
var source = new FakeSource("arxiv");
79+
var command = CreateCommand(new AppConfig { DefaultSource = "arxiv" }, source);
80+
81+
var (error, _, exitCode) = await CaptureConsoleAsync(() =>
82+
command.Search("attention", limit: 0, json: true));
83+
84+
await Assert.That(exitCode).IsEqualTo(1);
85+
await Assert.That(source.Calls).IsEqualTo(0);
86+
await Assert.That(error.Contains("--limit must be greater than 0.")).IsTrue();
87+
}
88+
89+
[Test]
90+
[NotInParallel("Console")]
91+
public async Task Search_RejectsInvalidPage()
92+
{
93+
var source = new FakeSource("arxiv");
94+
var command = CreateCommand(new AppConfig { DefaultSource = "arxiv" }, source);
95+
96+
var (error, _, exitCode) = await CaptureConsoleAsync(() =>
97+
command.Search("attention", page: 0, json: true));
98+
99+
await Assert.That(exitCode).IsEqualTo(1);
100+
await Assert.That(source.Calls).IsEqualTo(0);
101+
await Assert.That(error.Contains("--page must be greater than 0.")).IsTrue();
102+
}
103+
104+
private static PaperCommands CreateCommand(AppConfig config, params IPaperSource[] sources)
105+
{
106+
var dbPath = Path.Combine(Path.GetTempPath(), $"papers-command-test-{Guid.NewGuid()}.db");
107+
var repository = new PaperRepository($"Data Source={dbPath}");
108+
return new PaperCommands(repository, sources, config, new HttpClient());
109+
}
110+
111+
private static async Task<(string Error, string Output, int ExitCode)> CaptureConsoleAsync(Func<Task> action)
112+
{
113+
Environment.ExitCode = 0;
114+
var previousError = Console.Error;
115+
var previousOutput = Console.Out;
116+
using var error = new StringWriter();
117+
using var output = new StringWriter();
118+
#pragma warning disable TUnit0055
119+
Console.SetError(error);
120+
Console.SetOut(output);
121+
#pragma warning restore TUnit0055
122+
try
123+
{
124+
await action();
125+
return (error.ToString(), output.ToString(), Environment.ExitCode);
126+
}
127+
finally
128+
{
129+
#pragma warning disable TUnit0055
130+
Console.SetError(previousError);
131+
Console.SetOut(previousOutput);
132+
#pragma warning restore TUnit0055
133+
Environment.ExitCode = 0;
134+
}
135+
}
136+
137+
private sealed class FakeSource(string name) : IPaperSource
138+
{
139+
public string Name => name;
140+
public IReadOnlyList<string> SupportedFormats => ["pdf"];
141+
public int Calls { get; private set; }
142+
public string? LastQuery { get; private set; }
143+
public int? LastLimit { get; private set; }
144+
public int? LastPage { get; private set; }
145+
146+
public Task<SearchResultsPage> SearchAsync(
147+
string query,
148+
string? author = null,
149+
int? fromYear = null,
150+
int? toYear = null,
151+
string? category = null,
152+
string sort = "relevance",
153+
int limit = 20,
154+
int page = 1,
155+
CancellationToken cancellationToken = default)
156+
{
157+
Calls++;
158+
LastQuery = query;
159+
LastLimit = limit;
160+
LastPage = page;
161+
162+
return Task.FromResult(new SearchResultsPage
163+
{
164+
Source = name,
165+
Query = query,
166+
Page = page,
167+
Limit = limit,
168+
TotalResults = 30,
169+
Results =
170+
[
171+
new SearchResult
172+
{
173+
Source = name,
174+
SourceId = "id-1",
175+
Title = "Paper",
176+
Authors = "[]",
177+
Url = "https://example.com/paper",
178+
},
179+
],
180+
});
181+
}
182+
183+
public Task<SearchResult?> GetMetadataAsync(string sourceId, CancellationToken cancellationToken = default)
184+
=> throw new NotImplementedException();
185+
186+
public Task<Dictionary<string, string>> GetDownloadUrlsAsync(string sourceId, CancellationToken cancellationToken = default)
187+
=> throw new NotImplementedException();
188+
189+
public string? ParseUrl(string url) => null;
190+
}
191+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using PapersCli.Cli.Commands;
2+
3+
namespace PapersCli.Cli.Tests.Commands;
4+
5+
public class SearchResultInputParserTests
6+
{
7+
[Test]
8+
public async Task ParseIds_ReadsLegacySearchResultArray()
9+
{
10+
var ids = SearchResultInputParser.ParseIds("""
11+
[
12+
{
13+
"source": "arxiv",
14+
"source_id": "2301.00001",
15+
"title": "Paper",
16+
"authors": "[]",
17+
"url": "https://arxiv.org/abs/2301.00001"
18+
}
19+
]
20+
""");
21+
22+
await Assert.That(ids.Count).IsEqualTo(1);
23+
await Assert.That(ids[0]).IsEqualTo("arxiv:2301.00001");
24+
}
25+
26+
[Test]
27+
public async Task ParseIds_ReadsSearchResultsPageObject()
28+
{
29+
var ids = SearchResultInputParser.ParseIds("""
30+
{
31+
"source": "arxiv",
32+
"query": "attention",
33+
"page": 1,
34+
"limit": 20,
35+
"returned_results": 1,
36+
"total_results": 42,
37+
"total_pages": 3,
38+
"has_more": true,
39+
"results": [
40+
{
41+
"source": "arxiv",
42+
"source_id": "2301.00001",
43+
"title": "Paper",
44+
"authors": "[]",
45+
"url": "https://arxiv.org/abs/2301.00001"
46+
}
47+
]
48+
}
49+
""");
50+
51+
await Assert.That(ids.Count).IsEqualTo(1);
52+
await Assert.That(ids[0]).IsEqualTo("arxiv:2301.00001");
53+
}
54+
55+
[Test]
56+
public async Task ParseIds_FallsBackToLineSeparatedIds()
57+
{
58+
var ids = SearchResultInputParser.ParseIds("""
59+
arxiv:2301.00001
60+
jstage:10.1234/test
61+
""");
62+
63+
await Assert.That(ids.Count).IsEqualTo(2);
64+
await Assert.That(ids[0]).IsEqualTo("arxiv:2301.00001");
65+
await Assert.That(ids[1]).IsEqualTo("jstage:10.1234/test");
66+
}
67+
}

src/PapersCli.Cli.Tests/Sources/PaperIdParserTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ private class FakeArxivSource : IPaperSource
7272
public string Name => "arxiv";
7373
public IReadOnlyList<string> SupportedFormats => ["pdf"];
7474
public string? ParseUrl(string url) => _inner.ParseUrl(url);
75-
public Task<IReadOnlyList<SearchResult>> SearchAsync(string query, string? author, int? fromYear, int? toYear, string? category, string sort, int limit, CancellationToken ct) => throw new NotImplementedException();
75+
public Task<SearchResultsPage> SearchAsync(string query, string? author, int? fromYear, int? toYear, string? category, string sort, int limit, int page, CancellationToken ct) => throw new NotImplementedException();
7676
public Task<SearchResult?> GetMetadataAsync(string sourceId, CancellationToken ct) => throw new NotImplementedException();
7777
public Task<Dictionary<string, string>> GetDownloadUrlsAsync(string sourceId, CancellationToken ct) => throw new NotImplementedException();
7878
}
@@ -83,7 +83,7 @@ private class FakeJStageSource : IPaperSource
8383
public string Name => "jstage";
8484
public IReadOnlyList<string> SupportedFormats => ["pdf"];
8585
public string? ParseUrl(string url) => _inner.ParseUrl(url);
86-
public Task<IReadOnlyList<SearchResult>> SearchAsync(string query, string? author, int? fromYear, int? toYear, string? category, string sort, int limit, CancellationToken ct) => throw new NotImplementedException();
86+
public Task<SearchResultsPage> SearchAsync(string query, string? author, int? fromYear, int? toYear, string? category, string sort, int limit, int page, CancellationToken ct) => throw new NotImplementedException();
8787
public Task<SearchResult?> GetMetadataAsync(string sourceId, CancellationToken ct) => throw new NotImplementedException();
8888
public Task<Dictionary<string, string>> GetDownloadUrlsAsync(string sourceId, CancellationToken ct) => throw new NotImplementedException();
8989
}
@@ -94,7 +94,7 @@ private class FakeCiNiiSource : IPaperSource
9494
public string Name => "cinii";
9595
public IReadOnlyList<string> SupportedFormats => ["pdf"];
9696
public string? ParseUrl(string url) => _inner.ParseUrl(url);
97-
public Task<IReadOnlyList<SearchResult>> SearchAsync(string query, string? author, int? fromYear, int? toYear, string? category, string sort, int limit, CancellationToken ct) => throw new NotImplementedException();
97+
public Task<SearchResultsPage> SearchAsync(string query, string? author, int? fromYear, int? toYear, string? category, string sort, int limit, int page, CancellationToken ct) => throw new NotImplementedException();
9898
public Task<SearchResult?> GetMetadataAsync(string sourceId, CancellationToken ct) => throw new NotImplementedException();
9999
public Task<Dictionary<string, string>> GetDownloadUrlsAsync(string sourceId, CancellationToken ct) => throw new NotImplementedException();
100100
}

0 commit comments

Comments
 (0)