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,47 @@
|
|
|
1
|
+
"""Concepts Widgets."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from pub_analyzer.models.concept import DehydratedConcept
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConceptsTable(Static):
|
|
14
|
+
"""All Concepts from a work in a table."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
ConceptsTable .concepts-table {
|
|
18
|
+
height: auto;
|
|
19
|
+
padding: 1 2 0 2;
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, concepts_list: list[DehydratedConcept]) -> None:
|
|
24
|
+
self.concepts_list = concepts_list
|
|
25
|
+
super().__init__()
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
"""Compose Table."""
|
|
29
|
+
concepts_table = Table(title="Concepts", expand=True, show_lines=True)
|
|
30
|
+
|
|
31
|
+
# Define Columns
|
|
32
|
+
concepts_table.add_column("", justify="center", vertical="middle")
|
|
33
|
+
concepts_table.add_column("Name", ratio=5)
|
|
34
|
+
concepts_table.add_column("Score", ratio=1)
|
|
35
|
+
concepts_table.add_column("Level", ratio=1)
|
|
36
|
+
|
|
37
|
+
for idx, concept in enumerate(self.concepts_list):
|
|
38
|
+
name = f"""[@click=app.open_link('{quote(str(concept.wikidata))}')][u]{concept.display_name}[/u][/]"""
|
|
39
|
+
|
|
40
|
+
concepts_table.add_row(
|
|
41
|
+
str(idx),
|
|
42
|
+
Text.from_markup(name, overflow="ellipsis"),
|
|
43
|
+
Text.from_markup(f"{concept.score:.2f}"),
|
|
44
|
+
Text.from_markup(f"{concept.level:.1f}"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
yield Static(concepts_table, classes="concepts-table")
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Main Report widgets."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import pathlib
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from time import time
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import TypeAdapter, ValidationError
|
|
11
|
+
from textual import on
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding, BindingType
|
|
14
|
+
from textual.containers import Container, Horizontal
|
|
15
|
+
from textual.reactive import reactive
|
|
16
|
+
from textual.widget import Widget
|
|
17
|
+
from textual.widgets import Button, LoadingIndicator, Static, TabbedContent, TabPane
|
|
18
|
+
|
|
19
|
+
from pub_analyzer.internal.report import FromDate, ToDate, make_author_report, make_institution_report
|
|
20
|
+
from pub_analyzer.models.author import Author
|
|
21
|
+
from pub_analyzer.models.institution import Institution
|
|
22
|
+
from pub_analyzer.models.report import AuthorReport, InstitutionReport
|
|
23
|
+
from pub_analyzer.widgets.common import FileSystemSelector, Select
|
|
24
|
+
|
|
25
|
+
from .author import AuthorReportPane
|
|
26
|
+
from .export import ExportReportPane
|
|
27
|
+
from .institution import InstitutionReportPane
|
|
28
|
+
from .source import SourcesReportPane
|
|
29
|
+
from .work import WorkReportPane
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReportWidget(Static):
|
|
33
|
+
"""Base report widget."""
|
|
34
|
+
|
|
35
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
36
|
+
Binding(key="ctrl+y", action="toggle_works", description="Toggle empty works"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
show_empty_works: reactive[bool] = reactive(True)
|
|
40
|
+
|
|
41
|
+
async def action_toggle_works(self) -> None:
|
|
42
|
+
"""Toggle show empty works attribute."""
|
|
43
|
+
self.show_empty_works = not self.show_empty_works
|
|
44
|
+
await self.query_one(WorkReportPane).toggle_empty_works()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthorReportWidget(ReportWidget):
|
|
48
|
+
"""Author report generator view."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, report: AuthorReport) -> None:
|
|
51
|
+
self.report = report
|
|
52
|
+
super().__init__()
|
|
53
|
+
|
|
54
|
+
def compose(self) -> ComposeResult:
|
|
55
|
+
"""Create main info container and with all the widgets."""
|
|
56
|
+
with TabbedContent(id="main-container"):
|
|
57
|
+
with TabPane("Author"):
|
|
58
|
+
yield AuthorReportPane(report=self.report)
|
|
59
|
+
with TabPane("Works"):
|
|
60
|
+
yield WorkReportPane(report=self.report)
|
|
61
|
+
with TabPane("Sources"):
|
|
62
|
+
yield SourcesReportPane(report=self.report)
|
|
63
|
+
with TabPane("Export"):
|
|
64
|
+
suggest_prefix = self.report.author.display_name.lower().split()[0]
|
|
65
|
+
yield ExportReportPane(report=self.report, suggest_prefix=suggest_prefix)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InstitutionReportWidget(ReportWidget):
|
|
69
|
+
"""Institution report generator view."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, report: InstitutionReport) -> None:
|
|
72
|
+
self.report = report
|
|
73
|
+
super().__init__()
|
|
74
|
+
|
|
75
|
+
def compose(self) -> ComposeResult:
|
|
76
|
+
"""Create main info container and with all the widgets."""
|
|
77
|
+
with TabbedContent(id="main-container"):
|
|
78
|
+
with TabPane("Institution"):
|
|
79
|
+
yield InstitutionReportPane(report=self.report)
|
|
80
|
+
with TabPane("Works"):
|
|
81
|
+
yield WorkReportPane(report=self.report)
|
|
82
|
+
with TabPane("Sources"):
|
|
83
|
+
yield SourcesReportPane(report=self.report)
|
|
84
|
+
with TabPane("Export"):
|
|
85
|
+
suggest_prefix = self.report.institution.display_name.lower().replace(" ", "-")
|
|
86
|
+
yield ExportReportPane(report=self.report, suggest_prefix=suggest_prefix)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CreateReportWidget(Static):
|
|
90
|
+
"""Base Widget report wrapper to load data from API."""
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
"""Create main info container and showing a loading animation."""
|
|
94
|
+
yield LoadingIndicator()
|
|
95
|
+
yield Container()
|
|
96
|
+
|
|
97
|
+
def on_mount(self) -> None:
|
|
98
|
+
"""Hiding the empty container and calling the data in the background."""
|
|
99
|
+
self.query_one(Container).display = False
|
|
100
|
+
self.run_worker(self.mount_report(), exclusive=True)
|
|
101
|
+
|
|
102
|
+
async def make_report(self) -> Widget:
|
|
103
|
+
"""Make report and create the widget."""
|
|
104
|
+
raise NotImplementedError
|
|
105
|
+
|
|
106
|
+
async def mount_report(self) -> None:
|
|
107
|
+
"""Mount report."""
|
|
108
|
+
try:
|
|
109
|
+
start = time()
|
|
110
|
+
report_widget = await self.make_report()
|
|
111
|
+
elapsed = time() - start
|
|
112
|
+
except httpx.HTTPStatusError as exc:
|
|
113
|
+
self.query_one(LoadingIndicator).display = False
|
|
114
|
+
status_error = f"HTTP Exception for url: {exc.request.url}. Status code: {exc.response.status_code}"
|
|
115
|
+
self.app.notify(
|
|
116
|
+
title="Error making report!",
|
|
117
|
+
message=f"The report could not be generated due to a problem with the OpenAlex API. {status_error}",
|
|
118
|
+
severity="error",
|
|
119
|
+
timeout=20.0,
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
self.app.notify(
|
|
124
|
+
title="Report created!",
|
|
125
|
+
message=f"Elapsed {elapsed:.2f}s",
|
|
126
|
+
severity="information",
|
|
127
|
+
timeout=20.0,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
container = self.query_one(Container)
|
|
131
|
+
await container.mount(report_widget)
|
|
132
|
+
|
|
133
|
+
# Show results
|
|
134
|
+
self.query_one(LoadingIndicator).display = False
|
|
135
|
+
container.display = True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CreateAuthorReportWidget(CreateReportWidget):
|
|
139
|
+
"""Widget Author report wrapper to load data from API."""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
author: Author,
|
|
144
|
+
pub_from_date: datetime.datetime | None = None,
|
|
145
|
+
pub_to_date: datetime.datetime | None = None,
|
|
146
|
+
cited_from_date: datetime.datetime | None = None,
|
|
147
|
+
cited_to_date: datetime.datetime | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
self.author = author
|
|
150
|
+
|
|
151
|
+
# Author publication date range
|
|
152
|
+
self.pub_from_date = pub_from_date
|
|
153
|
+
self.pub_to_date = pub_to_date
|
|
154
|
+
|
|
155
|
+
# Cited date range
|
|
156
|
+
self.cited_from_date = cited_from_date
|
|
157
|
+
self.cited_to_date = cited_to_date
|
|
158
|
+
|
|
159
|
+
super().__init__()
|
|
160
|
+
|
|
161
|
+
async def make_report(self) -> AuthorReportWidget:
|
|
162
|
+
"""Make report and create the widget."""
|
|
163
|
+
pub_from_date = FromDate(self.pub_from_date) if self.pub_from_date else None
|
|
164
|
+
pub_to_date = ToDate(self.pub_to_date) if self.pub_to_date else None
|
|
165
|
+
|
|
166
|
+
cited_from_date = FromDate(self.cited_from_date) if self.cited_from_date else None
|
|
167
|
+
cited_to_date = ToDate(self.cited_to_date) if self.cited_to_date else None
|
|
168
|
+
|
|
169
|
+
report = await make_author_report(
|
|
170
|
+
author=self.author,
|
|
171
|
+
pub_from_date=pub_from_date,
|
|
172
|
+
pub_to_date=pub_to_date,
|
|
173
|
+
cited_from_date=cited_from_date,
|
|
174
|
+
cited_to_date=cited_to_date,
|
|
175
|
+
)
|
|
176
|
+
return AuthorReportWidget(report=report)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class CreateInstitutionReportWidget(CreateReportWidget):
|
|
180
|
+
"""Widget Institution report wrapper to load data from API."""
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
institution: Institution,
|
|
185
|
+
pub_from_date: datetime.datetime | None = None,
|
|
186
|
+
pub_to_date: datetime.datetime | None = None,
|
|
187
|
+
cited_from_date: datetime.datetime | None = None,
|
|
188
|
+
cited_to_date: datetime.datetime | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
self.institution = institution
|
|
191
|
+
|
|
192
|
+
# Institution publication date range
|
|
193
|
+
self.pub_from_date = pub_from_date
|
|
194
|
+
self.pub_to_date = pub_to_date
|
|
195
|
+
|
|
196
|
+
# Cited date range
|
|
197
|
+
self.cited_from_date = cited_from_date
|
|
198
|
+
self.cited_to_date = cited_to_date
|
|
199
|
+
|
|
200
|
+
super().__init__()
|
|
201
|
+
|
|
202
|
+
async def make_report(self) -> InstitutionReportWidget:
|
|
203
|
+
"""Make report and create the widget."""
|
|
204
|
+
pub_from_date = FromDate(self.pub_from_date) if self.pub_from_date else None
|
|
205
|
+
pub_to_date = ToDate(self.pub_to_date) if self.pub_to_date else None
|
|
206
|
+
|
|
207
|
+
cited_from_date = FromDate(self.cited_from_date) if self.cited_from_date else None
|
|
208
|
+
cited_to_date = ToDate(self.cited_to_date) if self.cited_to_date else None
|
|
209
|
+
|
|
210
|
+
report = await make_institution_report(
|
|
211
|
+
institution=self.institution,
|
|
212
|
+
pub_from_date=pub_from_date,
|
|
213
|
+
pub_to_date=pub_to_date,
|
|
214
|
+
cited_from_date=cited_from_date,
|
|
215
|
+
cited_to_date=cited_to_date,
|
|
216
|
+
)
|
|
217
|
+
return InstitutionReportWidget(report=report)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class LoadReportWidget(Static):
|
|
221
|
+
"""Widget report wrapper to load data from disk."""
|
|
222
|
+
|
|
223
|
+
class EntityType(Enum):
|
|
224
|
+
"""Entity reports type."""
|
|
225
|
+
|
|
226
|
+
AUTHOR = AuthorReport
|
|
227
|
+
INSTITUTION = InstitutionReport
|
|
228
|
+
|
|
229
|
+
class EntityTypeSelector(Select[EntityType]):
|
|
230
|
+
"""Entity type Selector."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, entity_handler: EntityType = EntityType.AUTHOR) -> None:
|
|
233
|
+
self.entity_handler = entity_handler
|
|
234
|
+
super().__init__()
|
|
235
|
+
|
|
236
|
+
@on(FileSystemSelector.FileSelected)
|
|
237
|
+
def enable_button(self, event: FileSystemSelector.FileSelected) -> None:
|
|
238
|
+
"""Enable button on file select."""
|
|
239
|
+
if event.file_selected:
|
|
240
|
+
self.query_one(Button).disabled = False
|
|
241
|
+
else:
|
|
242
|
+
self.query_one(Button).disabled = True
|
|
243
|
+
|
|
244
|
+
@on(Button.Pressed, "#load-report-button")
|
|
245
|
+
async def load_report(self) -> None:
|
|
246
|
+
"""Load Report."""
|
|
247
|
+
from pub_analyzer.widgets.body import MainContent
|
|
248
|
+
|
|
249
|
+
file_path = self.query_one(FileSystemSelector).path_selected
|
|
250
|
+
if not file_path:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
with open(file_path, encoding="utf-8") as file:
|
|
254
|
+
data = file.read()
|
|
255
|
+
|
|
256
|
+
main_content = self.app.query_one(MainContent)
|
|
257
|
+
try:
|
|
258
|
+
match self.entity_handler:
|
|
259
|
+
case self.EntityType.AUTHOR:
|
|
260
|
+
author_report: AuthorReport = TypeAdapter(AuthorReport).validate_json(data)
|
|
261
|
+
author_report_widget = AuthorReportWidget(report=author_report)
|
|
262
|
+
await main_content.query("*").exclude("#page-title").remove()
|
|
263
|
+
|
|
264
|
+
await main_content.mount(author_report_widget)
|
|
265
|
+
main_content.update_title(title=author_report.author.display_name)
|
|
266
|
+
|
|
267
|
+
case self.EntityType.INSTITUTION:
|
|
268
|
+
institution_report: InstitutionReport = TypeAdapter(InstitutionReport).validate_json(data)
|
|
269
|
+
institution_report_widget = InstitutionReportWidget(report=institution_report)
|
|
270
|
+
await main_content.query("*").exclude("#page-title").remove()
|
|
271
|
+
|
|
272
|
+
await main_content.mount(institution_report_widget)
|
|
273
|
+
main_content.update_title(title=institution_report.institution.display_name)
|
|
274
|
+
except ValidationError:
|
|
275
|
+
self.app.notify(
|
|
276
|
+
title="Error loading report!",
|
|
277
|
+
message="The report does not have the correct structure. This may be because it is an old version or because it is not of the specified type.", # noqa: E501
|
|
278
|
+
severity="error",
|
|
279
|
+
timeout=10.0,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@on(Select.Changed)
|
|
283
|
+
async def on_select_entity(self, event: Select.Changed) -> None:
|
|
284
|
+
"""Change entity handler."""
|
|
285
|
+
match event.value:
|
|
286
|
+
case self.EntityType.AUTHOR:
|
|
287
|
+
self.entity_handler = self.EntityType.AUTHOR
|
|
288
|
+
case self.EntityType.INSTITUTION:
|
|
289
|
+
self.entity_handler = self.EntityType.INSTITUTION
|
|
290
|
+
case _:
|
|
291
|
+
raise NotImplementedError
|
|
292
|
+
|
|
293
|
+
def compose(self) -> ComposeResult:
|
|
294
|
+
"""Compose load report widget."""
|
|
295
|
+
with Horizontal(classes="filesystem-selector-container"):
|
|
296
|
+
entity_options = [(name.title(), endpoint) for name, endpoint in self.EntityType.__members__.items()]
|
|
297
|
+
|
|
298
|
+
yield FileSystemSelector(
|
|
299
|
+
path=pathlib.Path.home(),
|
|
300
|
+
only_dir=False,
|
|
301
|
+
extension=[
|
|
302
|
+
".json",
|
|
303
|
+
],
|
|
304
|
+
)
|
|
305
|
+
yield self.EntityTypeSelector(options=entity_options, value=self.entity_handler, allow_blank=False)
|
|
306
|
+
|
|
307
|
+
with Horizontal(classes="button-container"):
|
|
308
|
+
yield Button("Load Report", variant="primary", disabled=True, id="load-report-button")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Text editor widget."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from textual import events, on
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Button, Label, Static, TextArea
|
|
9
|
+
|
|
10
|
+
from pub_analyzer.widgets.common import Modal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TextEditor(Modal[str | None]):
|
|
14
|
+
"""Text editor widget."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, display_name: str, text: str) -> None:
|
|
17
|
+
self.display_name = display_name
|
|
18
|
+
self.text = text
|
|
19
|
+
super().__init__()
|
|
20
|
+
|
|
21
|
+
@on(events.Key)
|
|
22
|
+
def exit_modal(self, message: events.Key) -> None:
|
|
23
|
+
"""Exit from the modal with esc KEY."""
|
|
24
|
+
if message.key == "escape":
|
|
25
|
+
self.dismiss(None)
|
|
26
|
+
|
|
27
|
+
@on(Button.Pressed, "#save")
|
|
28
|
+
def save(self) -> None:
|
|
29
|
+
"""Return the edited content."""
|
|
30
|
+
self.dismiss(self.query_one(TextArea).text)
|
|
31
|
+
|
|
32
|
+
@on(Button.Pressed, "#cancel")
|
|
33
|
+
def cancel(self) -> None:
|
|
34
|
+
"""Cancel action button handler."""
|
|
35
|
+
self.dismiss(None)
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
"""Compose text editor."""
|
|
39
|
+
with VerticalScroll(id="dialog"):
|
|
40
|
+
yield Label(f'Editing the "{self.display_name}" field.', classes="dialog-title")
|
|
41
|
+
|
|
42
|
+
with VerticalScroll(id="text-editor-container"):
|
|
43
|
+
yield TextArea(text=self.text, theme="css", soft_wrap=True, show_line_numbers=True, tab_behavior="indent")
|
|
44
|
+
|
|
45
|
+
with Horizontal(id="actions-buttons"):
|
|
46
|
+
yield Button("Save", variant="primary", id="save")
|
|
47
|
+
yield Button("Cancel", variant="default", id="cancel")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EditWidget(Static):
|
|
51
|
+
"""Ask for edit widget."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, display_name: str, field_name: str, model: BaseModel, widget: Widget | None, widget_field: str | None) -> None:
|
|
54
|
+
self.display_name = display_name
|
|
55
|
+
self.field_name = field_name
|
|
56
|
+
self.model = model
|
|
57
|
+
self.widget = widget
|
|
58
|
+
self.widget_field = widget_field
|
|
59
|
+
super().__init__()
|
|
60
|
+
|
|
61
|
+
@on(Button.Pressed)
|
|
62
|
+
def launch_text_editor(self) -> None:
|
|
63
|
+
"""Lunch Text editor."""
|
|
64
|
+
|
|
65
|
+
def save(new_text: str | None) -> None:
|
|
66
|
+
"""Save changes if save button is pressed."""
|
|
67
|
+
if new_text:
|
|
68
|
+
self.app.log("Value updated.")
|
|
69
|
+
setattr(self.model, self.field_name, new_text)
|
|
70
|
+
|
|
71
|
+
if self.widget and self.widget_field:
|
|
72
|
+
setattr(self.widget, self.widget_field, new_text)
|
|
73
|
+
|
|
74
|
+
text = getattr(self.model, self.field_name)
|
|
75
|
+
self.app.push_screen(TextEditor(self.display_name, text), callback=save)
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
"""Compose widget."""
|
|
79
|
+
with Horizontal():
|
|
80
|
+
yield Button("Edit", variant="primary")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Export report widget."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from textual import on, work
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
10
|
+
from textual.widgets import Button, Label
|
|
11
|
+
|
|
12
|
+
from pub_analyzer.internal.render import render_report
|
|
13
|
+
from pub_analyzer.models.report import AuthorReport, InstitutionReport
|
|
14
|
+
from pub_analyzer.widgets.common import FileSystemSelector, Input, Select
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExportReportPane(VerticalScroll):
|
|
18
|
+
"""Export report pane Widget."""
|
|
19
|
+
|
|
20
|
+
DEFAULT_CSS = """
|
|
21
|
+
ExportReportPane {
|
|
22
|
+
layout: vertical;
|
|
23
|
+
overflow-x: hidden;
|
|
24
|
+
overflow-y: auto;
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
class ExportFileType(Enum):
|
|
29
|
+
"""File types."""
|
|
30
|
+
|
|
31
|
+
JSON = 0
|
|
32
|
+
PDF = 1
|
|
33
|
+
|
|
34
|
+
class ExportTypeSelector(Select[ExportFileType]):
|
|
35
|
+
"""Export file type selector."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, report: AuthorReport | InstitutionReport, suggest_prefix: str = "") -> None:
|
|
38
|
+
self.report = report
|
|
39
|
+
self.suggest_prefix = suggest_prefix
|
|
40
|
+
super().__init__()
|
|
41
|
+
|
|
42
|
+
@on(Select.Changed)
|
|
43
|
+
async def on_select_entity(self, event: Select.Changed) -> None:
|
|
44
|
+
"""Change entity endpoint."""
|
|
45
|
+
file_name_input = self.query_one(Input)
|
|
46
|
+
match event.value:
|
|
47
|
+
case self.ExportFileType.JSON:
|
|
48
|
+
file_name_input.value = f"{self.suggest_prefix}-{datetime.now().strftime('%m-%d-%Y')}.json"
|
|
49
|
+
case self.ExportFileType.PDF:
|
|
50
|
+
file_name_input.value = f"{self.suggest_prefix}-{datetime.now().strftime('%m-%d-%Y')}.pdf"
|
|
51
|
+
case _:
|
|
52
|
+
file_name_input.value = f"{self.suggest_prefix}-{datetime.now().strftime('%m-%d-%Y')}"
|
|
53
|
+
|
|
54
|
+
@on(FileSystemSelector.FileSelected)
|
|
55
|
+
def enable_button(self, event: FileSystemSelector.FileSelected) -> None:
|
|
56
|
+
"""Enable button on file select."""
|
|
57
|
+
if event.file_selected:
|
|
58
|
+
self.query_one(Button).disabled = False
|
|
59
|
+
else:
|
|
60
|
+
self.query_one(Button).disabled = True
|
|
61
|
+
|
|
62
|
+
@work(exclusive=True, thread=True)
|
|
63
|
+
def _export_report(self, file_type: ExportFileType, file_path: pathlib.Path) -> None:
|
|
64
|
+
"""Export report."""
|
|
65
|
+
match file_type:
|
|
66
|
+
case self.ExportFileType.JSON:
|
|
67
|
+
with open(file_path, mode="w", encoding="utf-8") as file:
|
|
68
|
+
file.write(self.report.model_dump_json(indent=2, by_alias=True))
|
|
69
|
+
case self.ExportFileType.PDF:
|
|
70
|
+
render_report(report=self.report, file_path=file_path)
|
|
71
|
+
|
|
72
|
+
self.app.call_from_thread(
|
|
73
|
+
self.app.notify,
|
|
74
|
+
title="Report exported successfully!",
|
|
75
|
+
message=f"The report was exported correctly. You can go see it at [i]{file_path}[/]",
|
|
76
|
+
timeout=20.0,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@on(Button.Pressed, "#export-report-button")
|
|
80
|
+
async def export_report(self) -> None:
|
|
81
|
+
"""Handle export report button."""
|
|
82
|
+
export_path = self.query_one(FileSystemSelector).path_selected
|
|
83
|
+
file_name = self.query_one(Input).value
|
|
84
|
+
file_type = self.query_one(self.ExportTypeSelector).value
|
|
85
|
+
|
|
86
|
+
if export_path and file_name and file_type:
|
|
87
|
+
file_path = export_path.joinpath(file_name)
|
|
88
|
+
self._export_report(file_type=file_type, file_path=file_path)
|
|
89
|
+
self.query_one(Button).disabled = True
|
|
90
|
+
|
|
91
|
+
def compose(self) -> ComposeResult:
|
|
92
|
+
"""Compose content pane."""
|
|
93
|
+
suggest_file_name = f"{self.suggest_prefix}-{datetime.now().strftime('%m-%d-%Y')}.json"
|
|
94
|
+
|
|
95
|
+
with Vertical(id="export-form"):
|
|
96
|
+
with Vertical(classes="export-form-input-container"):
|
|
97
|
+
yield Label("[b]Name File:[/]", classes="export-form-label")
|
|
98
|
+
with Horizontal(classes="file-selector-container"):
|
|
99
|
+
type_options = list(self.ExportFileType.__members__.items())
|
|
100
|
+
selector_disabled = isinstance(self.report, InstitutionReport)
|
|
101
|
+
|
|
102
|
+
yield Input(value=suggest_file_name, placeholder="report.json", classes="export-form-input")
|
|
103
|
+
yield self.ExportTypeSelector(
|
|
104
|
+
options=type_options, value=self.ExportFileType.JSON, allow_blank=False, disabled=selector_disabled
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
with Vertical(classes="export-form-input-container"):
|
|
108
|
+
yield Label("[b]Export Directory:[/]", classes="export-form-label")
|
|
109
|
+
yield FileSystemSelector(path=pathlib.Path.home(), only_dir=True)
|
|
110
|
+
|
|
111
|
+
with Horizontal(classes="export-form-buttons"):
|
|
112
|
+
yield Button("Export Report", variant="primary", disabled=True, id="export-report-button")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Grants Widgets."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from pub_analyzer.models.work import Award, Grant
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GrantsTable(Static):
|
|
14
|
+
"""All Grants from a work in a table."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
GrantsTable .grants-table {
|
|
18
|
+
height: auto;
|
|
19
|
+
padding: 1 2 0 2;
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, grants_list: list[Grant]) -> None:
|
|
24
|
+
self.grants_list = grants_list
|
|
25
|
+
super().__init__()
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
"""Compose Table."""
|
|
29
|
+
grants_table = Table(title="Grants", expand=True, show_lines=True)
|
|
30
|
+
|
|
31
|
+
# Define Columns
|
|
32
|
+
grants_table.add_column("", justify="center", vertical="middle")
|
|
33
|
+
grants_table.add_column("Name", ratio=3)
|
|
34
|
+
grants_table.add_column("Award ID", ratio=2)
|
|
35
|
+
|
|
36
|
+
for idx, grant in enumerate(self.grants_list):
|
|
37
|
+
name = f"""[@click=app.open_link('{quote(str(grant.funder))}')][u]{grant.funder_display_name}[/u][/]"""
|
|
38
|
+
award_id = grant.award_id or "-"
|
|
39
|
+
|
|
40
|
+
grants_table.add_row(
|
|
41
|
+
str(idx),
|
|
42
|
+
Text.from_markup(name, overflow="ellipsis"),
|
|
43
|
+
Text.from_markup(award_id),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
yield Static(grants_table, classes="grants-table")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AwardsTable(Static):
|
|
50
|
+
"""Grants or funding awards that support research."""
|
|
51
|
+
|
|
52
|
+
DEFAULT_CSS = """
|
|
53
|
+
GrantsTable .grants-table {
|
|
54
|
+
height: auto;
|
|
55
|
+
padding: 1 2 0 2;
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, awards_list: list[Award]) -> None:
|
|
60
|
+
self.awards_list = awards_list
|
|
61
|
+
super().__init__()
|
|
62
|
+
|
|
63
|
+
def compose(self) -> ComposeResult:
|
|
64
|
+
"""Compose Table."""
|
|
65
|
+
awards_table = Table(title="Awards", expand=True, show_lines=True)
|
|
66
|
+
|
|
67
|
+
# Define Columns
|
|
68
|
+
awards_table.add_column("", justify="center", vertical="middle")
|
|
69
|
+
awards_table.add_column("Name", ratio=3)
|
|
70
|
+
awards_table.add_column("Award ID", ratio=2)
|
|
71
|
+
awards_table.add_column("DOI", ratio=2)
|
|
72
|
+
|
|
73
|
+
for idx, award in enumerate(self.awards_list):
|
|
74
|
+
name = f"""[@click=app.open_link('{quote(str(award.funder_award_id))}')][u]{award.funder_display_name}[/u][/]"""
|
|
75
|
+
award_id = award.display_name or "-"
|
|
76
|
+
doi = f"""[@click=app.open_link('{quote(str(award.doi))}')][u]{award.doi}[/u][/]""" if award.doi else "-"
|
|
77
|
+
|
|
78
|
+
awards_table.add_row(
|
|
79
|
+
str(idx),
|
|
80
|
+
Text.from_markup(name, overflow="ellipsis"),
|
|
81
|
+
Text.from_markup(award_id),
|
|
82
|
+
Text.from_markup(doi, overflow="ellipsis"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
yield Static(awards_table, classes="grants-table")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Institution Report Widgets."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
5
|
+
|
|
6
|
+
from pub_analyzer.models.report import InstitutionReport
|
|
7
|
+
from pub_analyzer.widgets.institution.cards import CitationMetricsCard, IdentifiersCard, RolesCard
|
|
8
|
+
from pub_analyzer.widgets.institution.tables import InstitutionWorksByYearTable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InstitutionReportPane(VerticalScroll):
|
|
12
|
+
"""Work report Pane Widget."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
InstitutionReportPane {
|
|
16
|
+
layout: vertical;
|
|
17
|
+
overflow-x: hidden;
|
|
18
|
+
overflow-y: auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
InstitutionReportPane .table-container {
|
|
22
|
+
margin: 1 0 0 0 ;
|
|
23
|
+
height: auto;
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, report: InstitutionReport) -> None:
|
|
28
|
+
self.report = report
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
def compose(self) -> ComposeResult:
|
|
32
|
+
"""Compose content pane."""
|
|
33
|
+
with Horizontal(classes="cards-container"):
|
|
34
|
+
yield RolesCard(institution=self.report.institution)
|
|
35
|
+
yield IdentifiersCard(institution=self.report.institution)
|
|
36
|
+
yield CitationMetricsCard(institution=self.report.institution)
|
|
37
|
+
|
|
38
|
+
with Container(classes="table-container"):
|
|
39
|
+
yield InstitutionWorksByYearTable(institution=self.report.institution)
|