scholarinboxcli 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl

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.
@@ -1 +1 @@
1
- __version__ = "0.1.1"
1
+ __version__ = "0.1.2"
@@ -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",
scholarinboxcli/cli.py CHANGED
@@ -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)
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
+ import json
7
+ from datetime import datetime, timezone
6
8
 
7
9
  from rich.console import Console
8
10
  from rich.table import Table
@@ -17,9 +19,41 @@ def _get_authors(paper: dict[str, Any]) -> str:
17
19
  names.append(a)
18
20
  elif isinstance(a, dict):
19
21
  names.append(a.get("name") or a.get("author") or "")
20
- return ", ".join([n for n in names if n])
22
+ return _truncate_text(", ".join([n for n in names if n]), 72)
21
23
  if isinstance(authors, str):
22
- return authors
24
+ result = authors
25
+ else:
26
+ result = ""
27
+ return _truncate_text(result, 72)
28
+
29
+
30
+ def _truncate_text(text: str, max_len: int) -> str:
31
+ if len(text) <= max_len:
32
+ return text
33
+ if max_len <= 3:
34
+ return text[:max_len]
35
+ return text[: max_len - 3] + "..."
36
+
37
+
38
+ def _get_year(paper: dict[str, Any]) -> str:
39
+ year = paper.get("year") or paper.get("publication_year") or paper.get("conference_year")
40
+ if year is not None:
41
+ if isinstance(year, float) and year.is_integer():
42
+ return str(int(year))
43
+ return str(year)
44
+
45
+ publication_date = paper.get("publication_date")
46
+ if isinstance(publication_date, str) and len(publication_date) >= 4 and publication_date[:4].isdigit():
47
+ return publication_date[:4]
48
+ if isinstance(publication_date, (int, float)):
49
+ try:
50
+ # Handle epoch milliseconds seen in some API payloads.
51
+ ts = float(publication_date)
52
+ if ts > 10_000_000_000:
53
+ ts /= 1000.0
54
+ return str(datetime.fromtimestamp(ts, tz=timezone.utc).year)
55
+ except Exception:
56
+ return ""
23
57
  return ""
24
58
 
25
59
 
@@ -34,33 +68,67 @@ def _extract_papers(data: Any) -> list[dict[str, Any]]:
34
68
  return []
35
69
 
36
70
 
37
- def format_table(data: Any, title: str | None = None) -> str:
38
- papers = _extract_papers(data)
39
- if not papers:
40
- return "(no results)"
71
+ def _format_scalar(value: Any) -> str:
72
+ if isinstance(value, (dict, list)):
73
+ return json.dumps(value, ensure_ascii=True)
74
+ return str(value)
75
+
41
76
 
77
+ def _format_kv_table(data: dict[str, Any], title: str | None = None) -> str:
42
78
  table = Table(title=title)
43
- table.add_column("Title", overflow="fold")
44
- table.add_column("Authors", overflow="fold")
45
- table.add_column("Year", justify="right")
46
- table.add_column("Venue", overflow="fold")
47
- table.add_column("ID", overflow="fold")
48
-
49
- for p in papers:
50
- title_val = str(p.get("title") or p.get("paper_title") or "")
51
- authors_val = _get_authors(p)
52
- year_val = str(p.get("year") or p.get("publication_year") or "")
53
- venue_val = str(p.get("venue") or p.get("conference") or p.get("journal") or "")
54
- pid = str(
55
- p.get("paper_id")
56
- or p.get("paperId")
57
- or p.get("id")
58
- or p.get("corpusid")
59
- or ""
60
- )
61
- table.add_row(title_val, authors_val, year_val, venue_val, pid)
79
+ table.add_column("Field", overflow="fold")
80
+ table.add_column("Value", overflow="fold")
81
+ for key, value in data.items():
82
+ table.add_row(str(key), _format_scalar(value))
83
+ console = Console()
84
+ with console.capture() as capture:
85
+ console.print(table)
86
+ return capture.get()
87
+
62
88
 
89
+ def _format_list_table(data: list[Any], title: str | None = None) -> str:
90
+ table = Table(title=title)
91
+ table.add_column("#", justify="right")
92
+ table.add_column("Value", overflow="fold")
93
+ for idx, value in enumerate(data, start=1):
94
+ table.add_row(str(idx), _format_scalar(value))
63
95
  console = Console()
64
96
  with console.capture() as capture:
65
97
  console.print(table)
66
98
  return capture.get()
99
+
100
+
101
+ def format_table(data: Any, title: str | None = None) -> str:
102
+ papers = _extract_papers(data)
103
+ if papers:
104
+ table = Table(title=title, show_lines=True, row_styles=["", "dim"])
105
+ table.add_column("Title", overflow="fold", max_width=68)
106
+ table.add_column("Authors", overflow="fold", max_width=56)
107
+ table.add_column("Year", justify="right")
108
+ table.add_column("Venue", overflow="fold")
109
+ table.add_column("ID", overflow="fold")
110
+
111
+ for p in papers:
112
+ title_val = str(p.get("title") or p.get("paper_title") or "")
113
+ authors_val = _get_authors(p)
114
+ year_val = _get_year(p)
115
+ venue_val = str(p.get("venue") or p.get("conference") or p.get("journal") or "")
116
+ pid = str(
117
+ p.get("paper_id")
118
+ or p.get("paperId")
119
+ or p.get("id")
120
+ or p.get("corpusid")
121
+ or ""
122
+ )
123
+ table.add_row(title_val, authors_val, year_val, venue_val, pid)
124
+
125
+ console = Console()
126
+ with console.capture() as capture:
127
+ console.print(table)
128
+ return capture.get()
129
+
130
+ if isinstance(data, dict) and data:
131
+ return _format_kv_table(data, title=title)
132
+ if isinstance(data, list) and data:
133
+ return _format_list_table(data, title=title)
134
+ return "(no results)"
@@ -2,8 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
6
-
7
5
  from scholarinboxcli.api.client import ApiError, ScholarInboxClient
8
6
 
9
7
 
@@ -0,0 +1,54 @@
1
+ """Helpers for sorting paper-like API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+
9
+ def _year_from_paper(paper: dict[str, Any]) -> int:
10
+ year = paper.get("year") or paper.get("publication_year") or paper.get("conference_year")
11
+ if isinstance(year, int):
12
+ return year
13
+ if isinstance(year, float):
14
+ return int(year)
15
+ if isinstance(year, str) and year.isdigit():
16
+ return int(year)
17
+
18
+ publication_date = paper.get("publication_date")
19
+ if isinstance(publication_date, str) and len(publication_date) >= 4 and publication_date[:4].isdigit():
20
+ return int(publication_date[:4])
21
+ if isinstance(publication_date, (int, float)):
22
+ ts = float(publication_date)
23
+ if ts > 10_000_000_000:
24
+ ts /= 1000.0
25
+ try:
26
+ return datetime.fromtimestamp(ts, tz=timezone.utc).year
27
+ except Exception:
28
+ return 0
29
+ return 0
30
+
31
+
32
+ def sort_paper_response(data: Any, sort_by: str | None, asc: bool) -> Any:
33
+ """Return a sorted copy of known paper-list structures."""
34
+ if not sort_by:
35
+ return data
36
+ if not isinstance(data, dict):
37
+ return data
38
+
39
+ for key in ("digest_df", "papers", "results", "items", "data"):
40
+ rows = data.get(key)
41
+ if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
42
+ continue
43
+
44
+ if sort_by == "year":
45
+ sorted_rows = sorted(rows, key=_year_from_paper, reverse=not asc)
46
+ elif sort_by == "title":
47
+ sorted_rows = sorted(rows, key=lambda p: str(p.get("title") or p.get("paper_title") or "").lower(), reverse=not asc)
48
+ else:
49
+ return data
50
+
51
+ out = dict(data)
52
+ out[key] = sorted_rows
53
+ return out
54
+ return data
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scholarinboxcli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: CLI for Scholar Inbox (authenticated web API)
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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
+ CLI for [Scholar Inbox](https://scholar-inbox.com), 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`.
@@ -0,0 +1,23 @@
1
+ scholarinboxcli/__init__.py,sha256=YvuYzWnKtqBb-IqG8HAu-nhIYAsgj9Vmc_b9o7vO-js,22
2
+ scholarinboxcli/cli.py,sha256=BPXTPhBVO6mDoihXfDIp6zmbTqIcMJ8-VqKiF616DrI,1015
3
+ scholarinboxcli/config.py,sha256=cxp1RzNwzT6Iu225EPvs8NhH2YTTMg9fQBjaYIRVDoc,1545
4
+ scholarinboxcli/api/client.py,sha256=pEUMcoTauEmDdB7weV5m82-yyjUpdcvbRe8oY9Rj8Qo,15142
5
+ scholarinboxcli/api/endpoints.py,sha256=lwSk3PAOITGurTliP9Ko8KSP-DFR8WqKrRxC760rpPc,1515
6
+ scholarinboxcli/commands/__init__.py,sha256=aOPGu7M21finmICNxDSxNBpe1C3Vw2Iimh1UBESfekU,42
7
+ scholarinboxcli/commands/auth.py,sha256=6iwFsuAqiwiRTJbaJFgEt4JXRo1HVrLEF3WSsMo3GHA,1024
8
+ scholarinboxcli/commands/bookmarks.py,sha256=UbR99VrrCyCdu1EhyHIgwn3sPOYKfRc4jF4gUx1fU6w,1598
9
+ scholarinboxcli/commands/collections.py,sha256=pBZFXi_e3Dto-4E2GS6pgIrcVMzWpWaGLNTLB5My5sQ,5634
10
+ scholarinboxcli/commands/common.py,sha256=5EcH0RDuX8DKzyB6lrPqGEok5TKx8SSV37mXczD9dqI,1654
11
+ scholarinboxcli/commands/conferences.py,sha256=e1QjX9R9WZhkSkPXKgoxuRPbl612qEVbsjX90zEgKSw,1183
12
+ scholarinboxcli/commands/papers.py,sha256=mdb3-_iecy_pjavPL_K0cXfVnDsQMU93SbD8T3hJY10,3968
13
+ scholarinboxcli/formatters/domain_tables.py,sha256=9RZcq-k9YycO64FvQAblE1dzYmytSj7NBasBEZeDW9I,4702
14
+ scholarinboxcli/formatters/json_fmt.py,sha256=Ntcp4EqHugCXg79RIF62c7QHa-lexptLDDTT3IEP65U,197
15
+ scholarinboxcli/formatters/table.py,sha256=G0ehvuGgo77XX5kw_9u5DEf8oUTIR-fOJ5rhYQvMEkc,4587
16
+ scholarinboxcli/services/__init__.py,sha256=i-8EAvmxCDyYEjmfILYRcUHUh7ryY5hFS4PZARScFXo,56
17
+ scholarinboxcli/services/collections.py,sha256=rz9rqgf-nfzBl-scBhweA-22--Z70qKEyBhW84os4bY,4353
18
+ scholarinboxcli/services/paper_sort.py,sha256=HYoIHry14QIUsfd9D9kZvy7phVhvCnwEjKHv6zO-0IU,1809
19
+ scholarinboxcli-0.1.2.dist-info/METADATA,sha256=oqmUh6jnAapWYtUA13efz3k4r1Q4kQutPP_nGEOzS00,5328
20
+ scholarinboxcli-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
21
+ scholarinboxcli-0.1.2.dist-info/entry_points.txt,sha256=iescoEMF_CPwSNSmvlzNDl5pT2VpBL9_1bIq_FFIAKc,60
22
+ scholarinboxcli-0.1.2.dist-info/licenses/LICENSE,sha256=sP1DPhQGvTFx1mH4JhNuczMMbApQgFvhBcVjZKM79gU,1068
23
+ scholarinboxcli-0.1.2.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- scholarinboxcli/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
2
- scholarinboxcli/cli.py,sha256=MEpkfuWXC4U_GjT85ue-LRTE_FejFvYhxwtnYbWr18k,1001
3
- scholarinboxcli/config.py,sha256=cxp1RzNwzT6Iu225EPvs8NhH2YTTMg9fQBjaYIRVDoc,1545
4
- scholarinboxcli/api/client.py,sha256=8Wd7CzAuku2gP2lrdvv2s81JV5Gr6pqGSggnKdk-7-8,13033
5
- scholarinboxcli/api/endpoints.py,sha256=aw686_VtgbJelcGbw__cG8qtHVm_f9pGeNEqeuWeOuo,1503
6
- scholarinboxcli/commands/__init__.py,sha256=aOPGu7M21finmICNxDSxNBpe1C3Vw2Iimh1UBESfekU,42
7
- scholarinboxcli/commands/auth.py,sha256=D2CXLVMwJTxU3_OLNDZ9YGG-mYzrZ4agTKRLeIS3wx4,916
8
- scholarinboxcli/commands/bookmarks.py,sha256=S0ah6woZrS3p_T4r-gAZZrTSr8GL2EQb6cEtaCKXD0o,1478
9
- scholarinboxcli/commands/collections.py,sha256=rfSh98wT-yrDvYjl_1UcbVvEiw-h5yaHCv9WTkNHMWg,5139
10
- scholarinboxcli/commands/common.py,sha256=KgudFViqscHOCfLe_KPADGr_E_d1KKBoW1ZGje3gHuU,1531
11
- scholarinboxcli/commands/conferences.py,sha256=wVs-YpdkLVxlwqu3_rYHrJl4j1tIb8exvsp7t96qHqo,997
12
- scholarinboxcli/commands/papers.py,sha256=mdb3-_iecy_pjavPL_K0cXfVnDsQMU93SbD8T3hJY10,3968
13
- scholarinboxcli/formatters/json_fmt.py,sha256=Ntcp4EqHugCXg79RIF62c7QHa-lexptLDDTT3IEP65U,197
14
- scholarinboxcli/formatters/table.py,sha256=GnzpmSJ7M_yq-R-c8no8SE9vXbycvWWPUu6hV4tcJAA,2133
15
- scholarinboxcli/services/__init__.py,sha256=i-8EAvmxCDyYEjmfILYRcUHUh7ryY5hFS4PZARScFXo,56
16
- scholarinboxcli/services/collections.py,sha256=mQ67tEmNwspOeWg9NyIIovpqkLMP2ANi4Jso9SHXi5A,4377
17
- scholarinboxcli-0.1.1.dist-info/METADATA,sha256=E1IrW2o5X9P_-6AJDzcS8K6YvyrGrADdHBgVmAVlcPU,6285
18
- scholarinboxcli-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
- scholarinboxcli-0.1.1.dist-info/entry_points.txt,sha256=iescoEMF_CPwSNSmvlzNDl5pT2VpBL9_1bIq_FFIAKc,60
20
- scholarinboxcli-0.1.1.dist-info/licenses/LICENSE,sha256=sP1DPhQGvTFx1mH4JhNuczMMbApQgFvhBcVjZKM79gU,1068
21
- scholarinboxcli-0.1.1.dist-info/RECORD,,