scholarinboxcli 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/CHANGELOG.md +23 -1
  2. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/PKG-INFO +13 -53
  3. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/README.md +11 -51
  4. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/pyproject.toml +8 -2
  5. scholarinboxcli-0.1.3/src/scholarinboxcli/__init__.py +1 -0
  6. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/api/client.py +67 -14
  7. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/api/endpoints.py +1 -1
  8. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/cli.py +1 -1
  9. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/commands/auth.py +2 -1
  10. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/commands/bookmarks.py +2 -1
  11. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/commands/collections.py +7 -2
  12. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/commands/common.py +8 -2
  13. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/commands/conferences.py +3 -2
  14. scholarinboxcli-0.1.3/src/scholarinboxcli/formatters/domain_tables.py +122 -0
  15. scholarinboxcli-0.1.3/src/scholarinboxcli/formatters/table.py +134 -0
  16. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/services/collections.py +0 -2
  17. scholarinboxcli-0.1.3/src/scholarinboxcli/services/paper_sort.py +54 -0
  18. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/tests/test_api_client_mocked.py +23 -0
  19. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/tests/test_cli_smoke.py +2 -1
  20. scholarinboxcli-0.1.3/tests/test_domain_tables.py +60 -0
  21. scholarinboxcli-0.1.3/tests/test_paper_sort.py +27 -0
  22. scholarinboxcli-0.1.3/tests/test_table_formatter.py +64 -0
  23. scholarinboxcli-0.1.3/uv.lock +334 -0
  24. scholarinboxcli-0.1.1/src/scholarinboxcli/__init__.py +0 -1
  25. scholarinboxcli-0.1.1/src/scholarinboxcli/formatters/table.py +0 -66
  26. scholarinboxcli-0.1.1/uv.lock +0 -198
  27. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/.github/workflows/ci.yml +0 -0
  28. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/.github/workflows/publish.yml +0 -0
  29. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/.gitignore +0 -0
  30. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/LICENSE +0 -0
  31. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/commands/__init__.py +0 -0
  32. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/commands/papers.py +0 -0
  33. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/config.py +0 -0
  34. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/formatters/json_fmt.py +0 -0
  35. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/src/scholarinboxcli/services/__init__.py +0 -0
  36. {scholarinboxcli-0.1.1 → scholarinboxcli-0.1.3}/tests/test_collection_resolution.py +0 -0
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.3] - 2026-02-04
11
+
12
+ ### Changed
13
+
14
+ - Updated project tagline to emphasize humans + agents use case.
15
+
16
+ ## [0.1.2] - 2026-02-04
17
+
18
+ ### Added
19
+
20
+ - Added collection-aware paper formatting for bookmark and collection views.
21
+ - Added tests for bookmark collection lookup and collection paper formatting.
22
+ - Added dev tooling dependencies for pytest and ruff.
23
+
24
+ ### Changed
25
+
26
+ - Bookmarks now resolve the "Bookmarks" collection and load papers via `/api/get_collections`.
27
+ - Collection paper retrieval prefers `/api/get_collections` with fallback to legacy endpoints.
28
+ - Cleaned up minor lint issues and restored `_resolve_collection_id` export for tests.
29
+
10
30
  ## [0.1.1] - 2026-02-01
11
31
 
12
32
  ### Added
@@ -54,6 +74,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
74
 
55
75
  - Tracked Python bytecode artifacts (`.pyc`) removed from repository history and ignored via `.gitignore`.
56
76
 
57
- [Unreleased]: https://github.com/mrshu/scholarinboxcli/compare/v0.1.1...HEAD
77
+ [Unreleased]: https://github.com/mrshu/scholarinboxcli/compare/v0.1.3...HEAD
78
+ [0.1.3]: https://github.com/mrshu/scholarinboxcli/releases/tag/v0.1.3
79
+ [0.1.2]: https://github.com/mrshu/scholarinboxcli/releases/tag/v0.1.2
58
80
  [0.1.1]: https://github.com/mrshu/scholarinboxcli/releases/tag/v0.1.1
59
81
  [0.1.0]: https://github.com/mrshu/scholarinboxcli/releases/tag/v0.1.0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scholarinboxcli
3
- Version: 0.1.1
4
- Summary: CLI for Scholar Inbox (authenticated web API)
3
+ Version: 0.1.3
4
+ Summary: Scholar Inbox CLI for digests, search, collections, and bookmarks, for humans and agents alike
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: bibliography,cli,research,scholar
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  # scholarinboxcli
24
24
 
25
- CLI for Scholar Inbox, for humans and agents alike.
25
+ Scholar Inbox CLI: digests, search, collections, and bookmarks for humans and agents alike.
26
26
 
27
27
  ## Installation
28
28
 
@@ -122,6 +122,12 @@ scholarinboxcli collection papers 10759
122
122
  # Similar papers for one or more collections
123
123
  scholarinboxcli collection similar 10759 12345
124
124
 
125
+ # Optional local sorting for display (e.g., newest first)
126
+ scholarinboxcli collection similar "AIAgents" --sort year
127
+
128
+ # Sort ascending instead
129
+ scholarinboxcli collection similar "AIAgents" --sort year --asc
130
+
125
131
  # You can also use collection names (case-insensitive). The CLI will
126
132
  # automatically fetch collection ID mappings from the API when needed.
127
133
  scholarinboxcli collection papers "AIAgents"
@@ -129,6 +135,7 @@ scholarinboxcli collection similar "AIAgents" "Benchmark"
129
135
  ```
130
136
 
131
137
  Collection name matching is exact → prefix → contains. If multiple matches exist, the CLI reports ambiguity and shows candidate IDs.
138
+ `collection similar` supports client-side sorting with `--sort year|title` and optional `--asc`.
132
139
 
133
140
  ## Search
134
141
 
@@ -185,59 +192,12 @@ scholarinboxcli collection papers "AIAgents" --json
185
192
  scholarinboxcli search "diffusion" --json
186
193
  ```
187
194
 
188
- ## Tested (2026-02-01)
189
-
190
- The following commands were exercised against the live API (with a valid magic-link login) to confirm behavior:
191
-
192
- ```bash
193
- scholarinboxcli --help
194
- scholarinboxcli auth status --json
195
- scholarinboxcli digest --date 01-30-2026 --json
196
- scholarinboxcli trending --category ALL --days 7 --json
197
- scholarinboxcli search "transformers" --limit 5 --json
198
- scholarinboxcli semantic "graph neural networks" --limit 5 --json
199
- scholarinboxcli interactions --type all --json
200
- scholarinboxcli bookmark list --json
201
- scholarinboxcli bookmark add 3302478 --json
202
- scholarinboxcli bookmark remove 3302478 --json
203
- scholarinboxcli collection list --json
204
- scholarinboxcli collection list --expanded --json
205
- scholarinboxcli collection papers "AIAgents" --json
206
- scholarinboxcli collection similar "AIAgents" --json
207
- scholarinboxcli conference list --json
208
- scholarinboxcli conference explore --json
209
- ```
210
-
211
195
  ## Notes
212
196
 
213
197
  - Some collection mutations (create/rename/delete/add/remove) rely on best-effort endpoints that may change on the service side. If a mutation fails, try again or use the web UI to validate the current behavior.
198
+ - Bookmarks are stored as a dedicated collection named "Bookmarks" in the web app; `bookmark list` pulls that collection via `/api/get_collections`.
214
199
  - Similar papers for collections uses the server endpoint used by the web UI. Results typically appear under `digest_df` in JSON responses.
215
200
 
216
- ## Publish to PyPI
217
-
218
- ```bash
219
- # 1) Build sdist + wheel
220
- uv run --with build python -m build
221
-
222
- # 2) Validate metadata/rendering
223
- uvx twine check dist/*
224
-
225
- # 3) (Optional) test publish first
226
- uvx twine upload --repository testpypi dist/*
227
-
228
- # 4) Publish to PyPI
229
- uvx twine upload dist/*
230
- ```
231
-
232
- If using an API token:
233
-
234
- ```bash
235
- export TWINE_USERNAME=__token__
236
- export TWINE_PASSWORD=<your-pypi-token>
237
- ```
238
-
239
- Automated publish is also configured via GitHub Actions:
201
+ ## License
240
202
 
241
- - Workflow: `.github/workflows/publish.yml`
242
- - Trigger: push a tag matching `v*` (for example `v0.1.1`)
243
- - Auth: PyPI Trusted Publishing (OIDC)
203
+ MIT. See `LICENSE`.
@@ -1,6 +1,6 @@
1
1
  # scholarinboxcli
2
2
 
3
- CLI for Scholar Inbox, for humans and agents alike.
3
+ Scholar Inbox CLI: digests, search, collections, and bookmarks for humans and agents alike.
4
4
 
5
5
  ## Installation
6
6
 
@@ -100,6 +100,12 @@ scholarinboxcli collection papers 10759
100
100
  # Similar papers for one or more collections
101
101
  scholarinboxcli collection similar 10759 12345
102
102
 
103
+ # Optional local sorting for display (e.g., newest first)
104
+ scholarinboxcli collection similar "AIAgents" --sort year
105
+
106
+ # Sort ascending instead
107
+ scholarinboxcli collection similar "AIAgents" --sort year --asc
108
+
103
109
  # You can also use collection names (case-insensitive). The CLI will
104
110
  # automatically fetch collection ID mappings from the API when needed.
105
111
  scholarinboxcli collection papers "AIAgents"
@@ -107,6 +113,7 @@ scholarinboxcli collection similar "AIAgents" "Benchmark"
107
113
  ```
108
114
 
109
115
  Collection name matching is exact → prefix → contains. If multiple matches exist, the CLI reports ambiguity and shows candidate IDs.
116
+ `collection similar` supports client-side sorting with `--sort year|title` and optional `--asc`.
110
117
 
111
118
  ## Search
112
119
 
@@ -163,59 +170,12 @@ scholarinboxcli collection papers "AIAgents" --json
163
170
  scholarinboxcli search "diffusion" --json
164
171
  ```
165
172
 
166
- ## Tested (2026-02-01)
167
-
168
- The following commands were exercised against the live API (with a valid magic-link login) to confirm behavior:
169
-
170
- ```bash
171
- scholarinboxcli --help
172
- scholarinboxcli auth status --json
173
- scholarinboxcli digest --date 01-30-2026 --json
174
- scholarinboxcli trending --category ALL --days 7 --json
175
- scholarinboxcli search "transformers" --limit 5 --json
176
- scholarinboxcli semantic "graph neural networks" --limit 5 --json
177
- scholarinboxcli interactions --type all --json
178
- scholarinboxcli bookmark list --json
179
- scholarinboxcli bookmark add 3302478 --json
180
- scholarinboxcli bookmark remove 3302478 --json
181
- scholarinboxcli collection list --json
182
- scholarinboxcli collection list --expanded --json
183
- scholarinboxcli collection papers "AIAgents" --json
184
- scholarinboxcli collection similar "AIAgents" --json
185
- scholarinboxcli conference list --json
186
- scholarinboxcli conference explore --json
187
- ```
188
-
189
173
  ## Notes
190
174
 
191
175
  - Some collection mutations (create/rename/delete/add/remove) rely on best-effort endpoints that may change on the service side. If a mutation fails, try again or use the web UI to validate the current behavior.
176
+ - Bookmarks are stored as a dedicated collection named "Bookmarks" in the web app; `bookmark list` pulls that collection via `/api/get_collections`.
192
177
  - Similar papers for collections uses the server endpoint used by the web UI. Results typically appear under `digest_df` in JSON responses.
193
178
 
194
- ## Publish to PyPI
195
-
196
- ```bash
197
- # 1) Build sdist + wheel
198
- uv run --with build python -m build
199
-
200
- # 2) Validate metadata/rendering
201
- uvx twine check dist/*
202
-
203
- # 3) (Optional) test publish first
204
- uvx twine upload --repository testpypi dist/*
205
-
206
- # 4) Publish to PyPI
207
- uvx twine upload dist/*
208
- ```
209
-
210
- If using an API token:
211
-
212
- ```bash
213
- export TWINE_USERNAME=__token__
214
- export TWINE_PASSWORD=<your-pypi-token>
215
- ```
216
-
217
- Automated publish is also configured via GitHub Actions:
179
+ ## License
218
180
 
219
- - Workflow: `.github/workflows/publish.yml`
220
- - Trigger: push a tag matching `v*` (for example `v0.1.1`)
221
- - Auth: PyPI Trusted Publishing (OIDC)
181
+ MIT. See `LICENSE`.
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "scholarinboxcli"
7
- version = "0.1.1"
8
- description = "CLI for Scholar Inbox (authenticated web API)"
7
+ version = "0.1.3"
8
+ description = "Scholar Inbox CLI for digests, search, collections, and bookmarks, for humans and agents alike"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
@@ -36,3 +36,9 @@ packages = ["src/scholarinboxcli"]
36
36
  [tool.pytest.ini_options]
37
37
  testpaths = ["tests"]
38
38
  pythonpath = ["src"]
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=9.0.2",
43
+ "ruff>=0.15.0",
44
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import os
7
6
  import time
8
7
  from urllib.parse import urlparse, parse_qs
@@ -12,7 +11,7 @@ from typing import Any
12
11
 
13
12
  import httpx
14
13
 
15
- from scholarinboxcli.config import Config, load_config, save_config
14
+ from scholarinboxcli.config import load_config, save_config
16
15
  from scholarinboxcli.api import endpoints as ep
17
16
 
18
17
 
@@ -82,6 +81,34 @@ def _is_paper_list(data: Any) -> bool:
82
81
  return False
83
82
 
84
83
 
84
+ def _extract_collections(data: Any) -> list[dict[str, Any]]:
85
+ if isinstance(data, dict):
86
+ for key in ("collections", "expanded_collections"):
87
+ val = data.get(key)
88
+ if isinstance(val, list):
89
+ return [item for item in val if isinstance(item, dict)]
90
+ if isinstance(data, list):
91
+ return [item for item in data if isinstance(item, dict)]
92
+ return []
93
+
94
+
95
+ def _find_collection_id(data: Any, name: str) -> str | None:
96
+ target = name.strip().lower()
97
+ for item in _extract_collections(data):
98
+ cname = str(item.get("name") or item.get("collection_name") or "").strip().lower()
99
+ if cname == target:
100
+ cid = item.get("id") or item.get("collection_id")
101
+ if cid is not None:
102
+ return str(cid)
103
+ if isinstance(data, dict):
104
+ mapping = data.get("collection_names_to_ids_dict")
105
+ if isinstance(mapping, dict):
106
+ for key, value in mapping.items():
107
+ if str(key).strip().lower() == target and value is not None:
108
+ return str(value)
109
+ return None
110
+
111
+
85
112
  class ScholarInboxClient:
86
113
  def __init__(self, api_base: str | None = None, no_retry: bool = False):
87
114
  self.no_retry = no_retry
@@ -142,13 +169,13 @@ class ScholarInboxClient:
142
169
 
143
170
  def _post_first(self, endpoints: list[str], payload: dict[str, Any]) -> Any:
144
171
  last_error: ApiError | None = None
145
- for ep in endpoints:
172
+ for endpoint in endpoints:
146
173
  try:
147
- return self._request("POST", ep, json=payload)
174
+ return self._request("POST", endpoint, json=payload)
148
175
  except ApiError as e:
149
176
  last_error = e
150
177
  try:
151
- return self._request("POST", ep, data=payload)
178
+ return self._request("POST", endpoint, data=payload)
152
179
  except ApiError as e:
153
180
  last_error = e
154
181
  if last_error:
@@ -199,7 +226,23 @@ class ScholarInboxClient:
199
226
  )
200
227
 
201
228
  def bookmarks(self) -> Any:
202
- return self._request("GET", ep.BOOKMARKS)
229
+ data = self.collections_list()
230
+ cid = _find_collection_id(data, "Bookmarks")
231
+ if not cid:
232
+ try:
233
+ data = self.collections_expanded()
234
+ cid = _find_collection_id(data, "Bookmarks")
235
+ except ApiError:
236
+ cid = None
237
+ if not cid:
238
+ try:
239
+ data = self.collections_map()
240
+ cid = _find_collection_id(data, "Bookmarks")
241
+ except ApiError:
242
+ cid = None
243
+ if not cid:
244
+ raise ApiError("Bookmarks collection not found")
245
+ return self.collections_get([cid])
203
246
 
204
247
  def bookmark_add(self, paper_id: str) -> Any:
205
248
  payload = {"bookmarked": True, "id": paper_id}
@@ -227,6 +270,13 @@ class ScholarInboxClient:
227
270
  def collections_map(self) -> Any:
228
271
  return self._request("GET", ep.COLLECTIONS_FALLBACK)
229
272
 
273
+ def collections_get(self, collection_ids: list[str]) -> Any:
274
+ payload = {"collection_ids": collection_ids}
275
+ try:
276
+ return self._request("POST", ep.COLLECTIONS_GET, json=payload)
277
+ except ApiError:
278
+ return self._request("POST", ep.COLLECTIONS_GET, data=payload)
279
+
230
280
  def collection_create(self, name: str) -> Any:
231
281
  payload = {"name": name, "collection_name": name}
232
282
  return self._post_first(list(ep.COLLECTION_CREATE_CANDIDATES), payload)
@@ -253,16 +303,19 @@ class ScholarInboxClient:
253
303
  return self._post_first(list(ep.COLLECTION_REMOVE_PAPER_CANDIDATES), payload)
254
304
 
255
305
  def collection_papers(self, collection_id: str, limit: int | None = None, offset: int | None = None) -> Any:
256
- params: dict[str, Any] = {"collection_id": collection_id}
257
- if limit is not None:
258
- params["limit"] = limit
259
- if offset is not None:
260
- params["offset"] = offset
261
306
  try:
262
- return self._request("GET", ep.COLLECTION_PAPERS, params=params)
307
+ return self.collections_get([collection_id])
263
308
  except ApiError:
264
- # fallback without paging
265
- return self._request("GET", ep.COLLECTION_PAPERS, params={"collection_id": collection_id})
309
+ params: dict[str, Any] = {"collection_id": collection_id}
310
+ if limit is not None:
311
+ params["limit"] = limit
312
+ if offset is not None:
313
+ params["offset"] = offset
314
+ try:
315
+ return self._request("GET", ep.COLLECTION_PAPERS, params=params)
316
+ except ApiError:
317
+ # fallback without paging
318
+ return self._request("GET", ep.COLLECTION_PAPERS, params={"collection_id": collection_id})
266
319
 
267
320
  def collections_similar(self, collection_ids: list[str], limit: int | None = None, offset: int | None = None) -> Any:
268
321
  schemas = [
@@ -14,13 +14,13 @@ SEMANTIC_SEARCH = "/api/semantic-search"
14
14
  INTERACTIONS = "/api/interactions"
15
15
 
16
16
  # Bookmarks
17
- BOOKMARKS = "/api/bookmarks"
18
17
  BOOKMARK_PAPER = "/api/bookmark_paper/"
19
18
 
20
19
  # Collections
21
20
  COLLECTIONS_PRIMARY = "/api/get_all_user_collections"
22
21
  COLLECTIONS_FALLBACK = "/api/collections"
23
22
  COLLECTIONS_EXPANDED = "/api/get_expanded_collections"
23
+ COLLECTIONS_GET = "/api/get_collections"
24
24
  COLLECTION_CREATE_CANDIDATES = (
25
25
  "/api/create_collection/",
26
26
  "/api/collections",
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import typer
6
6
 
7
7
  from scholarinboxcli.commands import auth, bookmarks, collections, conferences, papers
8
- from scholarinboxcli.services.collections import resolve_collection_id as _resolve_collection_id
8
+ from scholarinboxcli.services.collections import resolve_collection_id as _resolve_collection_id # noqa: F401
9
9
 
10
10
 
11
11
  app = typer.Typer(
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import typer
6
6
 
7
7
  from scholarinboxcli.commands.common import print_output, with_client
8
+ from scholarinboxcli.formatters.domain_tables import format_auth_status
8
9
 
9
10
 
10
11
  app = typer.Typer(help="Authentication commands", no_args_is_help=True)
@@ -25,7 +26,7 @@ def auth_login(
25
26
  def auth_status(json_output: bool = typer.Option(False, "--json", help="Output as JSON")):
26
27
  def action(client):
27
28
  data = client.session_info()
28
- print_output(data, json_output, title="Session")
29
+ print_output(data, json_output, title="Session", table_formatter=format_auth_status)
29
30
 
30
31
  with_client(False, action)
31
32
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import typer
6
6
 
7
7
  from scholarinboxcli.commands.common import print_output, with_client
8
+ from scholarinboxcli.formatters.domain_tables import format_collection_papers
8
9
 
9
10
 
10
11
  app = typer.Typer(help="Bookmark commands", no_args_is_help=True)
@@ -17,7 +18,7 @@ def bookmark_list(
17
18
  ):
18
19
  def action(client):
19
20
  data = client.bookmarks()
20
- print_output(data, json_output, title="Bookmarks")
21
+ print_output(data, json_output, title="Bookmarks", table_formatter=format_collection_papers)
21
22
 
22
23
  with_client(no_retry, action)
23
24
 
@@ -7,7 +7,9 @@ from typing import Optional
7
7
  import typer
8
8
 
9
9
  from scholarinboxcli.commands.common import print_output, with_client
10
+ from scholarinboxcli.formatters.domain_tables import format_collection_list, format_collection_papers
10
11
  from scholarinboxcli.services.collections import resolve_collection_id
12
+ from scholarinboxcli.services.paper_sort import sort_paper_response
11
13
 
12
14
 
13
15
  app = typer.Typer(help="Collection commands", no_args_is_help=True)
@@ -21,7 +23,7 @@ def collection_list(
21
23
  ):
22
24
  def action(client):
23
25
  data = client.collections_expanded() if expanded else client.collections_list()
24
- print_output(data, json_output, title="Collections")
26
+ print_output(data, json_output, title="Collections", table_formatter=format_collection_list)
25
27
 
26
28
  with_client(no_retry, action)
27
29
 
@@ -109,7 +111,7 @@ def collection_papers(
109
111
  def action(client):
110
112
  cid = resolve_collection_id(client, collection_id)
111
113
  data = client.collection_papers(cid, limit=limit, offset=offset)
112
- print_output(data, json_output, title=f"Collection {cid}")
114
+ print_output(data, json_output, title=f"Collection {cid}", table_formatter=format_collection_papers)
113
115
 
114
116
  with_client(no_retry, action)
115
117
 
@@ -119,12 +121,15 @@ def collection_similar(
119
121
  collection_ids: list[str] = typer.Argument(..., help="Collection ID(s) or names"),
120
122
  limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Limit results"),
121
123
  offset: Optional[int] = typer.Option(None, "--offset", help="Pagination offset"),
124
+ sort_by: Optional[str] = typer.Option(None, "--sort", help="Sort papers by: year, title"),
125
+ asc: bool = typer.Option(False, "--asc", help="Sort ascending (default is descending)"),
122
126
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
123
127
  no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
124
128
  ):
125
129
  def action(client):
126
130
  resolved = [resolve_collection_id(client, cid) for cid in collection_ids]
127
131
  data = client.collections_similar(resolved, limit=limit, offset=offset)
132
+ data = sort_paper_response(data, sort_by, asc)
128
133
  print_output(data, json_output, title="Similar Papers")
129
134
 
130
135
  with_client(no_retry, action)
@@ -12,12 +12,18 @@ from scholarinboxcli.formatters.json_fmt import format_json
12
12
  from scholarinboxcli.formatters.table import format_table
13
13
 
14
14
 
15
- def print_output(data: Any, use_json: bool, title: str | None = None) -> None:
15
+ def print_output(
16
+ data: Any,
17
+ use_json: bool,
18
+ title: str | None = None,
19
+ table_formatter: Callable[[Any, str | None], str] | None = None,
20
+ ) -> None:
16
21
  if use_json or not sys.stdout.isatty():
17
22
  typer.echo(format_json(data))
18
23
  return
19
24
 
20
- table = format_table(data, title=title)
25
+ formatter = table_formatter or format_table
26
+ table = formatter(data, title)
21
27
  if table == "(no results)":
22
28
  typer.echo(table)
23
29
  return
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import typer
6
6
 
7
7
  from scholarinboxcli.commands.common import print_output, with_client
8
+ from scholarinboxcli.formatters.domain_tables import format_conference_explore, format_conference_list
8
9
 
9
10
 
10
11
  app = typer.Typer(help="Conference commands", no_args_is_help=True)
@@ -17,7 +18,7 @@ def conference_list(
17
18
  ):
18
19
  def action(client):
19
20
  data = client.conference_list()
20
- print_output(data, json_output, title="Conferences")
21
+ print_output(data, json_output, title="Conferences", table_formatter=format_conference_list)
21
22
 
22
23
  with_client(no_retry, action)
23
24
 
@@ -29,6 +30,6 @@ def conference_explore(
29
30
  ):
30
31
  def action(client):
31
32
  data = client.conference_explorer()
32
- print_output(data, json_output, title="Conference Explorer")
33
+ print_output(data, json_output, title="Conference Explorer", table_formatter=format_conference_explore)
33
34
 
34
35
  with_client(no_retry, action)
@@ -0,0 +1,122 @@
1
+ """Domain-specific table formatters for non-paper responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from scholarinboxcli.formatters.table import format_table
11
+
12
+
13
+ def _render(table: Table) -> str:
14
+ console = Console()
15
+ with console.capture() as capture:
16
+ console.print(table)
17
+ return capture.get()
18
+
19
+
20
+ def format_auth_status(data: Any, title: str | None = None) -> str:
21
+ if not isinstance(data, dict):
22
+ return format_table(data, title)
23
+ table = Table(title=title)
24
+ table.add_column("Field", overflow="fold")
25
+ table.add_column("Value", overflow="fold")
26
+ for key, value in data.items():
27
+ table.add_row(str(key), str(value))
28
+ return _render(table)
29
+
30
+
31
+ def format_collection_list(data: Any, title: str | None = None) -> str:
32
+ if isinstance(data, list) and data and isinstance(data[0], str):
33
+ table = Table(title=title)
34
+ table.add_column("#", justify="right")
35
+ table.add_column("Name", overflow="fold")
36
+ for i, name in enumerate(data, start=1):
37
+ table.add_row(str(i), str(name))
38
+ return _render(table)
39
+
40
+ if isinstance(data, list) and data and isinstance(data[0], dict):
41
+ table = Table(title=title)
42
+ table.add_column("ID", overflow="fold")
43
+ table.add_column("Name", overflow="fold")
44
+ for item in data:
45
+ cid = item.get("id") or item.get("collection_id") or ""
46
+ name = item.get("name") or item.get("collection_name") or ""
47
+ table.add_row(str(cid), str(name))
48
+ return _render(table)
49
+
50
+ if isinstance(data, dict) and "expanded_collections" in data:
51
+ return format_collection_list(data.get("expanded_collections"), title)
52
+
53
+ return format_table(data, title)
54
+
55
+
56
+ def format_conference_list(data: Any, title: str | None = None) -> str:
57
+ rows = data.get("conferences") if isinstance(data, dict) else None
58
+ if isinstance(rows, list):
59
+ table = Table(title=title)
60
+ table.add_column("ID", justify="right")
61
+ table.add_column("Short")
62
+ table.add_column("Dates")
63
+ table.add_column("URL")
64
+ for row in rows:
65
+ cid = row.get("conference_id", "")
66
+ short = row.get("short_title") or row.get("full_title") or ""
67
+ start = row.get("start_date") or ""
68
+ end = row.get("end_date") or ""
69
+ dates = f"{start} -> {end}" if (start or end) else ""
70
+ url = row.get("conference_url") or ""
71
+ table.add_row(str(cid), str(short), str(dates), str(url))
72
+ return _render(table)
73
+ return format_table(data, title)
74
+
75
+
76
+ def format_conference_explore(data: Any, title: str | None = None) -> str:
77
+ rows = data.get("conf_data_list") if isinstance(data, dict) else None
78
+ if isinstance(rows, list):
79
+ table = Table(title=title)
80
+ table.add_column("Abbrev")
81
+ table.add_column("Conference")
82
+ table.add_column("Relevance", justify="right")
83
+ table.add_column("Years")
84
+ for row in rows:
85
+ abbrev = row.get("abbreviation") or ""
86
+ name = row.get("conference_name") or ""
87
+ rel = row.get("conf_relevance")
88
+ rel_str = f"{rel:.3f}" if isinstance(rel, (float, int)) else ""
89
+ years = row.get("list_of_years") or []
90
+ years_str = ", ".join(str(y) for y in years[:5])
91
+ table.add_row(str(abbrev), str(name), rel_str, years_str)
92
+ return _render(table)
93
+ return format_table(data, title)
94
+
95
+
96
+ def _extract_collection_papers(data: Any) -> list[dict[str, Any]]:
97
+ if isinstance(data, list):
98
+ return [item for item in data if isinstance(item, dict)]
99
+ if isinstance(data, dict):
100
+ for key in ("papers", "digest_df", "items", "results", "data"):
101
+ val = data.get(key)
102
+ if isinstance(val, list):
103
+ return [item for item in val if isinstance(item, dict)]
104
+ collections = data.get("collections")
105
+ if isinstance(collections, list):
106
+ papers: list[dict[str, Any]] = []
107
+ for collection in collections:
108
+ if isinstance(collection, dict):
109
+ for key in ("papers", "digest_df"):
110
+ val = collection.get(key)
111
+ if isinstance(val, list):
112
+ papers.extend([item for item in val if isinstance(item, dict)])
113
+ if papers:
114
+ return papers
115
+ return []
116
+
117
+
118
+ def format_collection_papers(data: Any, title: str | None = None) -> str:
119
+ papers = _extract_collection_papers(data)
120
+ if papers:
121
+ return format_table(papers, title)
122
+ return format_table(data, title)