pub-analyzer 0.1.2__py3-none-any.whl → 0.3.0__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.

Potentially problematic release.


This version of pub-analyzer might be problematic. Click here for more details.

Files changed (53) hide show
  1. pub_analyzer/css/body.tcss +48 -35
  2. pub_analyzer/css/buttons.tcss +0 -1
  3. pub_analyzer/css/collapsible.tcss +31 -0
  4. pub_analyzer/css/main.tcss +4 -0
  5. pub_analyzer/css/summary.tcss +75 -0
  6. pub_analyzer/internal/identifier.py +36 -10
  7. pub_analyzer/internal/render.py +1 -1
  8. pub_analyzer/internal/report.py +177 -53
  9. pub_analyzer/internal/templates/author/{author_resume.typ → author_summary.typ} +4 -3
  10. pub_analyzer/internal/templates/author/report.typ +4 -3
  11. pub_analyzer/internal/templates/author/sources.typ +7 -5
  12. pub_analyzer/internal/templates/author/works.typ +12 -12
  13. pub_analyzer/internal/templates/author/works_extended.typ +4 -4
  14. pub_analyzer/main.py +6 -7
  15. pub_analyzer/models/author.py +20 -28
  16. pub_analyzer/models/concept.py +19 -0
  17. pub_analyzer/models/institution.py +22 -5
  18. pub_analyzer/models/report.py +14 -14
  19. pub_analyzer/models/source.py +59 -3
  20. pub_analyzer/models/topic.py +59 -0
  21. pub_analyzer/models/work.py +30 -7
  22. pub_analyzer/widgets/author/cards.py +15 -14
  23. pub_analyzer/widgets/author/core.py +80 -115
  24. pub_analyzer/widgets/author/tables.py +1 -1
  25. pub_analyzer/widgets/common/__init__.py +6 -6
  26. pub_analyzer/widgets/common/filesystem.py +16 -13
  27. pub_analyzer/widgets/common/filters.py +111 -0
  28. pub_analyzer/widgets/common/input.py +14 -5
  29. pub_analyzer/widgets/common/selector.py +1 -1
  30. pub_analyzer/widgets/common/summary.py +7 -0
  31. pub_analyzer/widgets/institution/cards.py +13 -15
  32. pub_analyzer/widgets/institution/core.py +81 -115
  33. pub_analyzer/widgets/institution/tables.py +1 -1
  34. pub_analyzer/widgets/report/cards.py +33 -31
  35. pub_analyzer/widgets/report/concept.py +47 -0
  36. pub_analyzer/widgets/report/core.py +90 -20
  37. pub_analyzer/widgets/report/export.py +2 -2
  38. pub_analyzer/widgets/report/grants.py +46 -0
  39. pub_analyzer/widgets/report/locations.py +14 -12
  40. pub_analyzer/widgets/report/source.py +22 -14
  41. pub_analyzer/widgets/report/topic.py +55 -0
  42. pub_analyzer/widgets/report/work.py +70 -34
  43. pub_analyzer/widgets/search/__init__.py +4 -4
  44. pub_analyzer/widgets/search/results.py +15 -16
  45. pub_analyzer/widgets/sidebar.py +11 -9
  46. {pub_analyzer-0.1.2.dist-info → pub_analyzer-0.3.0.dist-info}/METADATA +31 -7
  47. pub_analyzer-0.3.0.dist-info/RECORD +69 -0
  48. {pub_analyzer-0.1.2.dist-info → pub_analyzer-0.3.0.dist-info}/WHEEL +1 -1
  49. pub_analyzer/css/author.tcss +0 -78
  50. pub_analyzer/css/institution.tcss +0 -78
  51. pub_analyzer-0.1.2.dist-info/RECORD +0 -62
  52. {pub_analyzer-0.1.2.dist-info → pub_analyzer-0.3.0.dist-info}/LICENSE +0 -0
  53. {pub_analyzer-0.1.2.dist-info → pub_analyzer-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -1,14 +1,21 @@
1
1
  """Institutions models from OpenAlex API Schema definition."""
2
2
 
3
3
  from enum import Enum
4
+ from typing import TypeAlias
4
5
 
5
6
  from pydantic import BaseModel, Field, HttpUrl
6
7
 
8
+ InstitutionOpenAlexID: TypeAlias = HttpUrl
9
+ """OpenAlex ID for Institution Objects with the format `https://openalex.org/I000000000`"""
10
+
11
+ InstitutionOpenAlexKey: TypeAlias = str
12
+ """OpenAlex Institution entity Key with the format `I000000000`"""
13
+
7
14
 
8
15
  class InstitutionIDs(BaseModel):
9
16
  """IDs from a Institution."""
10
17
 
11
- openalex: HttpUrl
18
+ openalex: InstitutionOpenAlexID
12
19
  grid: str | None = None
13
20
  ror: HttpUrl | None = None
14
21
  wikipedia: HttpUrl | None = None
@@ -70,21 +77,31 @@ class InstitutionRole(BaseModel):
70
77
  works_count: int
71
78
 
72
79
 
80
+ class International(BaseModel):
81
+ """The institution's display name in different languages."""
82
+
83
+ display_name: dict[str, str]
84
+
85
+
73
86
  class Institution(BaseModel):
74
87
  """Universities and other organizations to which authors claim affiliations."""
75
88
 
76
- id: HttpUrl
89
+ id: InstitutionOpenAlexID
77
90
  ids: InstitutionIDs
78
91
 
79
92
  display_name: str
80
93
  country_code: str
81
94
  type: InstitutionType
82
95
  homepage_url: HttpUrl | None = None
96
+ image_url: HttpUrl | None = None
97
+
98
+ display_name_acronyms: list[str]
99
+ international: International
83
100
 
84
101
  works_count: int
85
102
  cited_by_count: int
86
- counts_by_year: list[InstitutionYearCount]
87
103
  summary_stats: InstitutionSummaryStats
104
+ counts_by_year: list[InstitutionYearCount]
88
105
 
89
106
  geo: InstitutionGeo
90
107
  roles: list[InstitutionRole]
@@ -95,7 +112,7 @@ class Institution(BaseModel):
95
112
  class DehydratedInstitution(BaseModel):
96
113
  """Stripped-down Institution Model."""
97
114
 
98
- id: HttpUrl
115
+ id: InstitutionOpenAlexID
99
116
  ror: str
100
117
  display_name: str
101
118
  country_code: str
@@ -105,7 +122,7 @@ class DehydratedInstitution(BaseModel):
105
122
  class InstitutionResult(BaseModel):
106
123
  """Institution result Model resulting from a search in OpenAlex."""
107
124
 
108
- id: HttpUrl
125
+ id: InstitutionOpenAlexID
109
126
  display_name: str
110
127
  hint: str | None = None
111
128
 
@@ -6,7 +6,7 @@ from pydantic import BaseModel
6
6
 
7
7
  from .author import Author
8
8
  from .institution import Institution
9
- from .source import DehydratedSource
9
+ from .source import Source
10
10
  from .work import OpenAccessStatus, Work
11
11
 
12
12
 
@@ -24,7 +24,7 @@ class CitationReport(BaseModel):
24
24
  citation_type: CitationType
25
25
 
26
26
 
27
- class CitationResume(BaseModel):
27
+ class CitationSummary(BaseModel):
28
28
  """Summary of citation information in all works."""
29
29
 
30
30
  type_a_count: int = 0
@@ -38,7 +38,7 @@ class CitationResume(BaseModel):
38
38
  self.type_b_count += 1
39
39
 
40
40
 
41
- class OpenAccessResume(BaseModel):
41
+ class OpenAccessSummary(BaseModel):
42
42
  """Open Access Type counter."""
43
43
 
44
44
  gold: int = 0
@@ -75,13 +75,13 @@ class WorkReport(BaseModel):
75
75
  work: Work
76
76
  cited_by: list[CitationReport]
77
77
 
78
- citation_resume: CitationResume
78
+ citation_summary: CitationSummary
79
79
 
80
80
 
81
- class SourcesResume(BaseModel):
81
+ class SourcesSummary(BaseModel):
82
82
  """Sources model with stats."""
83
83
 
84
- sources: list[DehydratedSource]
84
+ sources: list[Source]
85
85
 
86
86
 
87
87
  class AuthorReport(BaseModel):
@@ -90,10 +90,10 @@ class AuthorReport(BaseModel):
90
90
  author: Author
91
91
  works: list[WorkReport]
92
92
 
93
- citation_resume: CitationResume
94
- open_access_resume: OpenAccessResume
95
- works_type_resume: list[WorkTypeCounter]
96
- sources_resume: SourcesResume
93
+ citation_summary: CitationSummary
94
+ open_access_summary: OpenAccessSummary
95
+ works_type_summary: list[WorkTypeCounter]
96
+ sources_summary: SourcesSummary
97
97
 
98
98
 
99
99
  class InstitutionReport(BaseModel):
@@ -102,7 +102,7 @@ class InstitutionReport(BaseModel):
102
102
  institution: Institution
103
103
  works: list[WorkReport]
104
104
 
105
- citation_resume: CitationResume
106
- open_access_resume: OpenAccessResume
107
- works_type_resume: list[WorkTypeCounter]
108
- sources_resume: SourcesResume
105
+ citation_summary: CitationSummary
106
+ open_access_summary: OpenAccessSummary
107
+ works_type_summary: list[WorkTypeCounter]
108
+ sources_summary: SourcesSummary
@@ -1,21 +1,77 @@
1
1
  """Sources models from OpenAlex API Schema definition."""
2
2
 
3
- from pydantic import BaseModel, HttpUrl
3
+ from pydantic import BaseModel, Field, HttpUrl
4
+
5
+
6
+ class SourceSummaryStats(BaseModel):
7
+ """Citation metrics for this Source."""
8
+
9
+ two_yr_mean_citedness: float = Field(..., alias="2yr_mean_citedness")
10
+ """The 2-year mean citedness for this source. Also known as impact factor."""
11
+ h_index: int
12
+ """The h-index for this source."""
13
+ i10_index: int
14
+ """The i-10 index for this source."""
15
+
16
+
17
+ class SourceYearCount(BaseModel):
18
+ """Summary of published papers and number of citations in a year."""
19
+
20
+ year: int
21
+ """Year."""
22
+ works_count: int
23
+ """The number of Works this source hosts in this year."""
24
+ cited_by_count: int
25
+ """The total number of Works that cite a Work hosted in this source in this year."""
4
26
 
5
27
 
6
28
  class DehydratedSource(BaseModel):
7
29
  """Stripped-down Source Model."""
8
30
 
9
31
  id: HttpUrl
32
+ """The OpenAlex ID for this source."""
10
33
  display_name: str
34
+ """The name of the source."""
11
35
 
12
36
  issn_l: str | None = None
37
+ """The ISSN-L identifying this source. The ISSN-L designating a single canonical ISSN
38
+ for all media versions of the title. It's usually the same as the print ISSN.
39
+ """
13
40
  issn: list[str] | None = None
41
+ """The ISSNs used by this source. An ISSN identifies all continuing resources, irrespective
42
+ of their medium (print or electronic). [More info](https://www.issn.org/){target=_blank}.
43
+ """
14
44
 
15
- is_oa: bool | None = False
16
- is_in_doaj: bool | None = False
45
+ is_oa: bool
46
+ """Whether this is currently fully-open-access source."""
47
+ is_in_doaj: bool
48
+ """Whether this is a journal listed in the [Directory of Open Access Journals](https://doaj.org){target=_blank} (DOAJ)."""
17
49
 
18
50
  host_organization: HttpUrl | None = None
51
+ """The host organization for this source as an OpenAlex ID. This will be an
52
+ [Institution.id][pub_analyzer.models.institution.Institution.id] if the source is a repository,
53
+ and a Publisher.id if the source is a journal, conference, or eBook platform
54
+ """
19
55
  host_organization_name: str | None = None
56
+ """The display_name from the host_organization."""
20
57
 
21
58
  type: str
59
+ """The type of source, which will be one of: `journal`, `repository`, `conference`,
60
+ `ebook platform`, or `book series`.
61
+ """
62
+
63
+
64
+ class Source(DehydratedSource):
65
+ """Where works are hosted."""
66
+
67
+ homepage_url: HttpUrl | None = None
68
+ """The homepage for this source's website."""
69
+
70
+ is_in_doaj: bool
71
+ """Whether this is a journal listed in the Directory of Open Access Journals (DOAJ)."""
72
+
73
+ summary_stats: SourceSummaryStats
74
+ """Citation metrics for this source."""
75
+
76
+ counts_by_year: list[SourceYearCount]
77
+ """works_count and cited_by_count for each of the last ten years, binned by year."""
@@ -0,0 +1,59 @@
1
+ """Topics models from OpenAlex API Schema definition."""
2
+
3
+ from pydantic import BaseModel, HttpUrl
4
+
5
+
6
+ class TopicIDs(BaseModel):
7
+ """External identifiers for a Topic."""
8
+
9
+ openalex: HttpUrl
10
+ """The OpenAlex ID for this Topic."""
11
+ wikipedia: HttpUrl | None = None
12
+ """This topic's Wikipedia page URL."""
13
+
14
+
15
+ class TopicLevel(BaseModel):
16
+ """Topic level information."""
17
+
18
+ id: HttpUrl
19
+ """ID for the topic level. For more info, consult the
20
+ [OpenAlex topic mapping table](https://docs.google.com/spreadsheets/d/1v-MAq64x4YjhO7RWcB-yrKV5D_2vOOsxl4u6GBKEXY8/){target=_blank}.
21
+ """
22
+ display_name: str
23
+ """The English-language label of the level."""
24
+
25
+
26
+ class DehydratedTopic(BaseModel):
27
+ """Stripped-down Topic Model."""
28
+
29
+ id: HttpUrl
30
+ """The OpenAlex ID for this Topic."""
31
+ display_name: str
32
+ """The English-language label of the topic."""
33
+ score: float
34
+ """The strength of the connection between the work and this topic (higher is stronger)."""
35
+
36
+ domain: TopicLevel
37
+ """The highest level in the Topics structure."""
38
+ field: TopicLevel
39
+ """The second-highest level in the Topics structure."""
40
+ subfield: TopicLevel
41
+ """The third-highest level in the Topics structure."""
42
+
43
+
44
+ class Topic(DehydratedTopic):
45
+ """Labels which can be used to describe what a paper is about."""
46
+
47
+ ids: TopicIDs
48
+ """All the external identifiers for a Topic."""
49
+ description: str
50
+ """A description of this topic, generated by AI."""
51
+ keywords: list[str]
52
+ """Keywords consisting of one or several words each, meant to represent
53
+ the content of the papers in the topic.
54
+ """
55
+
56
+ works_count: int
57
+ """The number of works tagged with this topic."""
58
+ cited_by_count: int
59
+ """The number of citations to works that have been tagged with this topic."""
@@ -6,7 +6,9 @@ from typing import Any
6
6
  from pydantic import BaseModel, HttpUrl, field_validator
7
7
 
8
8
  from .author import DehydratedAuthor
9
+ from .concept import DehydratedConcept
9
10
  from .source import DehydratedSource
11
+ from .topic import DehydratedTopic
10
12
 
11
13
 
12
14
  class WorkIDs(BaseModel):
@@ -34,7 +36,7 @@ class Location(BaseModel):
34
36
  license: str | None
35
37
  pdf_url: str | None
36
38
  version: WorkDrivenVersion | None
37
- source: DehydratedSource | None = None
39
+ source: DehydratedSource | None = None
38
40
 
39
41
 
40
42
  class OpenAccessStatus(str, Enum):
@@ -72,6 +74,22 @@ class ArticleProcessingCharge(BaseModel):
72
74
  value_usd: int | None
73
75
 
74
76
 
77
+ class Grant(BaseModel):
78
+ """Grant Model Object from OpenAlex API definition."""
79
+
80
+ funder: HttpUrl
81
+ funder_display_name: str
82
+ award_id: str | None = None
83
+
84
+
85
+ class Keyword(BaseModel):
86
+ """Keyword extracted from the work's title and confidence score."""
87
+
88
+ id: HttpUrl
89
+ display_name: str
90
+ score: float
91
+
92
+
75
93
  class Work(BaseModel):
76
94
  """Work Model Object from OpenAlex API definition."""
77
95
 
@@ -97,6 +115,11 @@ class Work(BaseModel):
97
115
  To use a verified number that respects the applied filters use [WorkReport][pub_analyzer.models.report.WorkReport].
98
116
  """
99
117
 
118
+ grants: list[Grant]
119
+ keywords: list[Keyword]
120
+ concepts: list[DehydratedConcept]
121
+ topics: list[DehydratedTopic]
122
+
100
123
  referenced_works: list[HttpUrl]
101
124
  cited_by_api_url: HttpUrl
102
125
 
@@ -105,20 +128,20 @@ class Work(BaseModel):
105
128
  apc_paid: ArticleProcessingCharge | None = None
106
129
  """APC actually paid by authors."""
107
130
 
108
- @field_validator('locations', mode='before')
131
+ @field_validator("locations", mode="before")
109
132
  def valid_locations(cls, locations: list[dict[str, Any]]) -> list[dict[str, Any]]:
110
133
  """Skip locations that do not contain enough data."""
111
- return [location for location in locations if location['landing_page_url'] is not None]
134
+ return [location for location in locations if location["landing_page_url"] is not None]
112
135
 
113
- @field_validator('primary_location', 'best_oa_location', mode='before')
136
+ @field_validator("primary_location", "best_oa_location", mode="before")
114
137
  def valid_location(cls, location: dict[str, Any]) -> dict[str, Any] | None:
115
138
  """Skip location that do not contain enough data."""
116
- if location and location['landing_page_url'] is None:
139
+ if location and location["landing_page_url"] is None:
117
140
  return None
118
141
  else:
119
142
  return location
120
143
 
121
- @field_validator('authorships', mode='before')
144
+ @field_validator("authorships", mode="before")
122
145
  def valid_authorships(cls, authorships: list[dict[str, Any]]) -> list[dict[str, Any]]:
123
146
  """Skip authorships that do not contain enough data."""
124
- return [authorship for authorship in authorships if authorship['author'].get("id") is not None]
147
+ return [authorship for authorship in authorships if authorship["author"].get("id") is not None]
@@ -19,12 +19,12 @@ class CitationMetricsCard(Card):
19
19
 
20
20
  def compose(self) -> ComposeResult:
21
21
  """Compose card."""
22
- yield Label('[italic]Citation metrics:[/italic]', classes="card-title")
22
+ yield Label("[italic]Citation metrics:[/italic]", classes="card-title")
23
23
 
24
- with Vertical(classes='card-container'):
25
- yield Label(f'[bold]2-year mean:[/bold] {self.author.summary_stats.two_yr_mean_citedness:.5f}')
26
- yield Label(f'[bold]h-index:[/bold] {self.author.summary_stats.h_index}')
27
- yield Label(f'[bold]i10 index:[/bold] {self.author.summary_stats.i10_index}')
24
+ with Vertical(classes="card-container"):
25
+ yield Label(f"[bold]2-year mean:[/bold] {self.author.summary_stats.two_yr_mean_citedness:.5f}")
26
+ yield Label(f"[bold]h-index:[/bold] {self.author.summary_stats.h_index}")
27
+ yield Label(f"[bold]i10 index:[/bold] {self.author.summary_stats.i10_index}")
28
28
 
29
29
 
30
30
  class IdentifiersCard(Card):
@@ -36,7 +36,7 @@ class IdentifiersCard(Card):
36
36
 
37
37
  def compose(self) -> ComposeResult:
38
38
  """Compose card."""
39
- yield Label('[italic]Identifiers:[/italic]', classes="card-title")
39
+ yield Label("[italic]Identifiers:[/italic]", classes="card-title")
40
40
 
41
41
  for platform, platform_url in self.author.ids.model_dump().items():
42
42
  if platform_url:
@@ -52,13 +52,14 @@ class LastInstitutionCard(Card):
52
52
 
53
53
  def compose(self) -> ComposeResult:
54
54
  """Compose card."""
55
- yield Label('[italic]Last Institution:[/italic]', classes="card-title")
55
+ yield Label("[italic]Last Institution:[/italic]", classes="card-title")
56
56
 
57
- if self.author.last_known_institution:
58
- ror = self.author.last_known_institution.ror
59
- institution_name = self.author.last_known_institution.display_name
57
+ if self.author.last_known_institutions:
58
+ last_known_institution = self.author.last_known_institutions[0]
59
+ ror = last_known_institution.ror
60
+ institution_name = last_known_institution.display_name
60
61
 
61
- with Vertical(classes='card-container'):
62
- yield Label(f'''[bold]Name:[/bold] [@click=app.open_link('{quote(str(ror))}')]{institution_name}[/]''')
63
- yield Label(f'[bold]Country:[/bold] {self.author.last_known_institution.country_code}')
64
- yield Label(f'[bold]Type:[/bold] {self.author.last_known_institution.type.value}')
62
+ with Vertical(classes="card-container"):
63
+ yield Label(f"""[bold]Name:[/bold] [@click=app.open_link('{quote(str(ror))}')]{institution_name}[/]""")
64
+ yield Label(f"[bold]Country:[/bold] {last_known_institution.country_code}")
65
+ yield Label(f"[bold]Type:[/bold] {last_known_institution.type.value}")
@@ -1,83 +1,84 @@
1
1
  """Module with Widgets that allows to display the complete information of an Author using OpenAlex."""
2
2
 
3
- import datetime
3
+ from typing import Any
4
4
 
5
5
  import httpx
6
6
  from textual import on
7
7
  from textual.app import ComposeResult
8
- from textual.containers import Container, Horizontal, Vertical, VerticalScroll
9
- from textual.widgets import Button, Checkbox, Label, LoadingIndicator, Static
8
+ from textual.containers import Container, Horizontal, Vertical
9
+ from textual.widgets import Button, Collapsible, Label, Static
10
10
 
11
11
  from pub_analyzer.internal.identifier import get_author_id
12
12
  from pub_analyzer.models.author import Author, AuthorResult
13
- from pub_analyzer.widgets.common import DateInput
13
+ from pub_analyzer.widgets.common.filters import DateRangeFilter, Filter
14
+ from pub_analyzer.widgets.common.summary import SummaryWidget
14
15
  from pub_analyzer.widgets.report.core import CreateAuthorReportWidget
15
16
 
16
17
  from .cards import CitationMetricsCard, IdentifiersCard, LastInstitutionCard
17
18
  from .tables import AuthorWorksByYearTable
18
19
 
19
20
 
20
- class AuthorResumeWidget(Static):
21
- """Author info resume."""
21
+ class _AuthorSummaryWidget(Static):
22
+ """Author info summary."""
22
23
 
23
- def __init__(self, author_result: AuthorResult) -> None:
24
- self.author_result = author_result
25
- self.author: Author
24
+ def __init__(self, author: Author) -> None:
25
+ self.author = author
26
26
  super().__init__()
27
27
 
28
28
  def compose(self) -> ComposeResult:
29
- """Create main info container and showing a loading animation."""
30
- yield LoadingIndicator()
31
- yield VerticalScroll(id="main-container")
29
+ """Compose author info."""
30
+ is_report_not_available = self.author.works_count < 1
32
31
 
33
- def on_mount(self) -> None:
34
- """Hiding the empty container and calling the data in the background."""
35
- self.query_one("#main-container", VerticalScroll).display = False
36
- self.run_worker(self.load_data(), exclusive=True)
32
+ # Compose Cards
33
+ with Vertical(classes="block-container"):
34
+ yield Label("[bold]Author info:[/bold]", classes="block-title")
37
35
 
36
+ with Horizontal(classes="cards-container"):
37
+ yield LastInstitutionCard(author=self.author)
38
+ yield IdentifiersCard(author=self.author)
39
+ yield CitationMetricsCard(author=self.author)
38
40
 
39
- @on(Checkbox.Changed, "#filters-checkbox")
40
- async def toggle_filter(self, event: Checkbox.Changed) -> None:
41
- """Toggle filters."""
42
- if event.checkbox.value:
43
- for date_input in self.query(DateInput).results(DateInput):
44
- date_input.disabled = False
45
- date_input.value = ""
46
- else:
47
- for date_input in self.query(DateInput).results(DateInput):
48
- date_input.disabled = True
49
- date_input.value = ""
50
- self.query_one("#make-report-button", Button).disabled = False
51
-
52
- @on(DateInput.Changed)
53
- async def enable_make_report(self, event: DateInput.Changed) -> None:
54
- """Enable make report button."""
55
- checkbox = self.query_one("#filters-checkbox", Checkbox)
56
-
57
- if event.validation_result:
58
- if not event.validation_result.is_valid and checkbox.value:
59
- self.query_one("#make-report-button", Button).disabled = True
60
- else:
61
- self.query_one("#make-report-button", Button).disabled = False
41
+ # Work realeted info
42
+ with Vertical(classes="block-container"):
43
+ yield Label("[bold]Work Info:[/bold]", classes="block-title")
62
44
 
63
- @on(Button.Pressed, "#make-report-button")
64
- async def make_report(self) -> None:
65
- """Make the author report."""
66
- checkbox = self.query_one("#filters-checkbox", Checkbox)
67
- from_input = self.query_one("#from-date", DateInput)
68
- to_input = self.query_one("#to-date", DateInput)
45
+ with Horizontal(classes="info-container"):
46
+ yield Label(f"[bold]Cited by count:[/bold] {self.author.cited_by_count}")
47
+ yield Label(f"[bold]Works count:[/bold] {self.author.works_count}")
69
48
 
70
- if checkbox.value and (from_input.value or to_input.value):
71
- date_format = "%Y-%m-%d"
72
- from_date = datetime.datetime.strptime(from_input.value, date_format) if from_input.value else None
73
- to_date = datetime.datetime.strptime(to_input.value, date_format) if to_input.value else None
49
+ # Count by year table section
50
+ with Container(classes="table-container"):
51
+ yield AuthorWorksByYearTable(author=self.author)
74
52
 
75
- report_widget = CreateAuthorReportWidget(author=self.author, from_date=from_date, to_date=to_date)
76
- else:
77
- report_widget = CreateAuthorReportWidget(author=self.author)
53
+ # Make report section
54
+ with Vertical(classes="block-container", disabled=is_report_not_available):
55
+ yield Label("[bold]Make report:[/bold]", classes="block-title")
78
56
 
79
- await self.app.query_one("MainContent").mount(report_widget)
80
- await self.app.query_one("AuthorResumeWidget").remove()
57
+ # Filters
58
+ with Collapsible(title="Report filters.", classes="filter-collapsible"):
59
+ # Author publication Date Range
60
+ yield DateRangeFilter(checkbox_label="Publication date range:", id="author-date-range-filter")
61
+
62
+ # Cite Date Range
63
+ yield DateRangeFilter(checkbox_label="Cited date range:", id="cited-date-range-filter")
64
+
65
+ # Button
66
+ with Vertical(classes="button-container"):
67
+ yield Button("Make Report", variant="primary", id="make-report-button")
68
+
69
+
70
+ class AuthorSummaryWidget(SummaryWidget):
71
+ """Author info summary container."""
72
+
73
+ def __init__(self, author_result: AuthorResult) -> None:
74
+ self.author_result = author_result
75
+ self.author: Author
76
+ super().__init__()
77
+
78
+ def on_mount(self) -> None:
79
+ """Hide the empty container and call data in the background."""
80
+ self.loading = True
81
+ self.run_worker(self.load_data(), exclusive=True)
81
82
 
82
83
  async def _get_info(self) -> None:
83
84
  """Query OpenAlex API."""
@@ -91,67 +92,31 @@ class AuthorResumeWidget(Static):
91
92
  async def load_data(self) -> None:
92
93
  """Query OpenAlex API and composing the widget."""
93
94
  await self._get_info()
94
- container = self.query_one("#main-container", VerticalScroll)
95
- is_report_not_available = self.author.works_count < 1
95
+ await self.mount(_AuthorSummaryWidget(author=self.author))
96
96
 
97
- # Compose Cards
98
- await container.mount(
99
- Vertical(
100
- Label('[bold]Author info:[/bold]', classes="block-title"),
101
- Horizontal(
102
- LastInstitutionCard(author=self.author),
103
- IdentifiersCard(author=self.author),
104
- CitationMetricsCard(author=self.author),
105
- classes="cards-container"
106
- ),
107
- classes="block-container"
108
- )
109
- )
97
+ self.loading = False
110
98
 
111
- # Work realeted info
112
- await container.mount(
113
- Vertical(
114
- Label('[bold]Work Info:[/bold]', classes="block-title"),
115
- Horizontal(
116
- Label(f'[bold]Cited by count:[/bold] {self.author.cited_by_count}'),
117
- Label(f'[bold]Works count:[/bold] {self.author.works_count}'),
118
- classes="info-container"
119
- ),
120
- classes="block-container"
121
- )
122
- )
99
+ @on(Filter.Changed)
100
+ def filter_change(self) -> None:
101
+ """Handle filter changes."""
102
+ filters = [filter for filter in self.query("_AuthorSummaryWidget Filter").results(Filter) if not filter.filter_disabled]
103
+ all_filters_valid = all(filter.validation_state for filter in filters)
123
104
 
124
- # Count by year table section
125
- await container.mount(
126
- Container(
127
- AuthorWorksByYearTable(author=self.author),
128
- classes="table-container"
129
- )
130
- )
131
-
132
- # Report Button
133
- await container.mount(
134
- Vertical(
135
- Label('[bold]Make report:[/bold]', classes="block-title"),
136
-
137
- # Filters
138
- Horizontal(
139
- Checkbox("Filter", id="filters-checkbox"),
140
- DateInput(placeholder="From yyyy-mm-dd", disabled=True, id="from-date"),
141
- DateInput(placeholder="To yyyy-mm-dd", disabled=True, id="to-date"),
142
- classes="info-container filter-container",
143
- ),
144
-
145
- # Button
146
- Vertical(
147
- Button("Make Report", variant="primary", id="make-report-button"),
148
- classes="block-container button-container"
149
- ),
150
- classes="block-container",
151
- disabled=is_report_not_available
152
- )
153
- )
154
-
155
- # Show results
156
- self.query_one(LoadingIndicator).display = False
157
- container.display = True
105
+ self.query_one("_AuthorSummaryWidget #make-report-button", Button).disabled = not all_filters_valid
106
+
107
+ @on(Button.Pressed, "#make-report-button")
108
+ async def make_report(self) -> None:
109
+ """Make the author report."""
110
+ filters: dict[str, Any] = {}
111
+ pub_date_range = self.query_one("#author-date-range-filter", DateRangeFilter)
112
+ cited_date_range = self.query_one("#cited-date-range-filter", DateRangeFilter)
113
+
114
+ if not pub_date_range.filter_disabled:
115
+ filters.update({"pub_from_date": pub_date_range.from_date, "pub_to_date": pub_date_range.to_date})
116
+
117
+ if not cited_date_range.filter_disabled:
118
+ filters.update({"cited_from_date": cited_date_range.from_date, "cited_to_date": cited_date_range.to_date})
119
+
120
+ report_widget = CreateAuthorReportWidget(author=self.author, **filters)
121
+ await self.app.query_one("MainContent").mount(report_widget)
122
+ await self.app.query_one("AuthorSummaryWidget").remove()
@@ -16,7 +16,7 @@ class AuthorWorksByYearTable(Static):
16
16
 
17
17
  def compose(self) -> ComposeResult:
18
18
  """Compose Table."""
19
- table = Table('Year', 'Works Count', 'Cited by Count', title="Counts by Year", expand=True)
19
+ table = Table("Year", "Works Count", "Cited by Count", title="Counts by Year", expand=True)
20
20
  for row in self.author.counts_by_year:
21
21
  year, works_count, cited_by_count = row.model_dump().values()
22
22
  table.add_row(str(year), str(works_count), str(cited_by_count))