jnav 1.0.0__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.
- jnav/__init__.py +0 -0
- jnav/__main__.py +108 -0
- jnav/app.py +398 -0
- jnav/buffer.py +48 -0
- jnav/detail_tree.py +242 -0
- jnav/field_mapping.py +136 -0
- jnav/filter_manager_screen.py +100 -0
- jnav/filter_provider.py +103 -0
- jnav/filter_tree.py +344 -0
- jnav/filtering.py +191 -0
- jnav/header.py +48 -0
- jnav/help_screen.py +79 -0
- jnav/inline_tree.py +75 -0
- jnav/key_sequences.py +211 -0
- jnav/log_entry_item.py +96 -0
- jnav/log_entry_renderer.py +90 -0
- jnav/log_list_view.py +292 -0
- jnav/log_model.py +114 -0
- jnav/logging.py +52 -0
- jnav/manager_screen_common.py +27 -0
- jnav/model.py +13 -0
- jnav/offset_group.py +30 -0
- jnav/parsing.py +101 -0
- jnav/reading.py +52 -0
- jnav/role_mapper.py +53 -0
- jnav/scrollbar_overlay.py +79 -0
- jnav/search_engine.py +87 -0
- jnav/selector_manager_screen.py +187 -0
- jnav/selector_provider.py +64 -0
- jnav/store.py +37 -0
- jnav/text_input_screen.py +63 -0
- jnav/tree_rendering.py +194 -0
- jnav/virtual_list_view.py +383 -0
- jnav-1.0.0.dist-info/METADATA +186 -0
- jnav-1.0.0.dist-info/RECORD +37 -0
- jnav-1.0.0.dist-info/WHEEL +4 -0
- jnav-1.0.0.dist-info/entry_points.txt +3 -0
jnav/detail_tree.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, TypedDict
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.binding import Binding, BindingType
|
|
9
|
+
from textual.events import Key
|
|
10
|
+
from textual.widgets import Tree
|
|
11
|
+
from textual.widgets.tree import TreeNode
|
|
12
|
+
|
|
13
|
+
from .filter_provider import FilterProvider
|
|
14
|
+
from .filtering import get_nested, jq_value_literal, resolve_selected_paths
|
|
15
|
+
from .key_sequences import KeySequence, KeySequenceMixin
|
|
16
|
+
from .log_entry_item import format_timestamp
|
|
17
|
+
from .parsing import ParsedEntry
|
|
18
|
+
from .role_mapper import RoleMapper
|
|
19
|
+
from .search_engine import SearchEngine
|
|
20
|
+
from .selector_provider import SelectorProvider
|
|
21
|
+
from .tree_rendering import TreeBuildVisitor, walk_tree
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TreeNodeData(TypedDict):
|
|
25
|
+
path: str
|
|
26
|
+
value: object
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _detail_add_branch(
|
|
30
|
+
parent: TreeNode[TreeNodeData],
|
|
31
|
+
label: Text,
|
|
32
|
+
path: str,
|
|
33
|
+
value: object,
|
|
34
|
+
) -> TreeNode[TreeNodeData]:
|
|
35
|
+
return parent.add(label, data={"path": path, "value": value})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _detail_add_leaf(
|
|
39
|
+
parent: TreeNode[TreeNodeData],
|
|
40
|
+
label: Text,
|
|
41
|
+
path: str,
|
|
42
|
+
value: object,
|
|
43
|
+
) -> None:
|
|
44
|
+
parent.add_leaf(label, data={"path": path, "value": value})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from textual import getters
|
|
49
|
+
from textual.app import App
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DetailTree(KeySequenceMixin, Tree[TreeNodeData]):
|
|
53
|
+
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
|
54
|
+
"tree--key",
|
|
55
|
+
"tree--key-selected",
|
|
56
|
+
"tree--value",
|
|
57
|
+
"tree--value-null",
|
|
58
|
+
"tree--json-string",
|
|
59
|
+
"tree--search-highlight",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
DEFAULT_CSS = ""
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING:
|
|
65
|
+
app = getters.app(App[None])
|
|
66
|
+
|
|
67
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
68
|
+
Binding("j", "cursor_down", show=False),
|
|
69
|
+
Binding("k", "cursor_up", show=False),
|
|
70
|
+
Binding("g", "scroll_home", show=False),
|
|
71
|
+
Binding("G", "scroll_end", show=False),
|
|
72
|
+
Binding("ctrl+d", "page_down", show=False),
|
|
73
|
+
Binding("ctrl+u", "page_up", show=False),
|
|
74
|
+
Binding("s", "add_select", "Select"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
SEQUENCES: ClassVar[list[KeySequence]] = [
|
|
78
|
+
KeySequence("ff", "filter_value", "by value"),
|
|
79
|
+
KeySequence("fn", "filter_has", "has field"),
|
|
80
|
+
KeySequence("vo", "toggle_filter_tree", "show selected only"),
|
|
81
|
+
KeySequence("ve", "view_value", "open in editor"),
|
|
82
|
+
]
|
|
83
|
+
SEQUENCE_GROUPS: ClassVar[dict[str, str]] = {"f": "filter ▸", "v": "view ▸"}
|
|
84
|
+
|
|
85
|
+
show_selected_only: bool = False
|
|
86
|
+
_entry: ParsedEntry | None = None
|
|
87
|
+
_entry_index: int = 0
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
label: str,
|
|
92
|
+
*,
|
|
93
|
+
selectors: SelectorProvider,
|
|
94
|
+
filters: FilterProvider,
|
|
95
|
+
search: SearchEngine,
|
|
96
|
+
role_mapper: RoleMapper,
|
|
97
|
+
id: str | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
super().__init__(label, id=id)
|
|
100
|
+
self._selectors = selectors
|
|
101
|
+
self._filters = filters
|
|
102
|
+
self._search = search
|
|
103
|
+
self._role_mapper = role_mapper
|
|
104
|
+
|
|
105
|
+
def on_focus(self) -> None:
|
|
106
|
+
if self.parent is not None:
|
|
107
|
+
self.parent.add_class("focused")
|
|
108
|
+
|
|
109
|
+
def on_blur(self) -> None:
|
|
110
|
+
if self.parent is not None:
|
|
111
|
+
self.parent.remove_class("focused")
|
|
112
|
+
|
|
113
|
+
async def on_mount(self) -> None: # pyright: ignore[reportIncompatibleMethodOverride, reportImplicitOverride]
|
|
114
|
+
await self._selectors.on_change.subscribe_async(self._rerender)
|
|
115
|
+
await self._search.on_change.subscribe_async(self._rerender)
|
|
116
|
+
await self._role_mapper.on_change.subscribe_async(self._rerender)
|
|
117
|
+
self.app.theme_changed_signal.subscribe(self, lambda _: self._rebuild_tree())
|
|
118
|
+
|
|
119
|
+
async def _rerender(self, _: None) -> None:
|
|
120
|
+
self._rebuild_tree()
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def entry(self) -> ParsedEntry | None:
|
|
124
|
+
return self._entry
|
|
125
|
+
|
|
126
|
+
def show_entry(self, parsed: ParsedEntry, index: int) -> None:
|
|
127
|
+
self._entry = parsed
|
|
128
|
+
self._entry_index = index
|
|
129
|
+
self._rebuild_tree()
|
|
130
|
+
|
|
131
|
+
def _rebuild_tree(self) -> None:
|
|
132
|
+
if self._entry is None:
|
|
133
|
+
return
|
|
134
|
+
entry = self._entry.expanded
|
|
135
|
+
selected = resolve_selected_paths(self._selectors.active_selectors, entry)
|
|
136
|
+
|
|
137
|
+
label = f"#{self._entry_index + 1}"
|
|
138
|
+
ts_field = self._role_mapper.mapping.timestamp
|
|
139
|
+
if ts_field is not None:
|
|
140
|
+
ts_val = entry.get(ts_field.path)
|
|
141
|
+
if ts_val not in (None, ""):
|
|
142
|
+
label += f" ({format_timestamp(ts_val, ts_field.format)})"
|
|
143
|
+
if self.show_selected_only:
|
|
144
|
+
label += " (selected)"
|
|
145
|
+
|
|
146
|
+
self.clear()
|
|
147
|
+
self.root.set_label(label)
|
|
148
|
+
data = (
|
|
149
|
+
{s: get_nested(entry, s) for s in self._selectors.active_selectors}
|
|
150
|
+
if self.show_selected_only
|
|
151
|
+
else entry
|
|
152
|
+
)
|
|
153
|
+
root_data: TreeNodeData = {"path": "", "value": data}
|
|
154
|
+
self.root.data = root_data
|
|
155
|
+
visitor = TreeBuildVisitor(
|
|
156
|
+
root=self.root,
|
|
157
|
+
add_branch=_detail_add_branch,
|
|
158
|
+
add_leaf=_detail_add_leaf,
|
|
159
|
+
selected=selected,
|
|
160
|
+
key_style=self.get_component_rich_style("tree--key", partial=True),
|
|
161
|
+
selected_style=self.get_component_rich_style(
|
|
162
|
+
"tree--key-selected", partial=True
|
|
163
|
+
),
|
|
164
|
+
value_style=self.get_component_rich_style("tree--value", partial=True),
|
|
165
|
+
value_null_style=self.get_component_rich_style(
|
|
166
|
+
"tree--value-null", partial=True
|
|
167
|
+
),
|
|
168
|
+
json_string_style=self.get_component_rich_style(
|
|
169
|
+
"tree--json-string", partial=True
|
|
170
|
+
),
|
|
171
|
+
search_highlight_style=self.get_component_rich_style(
|
|
172
|
+
"tree--search-highlight", partial=True
|
|
173
|
+
),
|
|
174
|
+
search_term=self._search.term,
|
|
175
|
+
)
|
|
176
|
+
walk_tree(
|
|
177
|
+
value=data, path="", visitor=visitor, json_paths=self._entry.expanded_paths
|
|
178
|
+
)
|
|
179
|
+
self.root.expand_all()
|
|
180
|
+
|
|
181
|
+
async def on_key(self, event: Key) -> None:
|
|
182
|
+
if await self._handle_sequence_key(event):
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
async def action_filter_value(self) -> None:
|
|
186
|
+
node = self.cursor_node
|
|
187
|
+
if node is None or node.data is None:
|
|
188
|
+
return
|
|
189
|
+
path = node.data["path"]
|
|
190
|
+
value = node.data["value"]
|
|
191
|
+
if isinstance(value, (dict, list)):
|
|
192
|
+
return
|
|
193
|
+
expr = f".{path} == {jq_value_literal(value)}"
|
|
194
|
+
await self._filters.add_filter(expr)
|
|
195
|
+
|
|
196
|
+
async def action_filter_has(self) -> None:
|
|
197
|
+
node = self.cursor_node
|
|
198
|
+
if node is None or node.data is None:
|
|
199
|
+
return
|
|
200
|
+
path = node.data["path"]
|
|
201
|
+
await self._filters.add_filter(f".{path} != null")
|
|
202
|
+
|
|
203
|
+
def action_toggle_filter_tree(self) -> None:
|
|
204
|
+
self.show_selected_only = not self.show_selected_only
|
|
205
|
+
self._rebuild_tree()
|
|
206
|
+
|
|
207
|
+
async def action_add_select(self) -> None:
|
|
208
|
+
node = self.cursor_node
|
|
209
|
+
if node is None or node.data is None:
|
|
210
|
+
return
|
|
211
|
+
selector = "." + node.data["path"]
|
|
212
|
+
if self._selectors.has_selector(selector):
|
|
213
|
+
await self._selectors.remove_selector_by_path(selector)
|
|
214
|
+
else:
|
|
215
|
+
await self._selectors.add_selector(selector)
|
|
216
|
+
|
|
217
|
+
def action_view_value(self) -> None:
|
|
218
|
+
node = self.cursor_node
|
|
219
|
+
if node is None or node.data is None:
|
|
220
|
+
return
|
|
221
|
+
value = node.data["value"]
|
|
222
|
+
if isinstance(value, (dict, list)):
|
|
223
|
+
content = json.dumps(value, indent=2, default=str)
|
|
224
|
+
suffix = ".json"
|
|
225
|
+
else:
|
|
226
|
+
content = str(value)
|
|
227
|
+
suffix = ".txt"
|
|
228
|
+
editor = os.environ.get("EDITOR", "less")
|
|
229
|
+
tmpdir = "/tmp/jnav"
|
|
230
|
+
os.makedirs(tmpdir, exist_ok=True)
|
|
231
|
+
with tempfile.NamedTemporaryFile(
|
|
232
|
+
mode="w",
|
|
233
|
+
suffix=suffix,
|
|
234
|
+
prefix="jnav_",
|
|
235
|
+
dir=tmpdir,
|
|
236
|
+
delete=False,
|
|
237
|
+
) as f:
|
|
238
|
+
f.write(content)
|
|
239
|
+
path = f.name
|
|
240
|
+
with self.app.suspend():
|
|
241
|
+
subprocess.run([editor, path], check=False)
|
|
242
|
+
os.unlink(path)
|
jnav/field_mapping.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
TimestampFormat = Literal["iso8601", "epoch_s", "epoch_ms", "epoch_us", "epoch_ns"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TimestampField(BaseModel):
|
|
12
|
+
path: str
|
|
13
|
+
format: TimestampFormat
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_timestamp(path: str, value: object) -> TimestampField | None:
|
|
17
|
+
fmt = _detect_timestamp_format(value)
|
|
18
|
+
return TimestampField(path=path, format=fmt) if fmt is not None else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_string_role(path: str, value: object) -> str | None:
|
|
22
|
+
return path if value not in (None, "") else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RoleSpec:
|
|
27
|
+
name: str
|
|
28
|
+
candidates: list[str]
|
|
29
|
+
build: Callable[[str, object], object | None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ROLES: list[RoleSpec] = [
|
|
33
|
+
RoleSpec(
|
|
34
|
+
name="timestamp",
|
|
35
|
+
candidates=[
|
|
36
|
+
"@timestamp",
|
|
37
|
+
"timestamp",
|
|
38
|
+
"ts",
|
|
39
|
+
"time",
|
|
40
|
+
"@t",
|
|
41
|
+
"asctime",
|
|
42
|
+
"eventTime",
|
|
43
|
+
"Timestamp",
|
|
44
|
+
],
|
|
45
|
+
build=_build_timestamp,
|
|
46
|
+
),
|
|
47
|
+
RoleSpec(
|
|
48
|
+
name="level",
|
|
49
|
+
candidates=[
|
|
50
|
+
"level",
|
|
51
|
+
"severity",
|
|
52
|
+
"levelname",
|
|
53
|
+
"@l",
|
|
54
|
+
"log_level",
|
|
55
|
+
"loglevel",
|
|
56
|
+
"SeverityText",
|
|
57
|
+
],
|
|
58
|
+
build=_build_string_role,
|
|
59
|
+
),
|
|
60
|
+
RoleSpec(
|
|
61
|
+
name="message",
|
|
62
|
+
candidates=[
|
|
63
|
+
"message",
|
|
64
|
+
"msg",
|
|
65
|
+
"@m",
|
|
66
|
+
"event",
|
|
67
|
+
"Body",
|
|
68
|
+
"log",
|
|
69
|
+
],
|
|
70
|
+
build=_build_string_role,
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FieldMapping(BaseModel):
|
|
76
|
+
timestamp: TimestampField | None = None
|
|
77
|
+
level: str | None = None
|
|
78
|
+
message: str | None = None
|
|
79
|
+
|
|
80
|
+
def assignments(self) -> dict[str, object | None]:
|
|
81
|
+
return {
|
|
82
|
+
"timestamp": self.timestamp,
|
|
83
|
+
"level": self.level,
|
|
84
|
+
"message": self.message,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def missing_roles(self) -> list[str]:
|
|
88
|
+
return [name for name, value in self.assignments().items() if value is None]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detect_role_updates(
|
|
92
|
+
mapping: FieldMapping,
|
|
93
|
+
entry: dict[str, object],
|
|
94
|
+
new_fields: set[str],
|
|
95
|
+
) -> dict[str, object]:
|
|
96
|
+
"""Detect updates to the field mapping based on a new entry.
|
|
97
|
+
For each role, if it's not already set in the mapping, check the candidate fields.
|
|
98
|
+
If a candidate field is present in the new fields, attempt to build the role value.
|
|
99
|
+
If successful, add it to the updates dict.
|
|
100
|
+
"""
|
|
101
|
+
updates: dict[str, object] = {}
|
|
102
|
+
current = mapping.assignments()
|
|
103
|
+
for role in ROLES:
|
|
104
|
+
if current[role.name] is not None:
|
|
105
|
+
continue
|
|
106
|
+
for candidate in role.candidates:
|
|
107
|
+
if candidate not in new_fields:
|
|
108
|
+
continue
|
|
109
|
+
built = role.build(candidate, entry.get(candidate))
|
|
110
|
+
if built is not None:
|
|
111
|
+
updates[role.name] = built
|
|
112
|
+
break
|
|
113
|
+
return updates
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _detect_timestamp_format(value: object) -> TimestampFormat | None:
|
|
117
|
+
if isinstance(value, str) and value:
|
|
118
|
+
try:
|
|
119
|
+
_ = datetime.fromisoformat(value)
|
|
120
|
+
return "iso8601"
|
|
121
|
+
except ValueError:
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
124
|
+
return _epoch_format_from_magnitude(float(value))
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _epoch_format_from_magnitude(value: float) -> TimestampFormat:
|
|
129
|
+
magnitude = abs(value)
|
|
130
|
+
if magnitude < 1e11:
|
|
131
|
+
return "epoch_s"
|
|
132
|
+
if magnitude < 1e14:
|
|
133
|
+
return "epoch_ms"
|
|
134
|
+
if magnitude < 1e17:
|
|
135
|
+
return "epoch_us"
|
|
136
|
+
return "epoch_ns"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from typing import ClassVar, override
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding, BindingType
|
|
5
|
+
from textual.containers import Vertical
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from jnav.filter_provider import FilterProvider
|
|
10
|
+
from jnav.filter_tree import FilterTree
|
|
11
|
+
from jnav.filtering import build_expression
|
|
12
|
+
from jnav.manager_screen_common import WrappingFooter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FilterManagerScreen(ModalScreen[bool]):
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
FilterManagerScreen {
|
|
18
|
+
align: center middle;
|
|
19
|
+
}
|
|
20
|
+
#filter-modal {
|
|
21
|
+
width: 70;
|
|
22
|
+
max-width: 90%;
|
|
23
|
+
height: auto;
|
|
24
|
+
max-height: 70%;
|
|
25
|
+
border: round $primary;
|
|
26
|
+
background: $background;
|
|
27
|
+
}
|
|
28
|
+
#filter-wrapper {
|
|
29
|
+
padding: 1 2;
|
|
30
|
+
height: auto;
|
|
31
|
+
}
|
|
32
|
+
#filter-expression {
|
|
33
|
+
color: $accent;
|
|
34
|
+
background: transparent;
|
|
35
|
+
border: round $background-lighten-2;
|
|
36
|
+
margin: 1 0 0 0;
|
|
37
|
+
padding: 0 1;
|
|
38
|
+
height: auto;
|
|
39
|
+
max-height: 5;
|
|
40
|
+
}
|
|
41
|
+
#filter-expression.empty {
|
|
42
|
+
color: $text-muted;
|
|
43
|
+
}
|
|
44
|
+
#filter-hints {
|
|
45
|
+
color: $text-muted;
|
|
46
|
+
margin: 1 0 0 0;
|
|
47
|
+
}
|
|
48
|
+
#filter-modal Footer {
|
|
49
|
+
background: transparent;
|
|
50
|
+
layout: grid;
|
|
51
|
+
grid-size: 4;
|
|
52
|
+
height: auto;
|
|
53
|
+
}
|
|
54
|
+
#filter-modal FooterKey .footer-key--key {
|
|
55
|
+
color: $primary;
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
60
|
+
Binding("escape", "maybe_close", "Close", priority=True),
|
|
61
|
+
Binding("q", "maybe_close", show=False),
|
|
62
|
+
Binding("ctrl+c", "maybe_close", show=False),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def __init__(self, filter_provider: FilterProvider) -> None:
|
|
66
|
+
super().__init__()
|
|
67
|
+
self._fp = filter_provider
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
def compose(self) -> ComposeResult:
|
|
71
|
+
yield Vertical(
|
|
72
|
+
Vertical(
|
|
73
|
+
FilterTree(self._fp, id="filter-tree"),
|
|
74
|
+
Static(id="filter-expression"),
|
|
75
|
+
id="filter-wrapper",
|
|
76
|
+
),
|
|
77
|
+
WrappingFooter(columns=4),
|
|
78
|
+
id="filter-modal",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def on_mount(self) -> None:
|
|
82
|
+
self.query_one("#filter-modal").border_title = "Filters"
|
|
83
|
+
self.query_one("#filter-tree", FilterTree).focus()
|
|
84
|
+
self._update_preview()
|
|
85
|
+
|
|
86
|
+
def on_filter_tree_changed(self) -> None:
|
|
87
|
+
self._update_preview()
|
|
88
|
+
|
|
89
|
+
def _update_preview(self) -> None:
|
|
90
|
+
expr_widget = self.query_one("#filter-expression", Static)
|
|
91
|
+
expr = build_expression(self._fp.root)
|
|
92
|
+
if expr:
|
|
93
|
+
expr_widget.update(f"{expr}")
|
|
94
|
+
expr_widget.remove_class("empty")
|
|
95
|
+
else:
|
|
96
|
+
expr_widget.update("(no active filters)")
|
|
97
|
+
expr_widget.add_class("empty")
|
|
98
|
+
|
|
99
|
+
def action_maybe_close(self) -> None:
|
|
100
|
+
self.dismiss(True)
|
jnav/filter_provider.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from typing import Any, Literal
|
|
2
|
+
|
|
3
|
+
from aioreactive import AsyncSubject
|
|
4
|
+
|
|
5
|
+
from jnav.filtering import Filter, FilterGroup, FilterNode, build_expression
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FilterProvider:
|
|
9
|
+
on_change: AsyncSubject[None]
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._root = FilterGroup()
|
|
13
|
+
self.on_change = AsyncSubject[None]()
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def root(self) -> FilterGroup:
|
|
17
|
+
return self._root
|
|
18
|
+
|
|
19
|
+
async def add_filter(
|
|
20
|
+
self,
|
|
21
|
+
expr: str,
|
|
22
|
+
label: str | None = None,
|
|
23
|
+
combine: Literal["and", "or"] = "and",
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Add a new filter leaf to the root group."""
|
|
26
|
+
existing = {
|
|
27
|
+
leaf.expr for leaf in self._root.children if isinstance(leaf, Filter)
|
|
28
|
+
}
|
|
29
|
+
if expr not in existing:
|
|
30
|
+
leaf = Filter(expr=expr, label=label)
|
|
31
|
+
if combine == "or":
|
|
32
|
+
self._root.children.append(
|
|
33
|
+
FilterGroup(
|
|
34
|
+
operator="or",
|
|
35
|
+
children=[leaf],
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
self._root.children.append(leaf)
|
|
40
|
+
await self.on_change.asend(None)
|
|
41
|
+
|
|
42
|
+
async def toggle_node(self, node: FilterNode) -> None:
|
|
43
|
+
node.enabled = not node.enabled
|
|
44
|
+
await self.on_change.asend(None)
|
|
45
|
+
|
|
46
|
+
async def toggle_negated(self, node: FilterNode) -> None:
|
|
47
|
+
node.negated = not node.negated
|
|
48
|
+
await self.on_change.asend(None)
|
|
49
|
+
|
|
50
|
+
async def add_group(
|
|
51
|
+
self,
|
|
52
|
+
parent: FilterGroup,
|
|
53
|
+
index: int | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
op = "or" if parent.operator == "and" else "and"
|
|
56
|
+
group = FilterGroup(operator=op)
|
|
57
|
+
if index is not None:
|
|
58
|
+
parent.children.insert(index + 1, group)
|
|
59
|
+
else:
|
|
60
|
+
parent.children.append(group)
|
|
61
|
+
await self.on_change.asend(None)
|
|
62
|
+
|
|
63
|
+
async def remove_node(self, node: FilterNode, parent: FilterGroup) -> None:
|
|
64
|
+
parent.children.remove(node)
|
|
65
|
+
await self.on_change.asend(None)
|
|
66
|
+
|
|
67
|
+
async def set_node_operator(
|
|
68
|
+
self,
|
|
69
|
+
node: FilterNode,
|
|
70
|
+
parent: FilterGroup,
|
|
71
|
+
) -> None:
|
|
72
|
+
if isinstance(node, FilterGroup):
|
|
73
|
+
node.operator = "or" if node.operator == "and" else "and"
|
|
74
|
+
else:
|
|
75
|
+
idx = parent.children.index(node)
|
|
76
|
+
parent.children[idx] = FilterGroup(operator="or", children=[node])
|
|
77
|
+
await self.on_change.asend(None)
|
|
78
|
+
|
|
79
|
+
async def flatten_group(self, group: FilterGroup, parent: FilterGroup) -> None:
|
|
80
|
+
"""Replace a group with a single leaf containing its compiled expression."""
|
|
81
|
+
expr = build_expression(
|
|
82
|
+
FilterGroup(operator=group.operator, children=group.children)
|
|
83
|
+
)
|
|
84
|
+
if expr is None:
|
|
85
|
+
return
|
|
86
|
+
idx = parent.children.index(group)
|
|
87
|
+
parent.children[idx] = Filter(expr=expr)
|
|
88
|
+
await self.on_change.asend(None)
|
|
89
|
+
|
|
90
|
+
async def edit_leaf(self, leaf: Filter, expr: str) -> None:
|
|
91
|
+
leaf.expr = expr
|
|
92
|
+
await self.on_change.asend(None)
|
|
93
|
+
|
|
94
|
+
async def clear_filters(self) -> None:
|
|
95
|
+
self._root.children.clear()
|
|
96
|
+
await self.on_change.asend(None)
|
|
97
|
+
|
|
98
|
+
def dump_root(self) -> dict[str, Any]:
|
|
99
|
+
return self._root.model_dump()
|
|
100
|
+
|
|
101
|
+
async def load_root(self, data: dict[str, Any]) -> None:
|
|
102
|
+
self._root = FilterGroup.model_validate(data)
|
|
103
|
+
await self.on_change.asend(None)
|