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,65 @@
1
+ """Author 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.author import Author
10
+ from pub_analyzer.widgets.common import Card
11
+
12
+
13
+ class CitationMetricsCard(Card):
14
+ """Citation metrics for this author."""
15
+
16
+ def __init__(self, author: Author) -> None:
17
+ self.author = author
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.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
+
29
+
30
+ class IdentifiersCard(Card):
31
+ """Card with external identifiers that we know about for this author."""
32
+
33
+ def __init__(self, author: Author) -> None:
34
+ self.author = author
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.author.ids.model_dump().items():
42
+ if platform_url:
43
+ yield Label(f"""- [@click=app.open_link('{quote(str(platform_url))}')]{platform}[/]""")
44
+
45
+
46
+ class LastInstitutionCard(Card):
47
+ """Card with author's last known institutional affiliation."""
48
+
49
+ def __init__(self, author: Author) -> None:
50
+ self.author = author
51
+ super().__init__()
52
+
53
+ def compose(self) -> ComposeResult:
54
+ """Compose card."""
55
+ yield Label("[italic]Last Institution:[/italic]", classes="card-title")
56
+
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
61
+
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}")
@@ -0,0 +1,122 @@
1
+ """Module with Widgets that allows to display the complete information of an Author 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_author_id
12
+ from pub_analyzer.models.author import Author, AuthorResult
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 CreateAuthorReportWidget
16
+
17
+ from .cards import CitationMetricsCard, IdentifiersCard, LastInstitutionCard
18
+ from .tables import AuthorWorksByYearTable
19
+
20
+
21
+ class _AuthorSummaryWidget(Static):
22
+ """Author info summary."""
23
+
24
+ def __init__(self, author: Author) -> None:
25
+ self.author = author
26
+ super().__init__()
27
+
28
+ def compose(self) -> ComposeResult:
29
+ """Compose author info."""
30
+ is_report_not_available = self.author.works_count < 1
31
+
32
+ # Compose Cards
33
+ with Vertical(classes="block-container"):
34
+ yield Label("[bold]Author info:[/bold]", classes="block-title")
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)
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.author.cited_by_count}")
47
+ yield Label(f"[bold]Works count:[/bold] {self.author.works_count}")
48
+
49
+ # Count by year table section
50
+ with Container(classes="table-container"):
51
+ yield AuthorWorksByYearTable(author=self.author)
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
+ # 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)
82
+
83
+ async def _get_info(self) -> None:
84
+ """Query OpenAlex API."""
85
+ author_id = get_author_id(self.author_result)
86
+ url = f"https://api.openalex.org/authors/{author_id}"
87
+
88
+ async with httpx.AsyncClient() as client:
89
+ results = (await client.get(url)).json()
90
+ self.author = Author(**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(_AuthorSummaryWidget(author=self.author))
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("_AuthorSummaryWidget 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("_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()
@@ -0,0 +1,50 @@
1
+ """Author Tables Widgets."""
2
+
3
+ from urllib.parse import quote
4
+
5
+ from rich.table import Table
6
+ from textual.app import ComposeResult
7
+ from textual.widgets import Static
8
+
9
+ from pub_analyzer.models.author import Author
10
+
11
+
12
+ class AuthorWorksByYearTable(Static):
13
+ """Table with Work count and cited by count of the last 10 years."""
14
+
15
+ def __init__(self, author: Author) -> None:
16
+ self.author = author
17
+ super().__init__()
18
+
19
+ def compose(self) -> ComposeResult:
20
+ """Compose Table."""
21
+ table = Table("Year", "Works Count", "Cited by Count", title="Counts by Year", expand=True)
22
+ for row in self.author.counts_by_year[:-11:-1]:
23
+ year, works_count, cited_by_count = row.model_dump().values()
24
+ table.add_row(str(year), str(works_count), str(cited_by_count))
25
+
26
+ yield Static(table)
27
+
28
+
29
+ class AffiliationsTable(Static):
30
+ """Table with all the institutions to which an author has been affiliated."""
31
+
32
+ def __init__(self, author: Author) -> None:
33
+ self.author = author
34
+ super().__init__()
35
+
36
+ def compose(self) -> ComposeResult:
37
+ """Compose Table."""
38
+ table = Table("Institution", "Country", "Type", "Years", title="Affiliations", expand=True, show_lines=True)
39
+ for affiliation in self.author.affiliations:
40
+ institution = affiliation.institution
41
+ institution_name = (
42
+ f"""[@click=app.open_link("{quote(str(institution.ror))}")]{institution.display_name}[/]"""
43
+ if institution.ror
44
+ else f"{institution.display_name}"
45
+ )
46
+ years = ",".join([str(year) for year in affiliation.years])
47
+ country_code = institution.country_code.upper() if institution.country_code else "-"
48
+ table.add_row(str(institution_name), str(country_code), str(institution.type.name), str(years))
49
+
50
+ yield Static(table)
@@ -0,0 +1,55 @@
1
+ """Body components."""
2
+
3
+ from rich.console import RenderableType
4
+ from textual import on
5
+ from textual.app import ComposeResult
6
+ from textual.message import Message
7
+ from textual.widget import Widget
8
+ from textual.widgets import Label, Static
9
+
10
+ from pub_analyzer.widgets.search import FinderWidget
11
+ from pub_analyzer.widgets.sidebar import SideBar
12
+
13
+
14
+ class MainContent(Static):
15
+ """Main content Widget."""
16
+
17
+ DEFAULT_CLASSES = "main-content"
18
+
19
+ class UpdateMainContent(Message):
20
+ """New main content required."""
21
+
22
+ def __init__(self, new_widget: Widget, title: str | None) -> None:
23
+ self.widget = new_widget
24
+ self.title = title
25
+ super().__init__()
26
+
27
+ def __init__(self, title: str = "Title") -> None:
28
+ self.title = title
29
+ super().__init__()
30
+
31
+ def compose(self) -> ComposeResult:
32
+ """Compose dynamically the main content view."""
33
+ yield Label(self.title, classes="title", id="page-title")
34
+ yield FinderWidget()
35
+
36
+ def update_title(self, title: RenderableType) -> None:
37
+ """Update view title."""
38
+ self.query_one("#page-title", Label).update(title)
39
+
40
+ @on(UpdateMainContent)
41
+ async def update_content(self, new_content: UpdateMainContent) -> None:
42
+ """Replace the main content."""
43
+ await self.query_children().exclude("#page-title").remove()
44
+ if new_content.title:
45
+ self.update_title(new_content.title)
46
+ await self.mount(new_content.widget)
47
+
48
+
49
+ class Body(Static):
50
+ """Body App."""
51
+
52
+ def compose(self) -> ComposeResult:
53
+ """Body App."""
54
+ yield SideBar()
55
+ yield MainContent(title="Search")
@@ -0,0 +1,18 @@
1
+ """Base Widgets."""
2
+
3
+ from .card import Card
4
+ from .filesystem import FileSystemSelector
5
+ from .input import DateInput, Input
6
+ from .label import ReactiveLabel
7
+ from .modal import Modal
8
+ from .selector import Select
9
+
10
+ __all__ = [
11
+ "Card",
12
+ "DateInput",
13
+ "FileSystemSelector",
14
+ "Input",
15
+ "Modal",
16
+ "ReactiveLabel",
17
+ "Select",
18
+ ]
@@ -0,0 +1,29 @@
1
+ """Card Widget."""
2
+
3
+ from textual.widgets import Static
4
+
5
+
6
+ class Card(Static):
7
+ """Container for short, related pieces of content displayed in a box."""
8
+
9
+ DEFAULT_CLASSES = "card"
10
+ DEFAULT_CSS = """
11
+ $bg-secondary-color: #e5e7eb;
12
+ $text-primary-color: black;
13
+
14
+ Card {
15
+ layout: vertical;
16
+ height: 100%;
17
+
18
+ padding: 1 2;
19
+ border: solid $text-primary-color;
20
+ background: $bg-secondary-color;
21
+ }
22
+
23
+ Card > .card-title {
24
+ margin: 0 0 1 0;
25
+ width: 100%;
26
+ text-align: center;
27
+ border-bottom: solid black;
28
+ }
29
+ """
@@ -0,0 +1,203 @@
1
+ """FileSystem Selector widgets."""
2
+
3
+ from collections.abc import Iterable
4
+ from enum import Enum, auto
5
+ from pathlib import Path
6
+
7
+ from rich.console import RenderableType
8
+ from rich.text import Text
9
+ from textual import events, on
10
+ from textual.app import ComposeResult
11
+ from textual.containers import Horizontal, VerticalScroll
12
+ from textual.message import Message
13
+ from textual.reactive import reactive
14
+ from textual.widget import Widget
15
+ from textual.widgets import Button, DirectoryTree, Label, Static, Tree
16
+ from textual.widgets._directory_tree import DirEntry
17
+
18
+ from .modal import Modal
19
+
20
+
21
+ class PathTypeSelector(Enum):
22
+ """Expected path type to be selected."""
23
+
24
+ FILE = auto()
25
+ DIRECTORY = auto()
26
+
27
+
28
+ class FilteredDirectoryTree(DirectoryTree):
29
+ """Directory Tree filtered."""
30
+
31
+ def __init__(
32
+ self, path: str | Path, *, show_hidden_paths: bool = False, only_dir: bool = False, extension: list[str] | None = None
33
+ ) -> None:
34
+ self.show_hidden_paths = show_hidden_paths
35
+ self.only_dir = only_dir
36
+ self.extension = extension
37
+ super().__init__(path)
38
+
39
+ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
40
+ """Filter paths with parameters specified in the class."""
41
+ final_paths: list[Path] = []
42
+ for path in paths:
43
+ # Filter in case show hidden paths are not enable.
44
+ if not self.show_hidden_paths and path.name.startswith("."):
45
+ continue
46
+
47
+ # Filter in case path type expected is directory.
48
+ if path.is_file() and self.only_dir:
49
+ continue
50
+
51
+ # Filter in case file extension is expected
52
+ if path.is_file() and self.extension and path.suffix not in self.extension:
53
+ continue
54
+
55
+ final_paths.append(path)
56
+
57
+ return final_paths
58
+
59
+
60
+ class PathSelectorModal(Modal[Path | None]):
61
+ """Export modal confirmation."""
62
+
63
+ DEFAULT_CSS = """
64
+ $bg-main-color: white;
65
+ $bg-secondary-color: #e5e7eb;
66
+
67
+ PathSelectorModal FilteredDirectoryTree {
68
+ background: $bg-main-color;
69
+ width: 100%;
70
+ margin: 0 2;
71
+ }
72
+
73
+ .-dark-mode PathSelectorModal FilteredDirectoryTree {
74
+ background: $bg-secondary-color;
75
+ }
76
+
77
+ PathSelectorModal .button-container {
78
+ height: 5;
79
+ align: center middle;
80
+ }
81
+ """
82
+
83
+ def __init__(
84
+ self, path: str | Path, show_hidden_paths: bool = False, only_dir: bool = False, extension: list[str] | None = None
85
+ ) -> None:
86
+ self.path = path
87
+ self.show_hidden_paths = show_hidden_paths
88
+ self.only_dir = only_dir
89
+ self.extension = extension
90
+
91
+ self.path_selected: Path | None = None
92
+ super().__init__()
93
+
94
+ @on(events.Key)
95
+ def exit_modal(self, message: events.Key) -> None:
96
+ """Exit from the modal with esc KEY."""
97
+ if message.key == "escape":
98
+ self.app.pop_screen()
99
+
100
+ @on(Button.Pressed, "#done-button")
101
+ def done_button(self) -> None:
102
+ """Done button."""
103
+ self.dismiss(self.path_selected)
104
+
105
+ @on(DirectoryTree.FileSelected)
106
+ def update_path_selected_for_file(self, event: DirectoryTree.FileSelected) -> None:
107
+ """Update file selected."""
108
+ if not self.only_dir:
109
+ self.path_selected = event.path
110
+ self.query_one("#done-button", Button).disabled = False
111
+
112
+ @on(Tree.NodeHighlighted)
113
+ def update_path_selected_for_dir(self, event: Tree.NodeHighlighted[DirEntry]) -> None:
114
+ """Update file selected."""
115
+ if self.only_dir and event.node and event.node.data:
116
+ self.path_selected = event.node.data.path
117
+ self.query_one("#done-button", Button).disabled = False
118
+
119
+ def compose(self) -> ComposeResult:
120
+ """Compose Modal."""
121
+ with VerticalScroll(id="dialog"):
122
+ yield Label("Export Path", classes="dialog-title")
123
+ yield FilteredDirectoryTree(
124
+ path=self.path, show_hidden_paths=self.show_hidden_paths, only_dir=self.only_dir, extension=self.extension
125
+ )
126
+
127
+ with Horizontal(classes="button-container"):
128
+ yield Button("Done", variant="primary", disabled=True, id="done-button")
129
+
130
+
131
+ class PathSelectedBox(Widget):
132
+ """Path selected box."""
133
+
134
+ DEFAULT_CSS = """
135
+ PathSelectedBox {
136
+ height: 3;
137
+ width: 100%;
138
+
139
+ background: $boost;
140
+ color: $text;
141
+
142
+ padding: 0 2;
143
+ border: tall $background;
144
+ }
145
+ """
146
+
147
+ path_selected: reactive[str] = reactive("...", layout=True)
148
+
149
+ class Selected(Message):
150
+ """Selected message."""
151
+
152
+ def on_click(self) -> None:
153
+ """Post message on click."""
154
+ self.post_message(self.Selected())
155
+
156
+ def render(self) -> RenderableType:
157
+ """Render path selected box."""
158
+ return Text(self.path_selected, overflow="ellipsis")
159
+
160
+
161
+ class FileSystemSelector(Static):
162
+ """File System selector widget."""
163
+
164
+ class FileSelected(Message):
165
+ """File Selected Message."""
166
+
167
+ def __init__(self, file_selected: Path | None) -> None:
168
+ self.file_selected = file_selected
169
+ super().__init__()
170
+
171
+ def __init__(
172
+ self, path: str | Path, show_hidden_paths: bool = False, only_dir: bool = False, extension: list[str] | None = None
173
+ ) -> None:
174
+ self.path = path
175
+ self.show_hidden_paths = show_hidden_paths
176
+ self.only_dir = only_dir
177
+ self.extension = extension
178
+
179
+ self.path_selected: Path | None = None
180
+ super().__init__()
181
+
182
+ @on(PathSelectedBox.Selected)
183
+ async def show_export_report_modal(self) -> None:
184
+ """Show export Modal."""
185
+
186
+ def update_file_selected(path: Path | None) -> None:
187
+ """Call when modal is closed."""
188
+ self.path_selected = path
189
+ if path:
190
+ self.query_one(PathSelectedBox).path_selected = path.as_posix()
191
+ self.post_message(self.FileSelected(file_selected=path))
192
+ else:
193
+ self.query_one(PathSelectedBox).path_selected = "..."
194
+ self.post_message(self.FileSelected(file_selected=path))
195
+
196
+ await self.app.push_screen(
197
+ PathSelectorModal(path=self.path, show_hidden_paths=self.show_hidden_paths, only_dir=self.only_dir, extension=self.extension),
198
+ callback=update_file_selected,
199
+ )
200
+
201
+ def compose(self) -> ComposeResult:
202
+ """Compose file system selector."""
203
+ yield PathSelectedBox()
@@ -0,0 +1,111 @@
1
+ """Filters selectors for OpenAlex API."""
2
+
3
+ from datetime import datetime
4
+
5
+ from rich.console import RenderableType
6
+ from textual import on
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal
9
+ from textual.message import Message
10
+ from textual.reactive import reactive, var
11
+ from textual.widgets import Checkbox, Static
12
+
13
+ from .input import DateInput
14
+
15
+
16
+ class Filter(Static):
17
+ """Base filter."""
18
+
19
+ class Changed(Message):
20
+ """Posted when any input in the filter changes."""
21
+
22
+ filter_disabled: reactive[bool] = reactive(True)
23
+ """Is filter inputs disabled?"""
24
+
25
+ @property
26
+ def validation_state(self) -> bool:
27
+ """Return true if all validation passes."""
28
+ raise NotImplementedError
29
+
30
+
31
+ class DateRangeFilter(Filter):
32
+ """Date range selector."""
33
+
34
+ DEFAULT_CSS = """
35
+ DateRangeFilter {
36
+ height: auto;
37
+ layout: horizontal;
38
+ }
39
+
40
+ DateRangeFilter Checkbox {
41
+ width: 1fr;
42
+ }
43
+
44
+ DateRangeFilter .filter-inputs {
45
+ height: auto;
46
+ width: 3fr;
47
+ }
48
+
49
+ DateRangeFilter DateInput {
50
+ width: 1fr;
51
+ }
52
+ """
53
+
54
+ from_date: var[datetime | None] = var(None)
55
+ to_date: var[datetime | None] = var(None)
56
+
57
+ def __init__(
58
+ self,
59
+ checkbox_label: str = "Date Range",
60
+ renderable: RenderableType = "",
61
+ *,
62
+ expand: bool = False,
63
+ shrink: bool = False,
64
+ markup: bool = True,
65
+ name: str | None = None,
66
+ id: str | None = None,
67
+ classes: str | None = None,
68
+ disabled: bool = False,
69
+ ) -> None:
70
+ self.checkbox_label = checkbox_label
71
+ super().__init__(renderable, expand=expand, shrink=shrink, markup=markup, name=name, id=id, classes=classes, disabled=disabled)
72
+
73
+ def compose(self) -> ComposeResult:
74
+ """Compose Date range selector."""
75
+ yield Checkbox(self.checkbox_label, value=False, id="filter-checkbox")
76
+ with Horizontal(classes="filter-inputs", disabled=True):
77
+ yield DateInput(placeholder="From yyyy-mm-dd", id="from-date")
78
+ yield DateInput(placeholder="To yyyy-mm-dd", id="to-date")
79
+
80
+ def watch_filter_disabled(self, is_filter_disabled: bool) -> None:
81
+ """Toggle filter disable status with the reactive attribute."""
82
+ self.query_one(".filter-inputs", Horizontal).disabled = is_filter_disabled
83
+
84
+ @property
85
+ def validation_state(self) -> bool:
86
+ """Return true if all datetime inputs are correctly formatted."""
87
+ return all([self.from_date, self.to_date])
88
+
89
+ @on(Checkbox.Changed)
90
+ def toggle_filter_disabled(self, event: Checkbox.Changed) -> None:
91
+ """Toggle filter enabled status."""
92
+ event.stop()
93
+ self.post_message(self.Changed())
94
+ self.filter_disabled = not event.value
95
+
96
+ @on(DateInput.Changed)
97
+ def date_input_handler(self, event: DateInput.Changed) -> None:
98
+ """Handle date input change."""
99
+ event.stop()
100
+ self.post_message(self.Changed())
101
+ date_input = event.input
102
+
103
+ if event.validation_result:
104
+ new_value = datetime.strptime(event.value, "%Y-%m-%d") if event.validation_result.is_valid else None
105
+ else:
106
+ new_value = None
107
+
108
+ if date_input.id == "from-date":
109
+ self.from_date = new_value
110
+ elif date_input.id == "to-date":
111
+ self.to_date = new_value