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.
Files changed (70) hide show
  1. pub_analyzer/__init__.py +1 -0
  2. pub_analyzer/__main__.py +7 -0
  3. pub_analyzer/css/body.tcss +87 -0
  4. pub_analyzer/css/buttons.tcss +24 -0
  5. pub_analyzer/css/checkbox.tcss +29 -0
  6. pub_analyzer/css/collapsible.tcss +31 -0
  7. pub_analyzer/css/datatable.tcss +50 -0
  8. pub_analyzer/css/editor.tcss +60 -0
  9. pub_analyzer/css/main.tcss +50 -0
  10. pub_analyzer/css/report.tcss +131 -0
  11. pub_analyzer/css/search.tcss +81 -0
  12. pub_analyzer/css/summary.tcss +75 -0
  13. pub_analyzer/css/tabs.tcss +18 -0
  14. pub_analyzer/css/tree.tcss +44 -0
  15. pub_analyzer/internal/__init__.py +1 -0
  16. pub_analyzer/internal/identifier.py +106 -0
  17. pub_analyzer/internal/limiter.py +34 -0
  18. pub_analyzer/internal/render.py +41 -0
  19. pub_analyzer/internal/report.py +497 -0
  20. pub_analyzer/internal/templates/author_report.typ +591 -0
  21. pub_analyzer/main.py +81 -0
  22. pub_analyzer/models/__init__.py +1 -0
  23. pub_analyzer/models/author.py +87 -0
  24. pub_analyzer/models/concept.py +19 -0
  25. pub_analyzer/models/institution.py +138 -0
  26. pub_analyzer/models/report.py +111 -0
  27. pub_analyzer/models/source.py +77 -0
  28. pub_analyzer/models/topic.py +59 -0
  29. pub_analyzer/models/work.py +158 -0
  30. pub_analyzer/widgets/__init__.py +1 -0
  31. pub_analyzer/widgets/author/__init__.py +1 -0
  32. pub_analyzer/widgets/author/cards.py +65 -0
  33. pub_analyzer/widgets/author/core.py +122 -0
  34. pub_analyzer/widgets/author/tables.py +50 -0
  35. pub_analyzer/widgets/body.py +55 -0
  36. pub_analyzer/widgets/common/__init__.py +18 -0
  37. pub_analyzer/widgets/common/card.py +29 -0
  38. pub_analyzer/widgets/common/filesystem.py +203 -0
  39. pub_analyzer/widgets/common/filters.py +111 -0
  40. pub_analyzer/widgets/common/input.py +97 -0
  41. pub_analyzer/widgets/common/label.py +36 -0
  42. pub_analyzer/widgets/common/modal.py +43 -0
  43. pub_analyzer/widgets/common/selector.py +66 -0
  44. pub_analyzer/widgets/common/summary.py +7 -0
  45. pub_analyzer/widgets/institution/__init__.py +1 -0
  46. pub_analyzer/widgets/institution/cards.py +78 -0
  47. pub_analyzer/widgets/institution/core.py +122 -0
  48. pub_analyzer/widgets/institution/tables.py +24 -0
  49. pub_analyzer/widgets/report/__init__.py +1 -0
  50. pub_analyzer/widgets/report/author.py +43 -0
  51. pub_analyzer/widgets/report/cards.py +130 -0
  52. pub_analyzer/widgets/report/concept.py +47 -0
  53. pub_analyzer/widgets/report/core.py +308 -0
  54. pub_analyzer/widgets/report/editor.py +80 -0
  55. pub_analyzer/widgets/report/export.py +112 -0
  56. pub_analyzer/widgets/report/grants.py +85 -0
  57. pub_analyzer/widgets/report/institution.py +39 -0
  58. pub_analyzer/widgets/report/locations.py +75 -0
  59. pub_analyzer/widgets/report/source.py +90 -0
  60. pub_analyzer/widgets/report/topic.py +55 -0
  61. pub_analyzer/widgets/report/work.py +391 -0
  62. pub_analyzer/widgets/search/__init__.py +11 -0
  63. pub_analyzer/widgets/search/core.py +96 -0
  64. pub_analyzer/widgets/search/results.py +82 -0
  65. pub_analyzer/widgets/sidebar.py +70 -0
  66. pub_analyzer-0.5.6.dist-info/METADATA +102 -0
  67. pub_analyzer-0.5.6.dist-info/RECORD +70 -0
  68. pub_analyzer-0.5.6.dist-info/WHEEL +4 -0
  69. pub_analyzer-0.5.6.dist-info/entry_points.txt +3 -0
  70. 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)