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,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
|