pub-analyzer 0.5.6__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.
- pub_analyzer/__init__.py +1 -0
- pub_analyzer/__main__.py +7 -0
- pub_analyzer/css/body.tcss +87 -0
- pub_analyzer/css/buttons.tcss +24 -0
- pub_analyzer/css/checkbox.tcss +29 -0
- pub_analyzer/css/collapsible.tcss +31 -0
- pub_analyzer/css/datatable.tcss +50 -0
- pub_analyzer/css/editor.tcss +60 -0
- pub_analyzer/css/main.tcss +50 -0
- pub_analyzer/css/report.tcss +131 -0
- pub_analyzer/css/search.tcss +81 -0
- pub_analyzer/css/summary.tcss +75 -0
- pub_analyzer/css/tabs.tcss +18 -0
- pub_analyzer/css/tree.tcss +44 -0
- pub_analyzer/internal/__init__.py +1 -0
- pub_analyzer/internal/identifier.py +106 -0
- pub_analyzer/internal/limiter.py +34 -0
- pub_analyzer/internal/render.py +41 -0
- pub_analyzer/internal/report.py +497 -0
- pub_analyzer/internal/templates/author_report.typ +591 -0
- pub_analyzer/main.py +81 -0
- pub_analyzer/models/__init__.py +1 -0
- pub_analyzer/models/author.py +87 -0
- pub_analyzer/models/concept.py +19 -0
- pub_analyzer/models/institution.py +138 -0
- pub_analyzer/models/report.py +111 -0
- pub_analyzer/models/source.py +77 -0
- pub_analyzer/models/topic.py +59 -0
- pub_analyzer/models/work.py +158 -0
- pub_analyzer/widgets/__init__.py +1 -0
- pub_analyzer/widgets/author/__init__.py +1 -0
- pub_analyzer/widgets/author/cards.py +65 -0
- pub_analyzer/widgets/author/core.py +122 -0
- pub_analyzer/widgets/author/tables.py +50 -0
- pub_analyzer/widgets/body.py +55 -0
- pub_analyzer/widgets/common/__init__.py +18 -0
- pub_analyzer/widgets/common/card.py +29 -0
- pub_analyzer/widgets/common/filesystem.py +203 -0
- pub_analyzer/widgets/common/filters.py +111 -0
- pub_analyzer/widgets/common/input.py +97 -0
- pub_analyzer/widgets/common/label.py +36 -0
- pub_analyzer/widgets/common/modal.py +43 -0
- pub_analyzer/widgets/common/selector.py +66 -0
- pub_analyzer/widgets/common/summary.py +7 -0
- pub_analyzer/widgets/institution/__init__.py +1 -0
- pub_analyzer/widgets/institution/cards.py +78 -0
- pub_analyzer/widgets/institution/core.py +122 -0
- pub_analyzer/widgets/institution/tables.py +24 -0
- pub_analyzer/widgets/report/__init__.py +1 -0
- pub_analyzer/widgets/report/author.py +43 -0
- pub_analyzer/widgets/report/cards.py +130 -0
- pub_analyzer/widgets/report/concept.py +47 -0
- pub_analyzer/widgets/report/core.py +308 -0
- pub_analyzer/widgets/report/editor.py +80 -0
- pub_analyzer/widgets/report/export.py +112 -0
- pub_analyzer/widgets/report/grants.py +85 -0
- pub_analyzer/widgets/report/institution.py +39 -0
- pub_analyzer/widgets/report/locations.py +75 -0
- pub_analyzer/widgets/report/source.py +90 -0
- pub_analyzer/widgets/report/topic.py +55 -0
- pub_analyzer/widgets/report/work.py +391 -0
- pub_analyzer/widgets/search/__init__.py +11 -0
- pub_analyzer/widgets/search/core.py +96 -0
- pub_analyzer/widgets/search/results.py +82 -0
- pub_analyzer/widgets/sidebar.py +70 -0
- pub_analyzer-0.5.6.dist-info/METADATA +102 -0
- pub_analyzer-0.5.6.dist-info/RECORD +70 -0
- pub_analyzer-0.5.6.dist-info/WHEEL +4 -0
- pub_analyzer-0.5.6.dist-info/entry_points.txt +3 -0
- pub_analyzer-0.5.6.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Input widgets."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from rich.highlighter import Highlighter
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.events import Key
|
|
9
|
+
from textual.suggester import Suggester
|
|
10
|
+
from textual.validation import Regex, Validator
|
|
11
|
+
from textual.widgets import Input as TextualInput
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Input(TextualInput):
|
|
15
|
+
"""Input with extra bindings."""
|
|
16
|
+
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
/* COLORS */
|
|
19
|
+
$primary-color: #b91c1c;
|
|
20
|
+
$primary-color-accent: #991b1b;
|
|
21
|
+
$primary-color-highlight: #dc2626;
|
|
22
|
+
|
|
23
|
+
Input {
|
|
24
|
+
border: tall $background;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Input:focus {
|
|
28
|
+
border: tall $primary-color-accent;
|
|
29
|
+
}
|
|
30
|
+
Input.-invalid {
|
|
31
|
+
border: tall orange 40%;
|
|
32
|
+
}
|
|
33
|
+
Input.-invalid:focus {
|
|
34
|
+
border: tall orange 60%;
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@on(Key)
|
|
39
|
+
def exit_focus(self, event: Key) -> None:
|
|
40
|
+
"""Unfocus from the input with esc KEY."""
|
|
41
|
+
if event.key == "escape":
|
|
42
|
+
self.screen.set_focus(None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DateSuggester(Suggester):
|
|
46
|
+
"""Suggest date format."""
|
|
47
|
+
|
|
48
|
+
async def get_suggestion(self, value: str) -> str | None:
|
|
49
|
+
"""Gets a completion as of the current input date."""
|
|
50
|
+
if match := re.match(r"^(?P<year>\d{1,4})(-|$)(?P<month>\d{1,2})?(-|$)(?P<day>\d{1,2})?$", value):
|
|
51
|
+
date_comp = match.groupdict()
|
|
52
|
+
|
|
53
|
+
year = date_comp["year"] or "yyyy"
|
|
54
|
+
month = date_comp["month"] or "mm"
|
|
55
|
+
day = date_comp["day"] or "dd"
|
|
56
|
+
|
|
57
|
+
return f"{year}-{month}-{day}"
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DateInput(Input):
|
|
62
|
+
"""Input with Date validation."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
value: str | None = None,
|
|
67
|
+
placeholder: str = "",
|
|
68
|
+
highlighter: Highlighter | None = None,
|
|
69
|
+
password: bool = False,
|
|
70
|
+
*,
|
|
71
|
+
validators: Validator | Iterable[Validator] | None = None,
|
|
72
|
+
name: str | None = None,
|
|
73
|
+
id: str | None = None,
|
|
74
|
+
classes: str | None = None,
|
|
75
|
+
disabled: bool = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
suggester = DateSuggester()
|
|
78
|
+
|
|
79
|
+
super().__init__(
|
|
80
|
+
value,
|
|
81
|
+
placeholder,
|
|
82
|
+
highlighter,
|
|
83
|
+
password,
|
|
84
|
+
suggester=suggester,
|
|
85
|
+
validators=validators,
|
|
86
|
+
name=name,
|
|
87
|
+
id=id,
|
|
88
|
+
classes=classes,
|
|
89
|
+
disabled=disabled,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.validators.append(
|
|
93
|
+
Regex(
|
|
94
|
+
regex=r"^(?P<year>\d{4})-((?P<longMonth>(0[13578]|1[02])-(0[1-9]|[12][0-9]|3[01]))|(?P<shortMonth>(0[469]|11)-(0[1-9]|[12][0-9]|30))|(?P<februaryMonth>(02)-([01][1-9]|[2][0-8])))$",
|
|
95
|
+
failure_description="Input must be formatted as `yyyy-mm-dd`",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Label widget but with reactivity enable."""
|
|
2
|
+
|
|
3
|
+
from rich.console import RenderableType
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
from textual.app import RenderResult
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReactiveLabel(Widget):
|
|
11
|
+
"""A Label widget but with reactivity enable."""
|
|
12
|
+
|
|
13
|
+
renderable: reactive[RenderableType] = reactive[RenderableType]("", layout=True)
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
renderable: RenderableType = "",
|
|
18
|
+
markup: bool = True,
|
|
19
|
+
name: str | None = None,
|
|
20
|
+
id: str | None = None,
|
|
21
|
+
classes: str | None = None,
|
|
22
|
+
disabled: bool = False,
|
|
23
|
+
):
|
|
24
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
25
|
+
self.markup = markup
|
|
26
|
+
self.renderable = renderable
|
|
27
|
+
|
|
28
|
+
def render(self) -> RenderResult:
|
|
29
|
+
"""Render widget."""
|
|
30
|
+
if isinstance(self.renderable, str):
|
|
31
|
+
if self.markup:
|
|
32
|
+
return Text.from_markup(self.renderable)
|
|
33
|
+
else:
|
|
34
|
+
return Text(self.renderable)
|
|
35
|
+
else:
|
|
36
|
+
return self.renderable
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Modal Screen."""
|
|
2
|
+
|
|
3
|
+
from textual.screen import Screen, ScreenResultType
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Modal(Screen[ScreenResultType]):
|
|
7
|
+
"""Base overlay window container."""
|
|
8
|
+
|
|
9
|
+
DEFAULT_CSS = """
|
|
10
|
+
$bg-main-color: white;
|
|
11
|
+
$bg-secondary-color: #e5e7eb;
|
|
12
|
+
$text-primary-color: black;
|
|
13
|
+
|
|
14
|
+
$text-primary-color-darken: black;
|
|
15
|
+
|
|
16
|
+
Modal {
|
|
17
|
+
background: rgba(229, 231, 235, 0.5);
|
|
18
|
+
align: center middle;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Modal #dialog {
|
|
22
|
+
background: $bg-main-color;
|
|
23
|
+
height: 100%;
|
|
24
|
+
width: 100%;
|
|
25
|
+
|
|
26
|
+
margin: 3 10;
|
|
27
|
+
border: $bg-secondary-color;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.-dark-mode Modal #dialog {
|
|
31
|
+
background: $bg-secondary-color;
|
|
32
|
+
color: $text-primary-color-darken;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Modal #dialog .dialog-title {
|
|
36
|
+
height: 3;
|
|
37
|
+
width: 100%;
|
|
38
|
+
margin: 1;
|
|
39
|
+
|
|
40
|
+
text-align: center;
|
|
41
|
+
border-bottom: solid $text-primary-color;
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Custom Selector widget."""
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.events import Key
|
|
7
|
+
from textual.widgets import Select as TextualSelect
|
|
8
|
+
|
|
9
|
+
SelectType = TypeVar("SelectType")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Select(TextualSelect[SelectType]):
|
|
13
|
+
"""Widget to select from a list of possible options."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
/* COLORS */
|
|
17
|
+
$primary-color: #b91c1c;
|
|
18
|
+
$primary-color-accent: #991b1b;
|
|
19
|
+
$primary-color-highlight: #dc2626;
|
|
20
|
+
|
|
21
|
+
$bg-secondary-color: #e5e7eb;
|
|
22
|
+
|
|
23
|
+
Select {
|
|
24
|
+
height: 3;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Select:focus > SelectCurrent {
|
|
28
|
+
border: tall $primary-color-accent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Select.-expanded > SelectCurrent {
|
|
32
|
+
border: tall $primary-color-accent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
SelectOverlay {
|
|
36
|
+
border: tall $bg-secondary-color;
|
|
37
|
+
background: $bg-secondary-color;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
SelectOverlay:focus {
|
|
41
|
+
border: tall $bg-secondary-color;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Select OptionList {
|
|
45
|
+
background: $bg-secondary-color;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Select OptionList:focus > .option-list--option-highlighted {
|
|
50
|
+
background: $primary-color;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Select OptionList > .option-list--option-hover-highlighted {
|
|
54
|
+
background: $primary-color-accent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Select OptionList:focus > .option-list--option-hover-highlighted {
|
|
58
|
+
background: $primary-color-accent;
|
|
59
|
+
}
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
@on(Key)
|
|
63
|
+
def exit_focus(self, event: Key) -> None:
|
|
64
|
+
"""Unfocus from the input with esc KEY."""
|
|
65
|
+
if event.key == "escape":
|
|
66
|
+
self.screen.set_focus(None)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Institution Widgets."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Institution Cards Widgets."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.widgets import Label
|
|
8
|
+
|
|
9
|
+
from pub_analyzer.models.institution import Institution
|
|
10
|
+
from pub_analyzer.widgets.common import Card
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CitationMetricsCard(Card):
|
|
14
|
+
"""Citation metrics for this institution."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, institution: Institution) -> None:
|
|
17
|
+
self.institution = institution
|
|
18
|
+
super().__init__()
|
|
19
|
+
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
"""Compose card."""
|
|
22
|
+
yield Label("[italic]Citation metrics:[/italic]", classes="card-title")
|
|
23
|
+
|
|
24
|
+
with Vertical(classes="card-container"):
|
|
25
|
+
yield Label(f"[bold]2-year mean:[/bold] {self.institution.summary_stats.two_yr_mean_citedness:.5f}")
|
|
26
|
+
yield Label(f"[bold]h-index:[/bold] {self.institution.summary_stats.h_index}")
|
|
27
|
+
yield Label(f"[bold]i10 index:[/bold] {self.institution.summary_stats.i10_index}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IdentifiersCard(Card):
|
|
31
|
+
"""Card with external identifiers that we know about this institution."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, institution: Institution) -> None:
|
|
34
|
+
self.institution = institution
|
|
35
|
+
super().__init__()
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
"""Compose card."""
|
|
39
|
+
yield Label("[italic]Identifiers:[/italic]", classes="card-title")
|
|
40
|
+
|
|
41
|
+
for platform, platform_url in self.institution.ids.model_dump().items():
|
|
42
|
+
if platform_url:
|
|
43
|
+
yield Label(f"""- [@click=app.open_link('{quote(str(platform_url))}')]{platform}[/]""")
|
|
44
|
+
|
|
45
|
+
if self.institution.homepage_url:
|
|
46
|
+
yield Label(f"""- [@click=app.open_link('{quote(str(self.institution.homepage_url))}')]Homepage[/]""")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GeoCard(Card):
|
|
50
|
+
"""Card with location info of this institution."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, institution: Institution) -> None:
|
|
53
|
+
self.institution = institution
|
|
54
|
+
super().__init__()
|
|
55
|
+
|
|
56
|
+
def compose(self) -> ComposeResult:
|
|
57
|
+
"""Compose card."""
|
|
58
|
+
yield Label("[italic]Geo:[/italic]", classes="card-title")
|
|
59
|
+
|
|
60
|
+
with Vertical(classes="card-container"):
|
|
61
|
+
yield Label(f"[bold]City:[/bold] {self.institution.geo.city}")
|
|
62
|
+
yield Label(f"[bold]Country:[/bold] {self.institution.geo.country}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RolesCard(Card):
|
|
66
|
+
"""Card with roles info of this institution."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, institution: Institution) -> None:
|
|
69
|
+
self.institution = institution
|
|
70
|
+
super().__init__()
|
|
71
|
+
|
|
72
|
+
def compose(self) -> ComposeResult:
|
|
73
|
+
"""Compose card."""
|
|
74
|
+
yield Label("[italic]Works by roles:[/italic]", classes="card-title")
|
|
75
|
+
|
|
76
|
+
with Vertical(classes="card-container"):
|
|
77
|
+
for role in self.institution.roles:
|
|
78
|
+
yield Label(f"""[@click=app.open_link('{quote(str(role.id))}')]{role.role.value.title()}[/]: {role.works_count}""")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Module with Widgets that allows to display the complete information of Institution using OpenAlex."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
9
|
+
from textual.widgets import Button, Collapsible, Label, Static
|
|
10
|
+
|
|
11
|
+
from pub_analyzer.internal.identifier import get_institution_id
|
|
12
|
+
from pub_analyzer.models.institution import Institution, InstitutionResult
|
|
13
|
+
from pub_analyzer.widgets.common.filters import DateRangeFilter, Filter
|
|
14
|
+
from pub_analyzer.widgets.common.summary import SummaryWidget
|
|
15
|
+
from pub_analyzer.widgets.report.core import CreateInstitutionReportWidget
|
|
16
|
+
|
|
17
|
+
from .cards import CitationMetricsCard, IdentifiersCard, RolesCard
|
|
18
|
+
from .tables import InstitutionWorksByYearTable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _InstitutionSummaryWidget(Static):
|
|
22
|
+
"""Institution info summary."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, institution: Institution) -> None:
|
|
25
|
+
self.institution = institution
|
|
26
|
+
super().__init__()
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
"""Compose institution info."""
|
|
30
|
+
is_report_not_available = self.institution.works_count < 1
|
|
31
|
+
|
|
32
|
+
# Compose Cards
|
|
33
|
+
with Vertical(classes="block-container"):
|
|
34
|
+
yield Label("[bold]Institution info:[/bold]", classes="block-title")
|
|
35
|
+
|
|
36
|
+
with Horizontal(classes="cards-container"):
|
|
37
|
+
yield RolesCard(institution=self.institution)
|
|
38
|
+
yield IdentifiersCard(institution=self.institution)
|
|
39
|
+
yield CitationMetricsCard(institution=self.institution)
|
|
40
|
+
|
|
41
|
+
# Work related info
|
|
42
|
+
with Vertical(classes="block-container"):
|
|
43
|
+
yield Label("[bold]Work Info:[/bold]", classes="block-title")
|
|
44
|
+
|
|
45
|
+
with Horizontal(classes="info-container"):
|
|
46
|
+
yield Label(f"[bold]Cited by count:[/bold] {self.institution.cited_by_count}")
|
|
47
|
+
yield Label(f"[bold]Works count:[/bold] {self.institution.works_count}")
|
|
48
|
+
|
|
49
|
+
# Count by year table section
|
|
50
|
+
with Container(classes="table-container"):
|
|
51
|
+
yield InstitutionWorksByYearTable(institution=self.institution)
|
|
52
|
+
|
|
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")
|
|
56
|
+
|
|
57
|
+
# Filters
|
|
58
|
+
with Collapsible(title="Report filters.", classes="filter-collapsible"):
|
|
59
|
+
# Institution publication Date Range
|
|
60
|
+
yield DateRangeFilter(checkbox_label="Publication date range:", id="institution-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="block-container button-container"):
|
|
67
|
+
yield Button("Make Report", variant="primary", id="make-report-button")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InstitutionSummaryWidget(SummaryWidget):
|
|
71
|
+
"""Institution info summary container."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, institution_result: InstitutionResult) -> None:
|
|
74
|
+
self.institution_result = institution_result
|
|
75
|
+
self.institution: Institution
|
|
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)
|
|
82
|
+
|
|
83
|
+
async def _get_info(self) -> None:
|
|
84
|
+
"""Query OpenAlex API."""
|
|
85
|
+
institution_id = get_institution_id(self.institution_result)
|
|
86
|
+
url = f"https://api.openalex.org/institutions/{institution_id}"
|
|
87
|
+
|
|
88
|
+
async with httpx.AsyncClient() as client:
|
|
89
|
+
results = (await client.get(url)).json()
|
|
90
|
+
self.institution = Institution(**results)
|
|
91
|
+
|
|
92
|
+
async def load_data(self) -> None:
|
|
93
|
+
"""Query OpenAlex API and composing the widget."""
|
|
94
|
+
await self._get_info()
|
|
95
|
+
await self.mount(_InstitutionSummaryWidget(institution=self.institution))
|
|
96
|
+
|
|
97
|
+
self.loading = False
|
|
98
|
+
|
|
99
|
+
@on(Filter.Changed)
|
|
100
|
+
def filter_change(self) -> None:
|
|
101
|
+
"""Handle filter changes."""
|
|
102
|
+
filters = [filter for filter in self.query("_InstitutionSummaryWidget Filter").results(Filter) if not filter.filter_disabled]
|
|
103
|
+
all_filters_valid = all(filter.validation_state for filter in filters)
|
|
104
|
+
|
|
105
|
+
self.query_one("_InstitutionSummaryWidget #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("#institution-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 = CreateInstitutionReportWidget(institution=self.institution, **filters)
|
|
121
|
+
await self.app.query_one("MainContent").mount(report_widget)
|
|
122
|
+
await self.app.query_one("InstitutionSummaryWidget").remove()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Institution Tables Widgets."""
|
|
2
|
+
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from pub_analyzer.models.institution import Institution
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InstitutionWorksByYearTable(Static):
|
|
11
|
+
"""Table with Work count and cited by count of the last 10 years."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, institution: Institution) -> None:
|
|
14
|
+
self.institution = institution
|
|
15
|
+
super().__init__()
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
"""Compose Table."""
|
|
19
|
+
table = Table("Year", "Works Count", "Cited by Count", title="Counts by Year", expand=True)
|
|
20
|
+
for row in self.institution.counts_by_year:
|
|
21
|
+
year, works_count, cited_by_count = row.model_dump().values()
|
|
22
|
+
table.add_row(str(year), str(works_count), str(cited_by_count))
|
|
23
|
+
|
|
24
|
+
yield Static(table)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Widgets for summarizing and generating reports."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Author Report Widgets."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
5
|
+
from textual.widgets import TabbedContent, TabPane
|
|
6
|
+
|
|
7
|
+
from pub_analyzer.models.report import AuthorReport
|
|
8
|
+
from pub_analyzer.widgets.author.cards import CitationMetricsCard, IdentifiersCard, LastInstitutionCard
|
|
9
|
+
from pub_analyzer.widgets.author.tables import AffiliationsTable, AuthorWorksByYearTable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthorReportPane(VerticalScroll):
|
|
13
|
+
"""Work report Pane Widget."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
AuthorReportPane {
|
|
17
|
+
layout: vertical;
|
|
18
|
+
overflow-x: hidden;
|
|
19
|
+
overflow-y: auto;
|
|
20
|
+
|
|
21
|
+
.author-tables-container {
|
|
22
|
+
margin: 1 0 0 0 ;
|
|
23
|
+
height: auto;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, report: AuthorReport) -> None:
|
|
29
|
+
self.report = report
|
|
30
|
+
super().__init__()
|
|
31
|
+
|
|
32
|
+
def compose(self) -> ComposeResult:
|
|
33
|
+
"""Compose content pane."""
|
|
34
|
+
with Horizontal(classes="cards-container"):
|
|
35
|
+
yield LastInstitutionCard(author=self.report.author)
|
|
36
|
+
yield IdentifiersCard(author=self.report.author)
|
|
37
|
+
yield CitationMetricsCard(author=self.report.author)
|
|
38
|
+
|
|
39
|
+
with TabbedContent(id="author-tables-container"):
|
|
40
|
+
with TabPane("Citation Metrics"):
|
|
41
|
+
yield AuthorWorksByYearTable(author=self.report.author)
|
|
42
|
+
with TabPane("Institutions"):
|
|
43
|
+
yield AffiliationsTable(author=self.report.author)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Report Cards Widgets."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical, VerticalScroll
|
|
7
|
+
from textual.widgets import Label
|
|
8
|
+
|
|
9
|
+
from pub_analyzer.models.author import Author
|
|
10
|
+
from pub_analyzer.models.report import AuthorReport, InstitutionReport, WorkReport
|
|
11
|
+
from pub_analyzer.models.work import Work
|
|
12
|
+
from pub_analyzer.widgets.common import Card
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Works pane cards.
|
|
16
|
+
class ReportCitationMetricsCard(Card):
|
|
17
|
+
"""Citation metrics for this report."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, report: AuthorReport | InstitutionReport) -> None:
|
|
20
|
+
self.report = report
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
def compose(self) -> ComposeResult:
|
|
24
|
+
"""Compose card."""
|
|
25
|
+
yield Label("[italic]Citation metrics:[/italic]", classes="card-title")
|
|
26
|
+
|
|
27
|
+
with Vertical(classes="card-container"):
|
|
28
|
+
type_a_count = self.report.citation_summary.type_a_count
|
|
29
|
+
type_b_count = self.report.citation_summary.type_b_count
|
|
30
|
+
cited_by_count = type_a_count + type_b_count
|
|
31
|
+
|
|
32
|
+
yield Label(f"[bold]Count:[/bold] {cited_by_count}")
|
|
33
|
+
yield Label(f"[bold]Type A:[/bold] {type_a_count}")
|
|
34
|
+
yield Label(f"[bold]Type B:[/bold] {type_b_count}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WorksTypeSummaryCard(Card):
|
|
38
|
+
"""Works Type Counters Summary Card."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, report: AuthorReport | InstitutionReport) -> None:
|
|
41
|
+
self.report = report
|
|
42
|
+
super().__init__()
|
|
43
|
+
|
|
44
|
+
def compose(self) -> ComposeResult:
|
|
45
|
+
"""Compose card."""
|
|
46
|
+
yield Label("[italic]Work Type[/italic]", classes="card-title")
|
|
47
|
+
|
|
48
|
+
with VerticalScroll(classes="card-container"):
|
|
49
|
+
for work_type_counter in self.report.works_type_summary:
|
|
50
|
+
yield Label(f"[bold]{work_type_counter.type_name}:[/bold] {work_type_counter.count}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OpenAccessSummaryCard(Card):
|
|
54
|
+
"""Open Access counts for this report."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, report: AuthorReport | InstitutionReport) -> None:
|
|
57
|
+
self.report = report
|
|
58
|
+
super().__init__()
|
|
59
|
+
|
|
60
|
+
def compose(self) -> ComposeResult:
|
|
61
|
+
"""Compose card."""
|
|
62
|
+
yield Label("[italic]Open Access[/italic]", classes="card-title")
|
|
63
|
+
|
|
64
|
+
with VerticalScroll(classes="card-container"):
|
|
65
|
+
for status, count in self.report.open_access_summary.model_dump().items():
|
|
66
|
+
yield Label(f"[bold]{status.capitalize()}:[/bold] {count}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Work Info cards.
|
|
70
|
+
class AuthorshipCard(Card):
|
|
71
|
+
"""Card that enumerate the authorship's of a work."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, work: Work, author: Author | None) -> None:
|
|
74
|
+
self.work = work
|
|
75
|
+
self.author = author
|
|
76
|
+
super().__init__()
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
"""Compose card."""
|
|
80
|
+
yield Label("[italic]Authorship's[/italic]", classes="card-title")
|
|
81
|
+
|
|
82
|
+
with VerticalScroll(classes="card-container"):
|
|
83
|
+
for authorship in self.work.authorships:
|
|
84
|
+
# If the author was provided, highlight
|
|
85
|
+
if self.author and authorship.author.display_name == self.author.display_name:
|
|
86
|
+
author_name_formated = f"[b #909d63]{authorship.author.display_name}[/]"
|
|
87
|
+
else:
|
|
88
|
+
author_name_formated = str(authorship.author.display_name)
|
|
89
|
+
|
|
90
|
+
external_id = authorship.author.orcid or authorship.author.id
|
|
91
|
+
yield Label(
|
|
92
|
+
f"""- [b]{authorship.author_position}:[/b] [@click=app.open_link('{quote(str(external_id))}')]{author_name_formated}[/]""" # noqa: E501
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class OpenAccessCard(Card):
|
|
97
|
+
"""Card that show OpenAccess status of a work."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, work: Work) -> None:
|
|
100
|
+
self.work = work
|
|
101
|
+
super().__init__()
|
|
102
|
+
|
|
103
|
+
def compose(self) -> ComposeResult:
|
|
104
|
+
"""Compose card."""
|
|
105
|
+
work_url = self.work.open_access.oa_url
|
|
106
|
+
|
|
107
|
+
yield Label("[italic]Open Access[/italic]", classes="card-title")
|
|
108
|
+
yield Label(f"[bold]Status:[/bold] {self.work.open_access.oa_status.value.capitalize()}")
|
|
109
|
+
if work_url:
|
|
110
|
+
yield Label(f"""[bold]URL:[/bold] [@click=app.open_link('{quote(str(work_url))}')]{work_url}[/]""")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CitationMetricsCard(Card):
|
|
114
|
+
"""Card that show Citation metrics of a work."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, work_report: WorkReport) -> None:
|
|
117
|
+
self.work_report = work_report
|
|
118
|
+
super().__init__()
|
|
119
|
+
|
|
120
|
+
def compose(self) -> ComposeResult:
|
|
121
|
+
"""Compose card."""
|
|
122
|
+
type_a_count = self.work_report.citation_summary.type_a_count
|
|
123
|
+
type_b_count = self.work_report.citation_summary.type_b_count
|
|
124
|
+
cited_by_count = type_a_count + type_b_count
|
|
125
|
+
|
|
126
|
+
yield Label("[italic]Citation[/italic]", classes="card-title")
|
|
127
|
+
|
|
128
|
+
yield Label(f"[bold]Count:[/bold] {cited_by_count}")
|
|
129
|
+
yield Label(f"[bold]Type A:[/bold] {type_a_count}")
|
|
130
|
+
yield Label(f"[bold]Type B:[/bold] {type_b_count}")
|