twd-m4sc0 3.0.3__py3-none-any.whl → 3.1.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.
- twd/cli.py +62 -2
- twd/data.py +2 -9
- twd/modals.py +92 -0
- twd/tui.py +55 -9
- twd/utils.py +12 -11
- {twd_m4sc0-3.0.3.dist-info → twd_m4sc0-3.1.0.dist-info}/METADATA +1 -1
- twd_m4sc0-3.1.0.dist-info/RECORD +13 -0
- {twd_m4sc0-3.0.3.dist-info → twd_m4sc0-3.1.0.dist-info}/WHEEL +1 -1
- twd_m4sc0-3.0.3.dist-info/RECORD +0 -12
- {twd_m4sc0-3.0.3.dist-info → twd_m4sc0-3.1.0.dist-info}/entry_points.txt +0 -0
- {twd_m4sc0-3.0.3.dist-info → twd_m4sc0-3.1.0.dist-info}/top_level.txt +0 -0
twd/cli.py
CHANGED
|
@@ -24,10 +24,13 @@ def cli(ctx):
|
|
|
24
24
|
|
|
25
25
|
path = TWDApp(manager=ctx.obj['manager']).run()
|
|
26
26
|
|
|
27
|
+
if not path:
|
|
28
|
+
print("Exiting...")
|
|
29
|
+
exit(0)
|
|
30
|
+
|
|
27
31
|
# write to fd3
|
|
28
32
|
os.write(3, bytes(str(path), "utf-8"))
|
|
29
|
-
|
|
30
|
-
pass
|
|
33
|
+
exit(0)
|
|
31
34
|
|
|
32
35
|
@cli.command()
|
|
33
36
|
@click.argument('path')
|
|
@@ -100,5 +103,62 @@ def list_twds(ctx):
|
|
|
100
103
|
click.echo("No TWDs saved yet. Use 'twd save <path> <alias>' to add one.")
|
|
101
104
|
return
|
|
102
105
|
|
|
106
|
+
invalid_found = False
|
|
103
107
|
for entry in entries:
|
|
108
|
+
if not invalid_found and not os.path.exists(entry.path):
|
|
109
|
+
invalid_found = True
|
|
104
110
|
click.echo(f"{entry.alias:20} {entry.name:30} {entry.path}")
|
|
111
|
+
|
|
112
|
+
if invalid_found:
|
|
113
|
+
click.echo(f"\nInvalid TWD paths found. Run <twd clean> to remove them.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cli.command('clean')
|
|
117
|
+
@click.option('--yes', '-y', is_flag=True, help="Remove all invalid entries without asking")
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def clean(ctx, yes):
|
|
120
|
+
"""
|
|
121
|
+
clean twds from invalid paths
|
|
122
|
+
"""
|
|
123
|
+
manager = ctx.obj['manager']
|
|
124
|
+
|
|
125
|
+
# all entries, valid and invalid
|
|
126
|
+
entries = manager.list_all()
|
|
127
|
+
|
|
128
|
+
# only invalids
|
|
129
|
+
invalids = []
|
|
130
|
+
for entry in entries:
|
|
131
|
+
if os.path.exists(entry.path):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
invalids.append(entry)
|
|
135
|
+
|
|
136
|
+
if len(invalids) == 0:
|
|
137
|
+
click.echo("No invalid TWDs found.")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
click.echo(f"Found {len(invalids)} invalid TWDs\n")
|
|
141
|
+
|
|
142
|
+
# only valid
|
|
143
|
+
valid_entries = []
|
|
144
|
+
for entry in entries:
|
|
145
|
+
if entry not in invalids:
|
|
146
|
+
valid_entries.append(entry)
|
|
147
|
+
|
|
148
|
+
# remove all
|
|
149
|
+
if yes:
|
|
150
|
+
click.echo("Removing all invalid entries...")
|
|
151
|
+
manager._write_all(valid_entries)
|
|
152
|
+
click.echo(f"Done. {len(valid_entries)} TWDs left.")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
to_keep = []
|
|
156
|
+
for inv in invalids:
|
|
157
|
+
if not click.confirm(f"Do you want to remove '{inv.alias}'?", default=True):
|
|
158
|
+
# user wants to keep invalid TWD (weird but i'll allow it)
|
|
159
|
+
to_keep.append(inv)
|
|
160
|
+
|
|
161
|
+
# write a unison list of valid entries and the ones to keep
|
|
162
|
+
final_entries = valid_entries + to_keep
|
|
163
|
+
manager._write_all(final_entries)
|
|
164
|
+
click.echo(f"Done. {len(final_entries)} TWDs left.")
|
twd/data.py
CHANGED
|
@@ -6,7 +6,6 @@ from typing import List, Optional
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
|
|
8
8
|
from .config import Config
|
|
9
|
-
from .utils import search
|
|
10
9
|
|
|
11
10
|
class Entry(BaseModel):
|
|
12
11
|
"""Data class for a signle TWD Entry"""
|
|
@@ -27,10 +26,6 @@ class Entry(BaseModel):
|
|
|
27
26
|
def validate_path(cls, v):
|
|
28
27
|
path = Path(v).expanduser().resolve()
|
|
29
28
|
|
|
30
|
-
if not path.exists():
|
|
31
|
-
raise ValueError(f"Path does not exist: {path}")
|
|
32
|
-
if not path.is_dir():
|
|
33
|
-
raise ValueError(f"Path is not a directory: {path}")
|
|
34
29
|
return path
|
|
35
30
|
|
|
36
31
|
def to_csv(self) -> List[str]:
|
|
@@ -56,6 +51,7 @@ class TwdManager:
|
|
|
56
51
|
"""twd entry manager stored in csv"""
|
|
57
52
|
|
|
58
53
|
CSV_HEADERS = ["alias", "path", "name", "created_at"]
|
|
54
|
+
CSV_HEADERS_FANCY = ["Alias", "Path", "Description", "Created at"]
|
|
59
55
|
|
|
60
56
|
def __init__(self, csv_path: Path):
|
|
61
57
|
self.csv_path = csv_path
|
|
@@ -81,10 +77,7 @@ class TwdManager:
|
|
|
81
77
|
next(reader) # skip headers
|
|
82
78
|
|
|
83
79
|
for row in reader:
|
|
84
|
-
|
|
85
|
-
entries.append(Entry.from_csv(row))
|
|
86
|
-
except Exception as e:
|
|
87
|
-
print(f"Warning: skipping invalid row: {e}")
|
|
80
|
+
entries.append(Entry.from_csv(row))
|
|
88
81
|
|
|
89
82
|
return entries
|
|
90
83
|
|
twd/modals.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from textual import on
|
|
2
|
+
from textual.app import ComposeResult
|
|
3
|
+
from textual.screen import ModalScreen
|
|
4
|
+
from textual.containers import Container, Horizontal
|
|
5
|
+
from textual.widgets import Button, Label
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
from twd.data import Entry
|
|
9
|
+
|
|
10
|
+
class ConfirmModal(ModalScreen[bool]):
|
|
11
|
+
"""A confirm modal"""
|
|
12
|
+
|
|
13
|
+
DEFAULT_CSS = """
|
|
14
|
+
ConfirmModal {
|
|
15
|
+
align: center middle;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ConfirmModal > Container {
|
|
19
|
+
width: auto;
|
|
20
|
+
height: auto;
|
|
21
|
+
border: thick $background 80%;
|
|
22
|
+
background: $surface;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ConfirmModal > Container > Label {
|
|
26
|
+
width: 100%;
|
|
27
|
+
content-align-horizontal: center;
|
|
28
|
+
margin-top: 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ConfirmModal > Container > Horizontal {
|
|
32
|
+
width: auto;
|
|
33
|
+
height: auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ConfirmModal > Container > Horizontal > Button {
|
|
37
|
+
margin: 2 4;
|
|
38
|
+
}
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
message: Union[str, None] = None,
|
|
44
|
+
confirm_text: str = "Yes",
|
|
45
|
+
cancel_text: str = "No"
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
message: The message to display when popping the modal
|
|
49
|
+
confirm_text: Text to show for confirmation
|
|
50
|
+
cancel_text: Text to show for cancellation
|
|
51
|
+
"""
|
|
52
|
+
self.message = message
|
|
53
|
+
self.confirm_text = confirm_text
|
|
54
|
+
self.cancel_text = cancel_text
|
|
55
|
+
|
|
56
|
+
super().__init__()
|
|
57
|
+
|
|
58
|
+
def compose_content(self) -> ComposeResult:
|
|
59
|
+
"""
|
|
60
|
+
Abstract method for presenting custom shenanigans
|
|
61
|
+
"""
|
|
62
|
+
if self.message:
|
|
63
|
+
yield Label(self.message, id="content")
|
|
64
|
+
else:
|
|
65
|
+
yield Label("Are you sure?", id="content")
|
|
66
|
+
|
|
67
|
+
def compose(self) -> ComposeResult:
|
|
68
|
+
with Container():
|
|
69
|
+
yield from self.compose_content()
|
|
70
|
+
with Horizontal():
|
|
71
|
+
yield Button(self.cancel_text, id="no", variant="error")
|
|
72
|
+
yield Button(self.confirm_text, id="yes", variant="success")
|
|
73
|
+
|
|
74
|
+
@on(Button.Pressed, "#no")
|
|
75
|
+
def no_decision(self) -> None:
|
|
76
|
+
"""decision no"""
|
|
77
|
+
self.dismiss(False)
|
|
78
|
+
|
|
79
|
+
@on(Button.Pressed, "#yes")
|
|
80
|
+
def yes_decision(self) -> None:
|
|
81
|
+
"""decision yes"""
|
|
82
|
+
self.dismiss(True)
|
|
83
|
+
|
|
84
|
+
class EntryDeleteModal(ConfirmModal):
|
|
85
|
+
"""Confirmation modal with detailed entry information"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, entry):
|
|
88
|
+
self.entry = entry
|
|
89
|
+
super().__init__(confirm_text="Delete", cancel_text="Cancel")
|
|
90
|
+
|
|
91
|
+
def compose_content(self) -> ComposeResult:
|
|
92
|
+
yield Label(f"Delete entry '{self.entry.name}'?", id="content")
|
twd/tui.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
from textual import on
|
|
3
4
|
from textual.app import App, ComposeResult, Binding
|
|
4
5
|
from textual.containers import HorizontalGroup, VerticalScroll
|
|
@@ -8,7 +9,8 @@ from textual.color import Color
|
|
|
8
9
|
|
|
9
10
|
from twd.config import Config
|
|
10
11
|
from twd.data import TwdManager
|
|
11
|
-
from twd.utils import
|
|
12
|
+
from twd.utils import fuzzy_search, linear_search
|
|
13
|
+
from twd.modals import ConfirmModal, EntryDeleteModal
|
|
12
14
|
|
|
13
15
|
class Mode(Enum):
|
|
14
16
|
NORMAL = "normal"
|
|
@@ -28,6 +30,7 @@ class TWDApp(App):
|
|
|
28
30
|
|
|
29
31
|
# modify
|
|
30
32
|
Binding("/", "enter_search_mode", "Search"),
|
|
33
|
+
Binding("d", "delete_entry", "Delete"),
|
|
31
34
|
Binding("escape", "enter_normal_mode", "Normal", show=False),
|
|
32
35
|
# TODO: edit
|
|
33
36
|
# TODO: rename
|
|
@@ -77,7 +80,7 @@ class TWDApp(App):
|
|
|
77
80
|
|
|
78
81
|
# add headers
|
|
79
82
|
table = self.query_one(DataTable)
|
|
80
|
-
table.add_columns(*self.manager.
|
|
83
|
+
table.add_columns(*self.manager.CSV_HEADERS_FANCY)
|
|
81
84
|
|
|
82
85
|
self._populate_table()
|
|
83
86
|
|
|
@@ -93,7 +96,7 @@ class TWDApp(App):
|
|
|
93
96
|
|
|
94
97
|
# fill data
|
|
95
98
|
for entry in entries:
|
|
96
|
-
table.add_row(entry.alias, str(entry.path), entry.name, entry.created_at)
|
|
99
|
+
table.add_row(entry.alias, str(entry.path), entry.name, entry.created_at.strftime("%Y-%m-%d %H:%M:%S"))
|
|
97
100
|
|
|
98
101
|
def watch_mode(self, old_mode: Mode, new_mode: Mode) -> None:
|
|
99
102
|
"""
|
|
@@ -128,8 +131,11 @@ class TWDApp(App):
|
|
|
128
131
|
"""
|
|
129
132
|
table = self.query_one(DataTable)
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
try:
|
|
135
|
+
current_row = table.cursor_coordinate.row
|
|
136
|
+
next_row = (current_row + 1) % table.row_count
|
|
137
|
+
except ZeroDivisionError as e:
|
|
138
|
+
return
|
|
133
139
|
|
|
134
140
|
table.move_cursor(row=next_row)
|
|
135
141
|
|
|
@@ -139,8 +145,11 @@ class TWDApp(App):
|
|
|
139
145
|
"""
|
|
140
146
|
table = self.query_one(DataTable)
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
try:
|
|
149
|
+
current_row = table.cursor_coordinate.row
|
|
150
|
+
prev_row = (current_row - 1) % table.row_count
|
|
151
|
+
except ZeroDivisionError as e:
|
|
152
|
+
return
|
|
144
153
|
|
|
145
154
|
table.move_cursor(row=prev_row)
|
|
146
155
|
|
|
@@ -160,6 +169,39 @@ class TWDApp(App):
|
|
|
160
169
|
return
|
|
161
170
|
self.mode = Mode.NORMAL
|
|
162
171
|
|
|
172
|
+
def action_delete_entry(self) -> None:
|
|
173
|
+
"""
|
|
174
|
+
open confirm modal and delete entry if yes
|
|
175
|
+
"""
|
|
176
|
+
if not self.mode == Mode.NORMAL:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
table = self.query_one(DataTable)
|
|
180
|
+
|
|
181
|
+
# get row
|
|
182
|
+
row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
|
|
183
|
+
row_data = table.get_row(row_key)
|
|
184
|
+
alias = row_data[0]
|
|
185
|
+
|
|
186
|
+
# get entry
|
|
187
|
+
entry = self.manager.get(alias)
|
|
188
|
+
|
|
189
|
+
def check_delete(decision: bool | None) -> None:
|
|
190
|
+
"""
|
|
191
|
+
open modal and return the decision
|
|
192
|
+
"""
|
|
193
|
+
if not decision:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
self.manager.remove(alias)
|
|
197
|
+
|
|
198
|
+
self._populate_table()
|
|
199
|
+
|
|
200
|
+
self.notify(f"Removed entry \"{entry.name}\"")
|
|
201
|
+
|
|
202
|
+
# self.push_screen(ConfirmModal(message=f"Are you sure want to delete '{entry.alias}'?"), check_delete)
|
|
203
|
+
self.push_screen(EntryDeleteModal(entry), check_delete)
|
|
204
|
+
|
|
163
205
|
def action_exit(self) -> None:
|
|
164
206
|
self.exit()
|
|
165
207
|
|
|
@@ -177,9 +219,13 @@ class TWDApp(App):
|
|
|
177
219
|
|
|
178
220
|
# TODO: filter entries and repopulate table
|
|
179
221
|
|
|
180
|
-
|
|
222
|
+
if query is None:
|
|
223
|
+
self._populate_table()
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
search_result = fuzzy_search(query, all_entries)
|
|
181
227
|
|
|
182
|
-
filtered = [
|
|
228
|
+
filtered = [item[0] for item in search_result]
|
|
183
229
|
|
|
184
230
|
self._populate_table(filtered)
|
|
185
231
|
|
twd/utils.py
CHANGED
|
@@ -1,39 +1,40 @@
|
|
|
1
1
|
from rapidfuzz import fuzz
|
|
2
2
|
from typing import List
|
|
3
3
|
|
|
4
|
-
FUZZY_THRESHOLD = 50
|
|
5
|
-
|
|
6
4
|
def normalize(name) -> str:
|
|
7
5
|
return name.lower().replace('-', ' ').replace('_', ' ')
|
|
8
6
|
|
|
9
|
-
def
|
|
7
|
+
def fuzzy_search(query, items, threshold = 50) -> List:
|
|
10
8
|
"""
|
|
11
9
|
query: search input
|
|
12
10
|
items: list of (alias, name)
|
|
11
|
+
threshold: threshold score over which the entries are selected
|
|
13
12
|
|
|
14
|
-
returns: list of (
|
|
13
|
+
returns: list of (entry, score)
|
|
15
14
|
"""
|
|
16
15
|
|
|
17
16
|
# return all items if query is empty
|
|
18
17
|
if not query:
|
|
19
|
-
return [(
|
|
18
|
+
return [(e, 100) for e in items]
|
|
20
19
|
|
|
21
20
|
normalized_query = normalize(query)
|
|
22
21
|
results = []
|
|
23
22
|
|
|
24
23
|
# filtering
|
|
25
|
-
for
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
for entry in items:
|
|
25
|
+
alias, name = normalize(entry.alias), normalize(entry.name)
|
|
26
|
+
|
|
27
|
+
alias_score = fuzz.ratio(normalized_query, alias)
|
|
28
|
+
name_score = fuzz.ratio(normalized_query, name)
|
|
28
29
|
|
|
29
30
|
# choose higher score
|
|
30
31
|
best_score = max(alias_score, name_score)
|
|
31
32
|
|
|
32
33
|
# filter out low scores
|
|
33
|
-
if best_score
|
|
34
|
-
results.append((
|
|
34
|
+
if best_score > threshold:
|
|
35
|
+
results.append((entry, best_score))
|
|
35
36
|
|
|
36
|
-
results.sort(key=lambda x: x[
|
|
37
|
+
results.sort(key=lambda x: x[1], reverse=True)
|
|
37
38
|
|
|
38
39
|
return results
|
|
39
40
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
twd/__init__.py,sha256=76brvw44gXGskDSsajfwk5rvxoRjk4PT99d4qGUs_64,162
|
|
2
|
+
twd/cli.py,sha256=m1Buh3_pDg4Yej-3v2cRlB2m-1jz55cLnPx63TJgb4s,4247
|
|
3
|
+
twd/config.py,sha256=pGAaurguz1Bv6NxjwKbLBbvJ9tuAVJx_l7tcvd0B84I,1996
|
|
4
|
+
twd/data.py,sha256=8GmBk5VOfmvJJa16H2jywi06_0wRNYjjkseqgM17Hdk,3861
|
|
5
|
+
twd/modals.py,sha256=NWeOu87DlVxYERUqKfulxRXlb1Bs1BYxs_Q8tih-WZM,2601
|
|
6
|
+
twd/tui.py,sha256=XksMYTDgOL5sZg4Cn0LjnNVYvOkgKHsUlve1KZZNmyo,7403
|
|
7
|
+
twd/tui.tcss,sha256=GVEtk-fUcAaaGP3I-VjTFUzZocPAEcASDCBNGVoUBWw,778
|
|
8
|
+
twd/utils.py,sha256=xsMa69vAotpNMd3Vp5_F7ELwZCwT-sd45taN7PRI9pE,1201
|
|
9
|
+
twd_m4sc0-3.1.0.dist-info/METADATA,sha256=ZzuIkcbhHTraVKyx3u_aNymjTYxQv8Fl577mjuiozpE,437
|
|
10
|
+
twd_m4sc0-3.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
11
|
+
twd_m4sc0-3.1.0.dist-info/entry_points.txt,sha256=KeIDpaQx0GSqgJFYql14wmT3yj_JHTwq7rRsm-mKckc,36
|
|
12
|
+
twd_m4sc0-3.1.0.dist-info/top_level.txt,sha256=B93Li7fIZ4uWoKq8rKVuGPU_MtzyjxxDb0NF96oGZ0o,4
|
|
13
|
+
twd_m4sc0-3.1.0.dist-info/RECORD,,
|
twd_m4sc0-3.0.3.dist-info/RECORD
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
twd/__init__.py,sha256=76brvw44gXGskDSsajfwk5rvxoRjk4PT99d4qGUs_64,162
|
|
2
|
-
twd/cli.py,sha256=Shm8ZeHrPKPr-lbo6Mvb_mjWEH8mYBVlpvZ3OcWb2sw,2550
|
|
3
|
-
twd/config.py,sha256=pGAaurguz1Bv6NxjwKbLBbvJ9tuAVJx_l7tcvd0B84I,1996
|
|
4
|
-
twd/data.py,sha256=YPVU2NhZuDOps-pkquRG9y4GW5RHf46eXIm23rgY_Lo,4131
|
|
5
|
-
twd/tui.py,sha256=2SGGQ5EhjZSun6NrOiU9aC6AQWfjWEZzFZjKlcIQ9vU,6062
|
|
6
|
-
twd/tui.tcss,sha256=GVEtk-fUcAaaGP3I-VjTFUzZocPAEcASDCBNGVoUBWw,778
|
|
7
|
-
twd/utils.py,sha256=F42JxIlqUMLsklQ7_zXoIOPmh872E2pHdu1jGLt6-vw,1144
|
|
8
|
-
twd_m4sc0-3.0.3.dist-info/METADATA,sha256=IB8U5YkNDs3MldgdSy6ffadSt84fFpgT15yz3QIjktg,437
|
|
9
|
-
twd_m4sc0-3.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
-
twd_m4sc0-3.0.3.dist-info/entry_points.txt,sha256=KeIDpaQx0GSqgJFYql14wmT3yj_JHTwq7rRsm-mKckc,36
|
|
11
|
-
twd_m4sc0-3.0.3.dist-info/top_level.txt,sha256=B93Li7fIZ4uWoKq8rKVuGPU_MtzyjxxDb0NF96oGZ0o,4
|
|
12
|
-
twd_m4sc0-3.0.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|