|
| 1 | +import argparse |
| 2 | + |
| 3 | +from db_connection import (connect, getKey, getMultipleKeys, listKeys, |
| 4 | + listVersions) |
| 5 | +from rich.text import Text |
| 6 | +from textual import on |
| 7 | +from textual.app import App, ComposeResult |
| 8 | +from textual.binding import Binding |
| 9 | +from textual.containers import Horizontal, Vertical |
| 10 | +from textual.reactive import reactive |
| 11 | +from textual.widget import Widget |
| 12 | +from textual.widgets import (Button, DataTable, Footer, Header, Input, Label, |
| 13 | + RichLog) |
| 14 | + |
| 15 | + |
| 16 | +class ListKeysWidget(Widget): |
| 17 | + def __init__(self, stub, **kwargs): |
| 18 | + super().__init__(**kwargs) |
| 19 | + self.stub = stub |
| 20 | + |
| 21 | + def compose(self) -> ComposeResult: |
| 22 | + with Horizontal(id="horizontal-list"): |
| 23 | + yield DataTable(id="list-keys-table") |
| 24 | + info_widget = KeyInfoWidget(id="key-info") |
| 25 | + info_widget.key = "No key selected" |
| 26 | + info_widget.stub = self.stub |
| 27 | + yield info_widget |
| 28 | + |
| 29 | + @on(DataTable.CellHighlighted) |
| 30 | + def on_data_table_row_highlighted(self, event): |
| 31 | + self.query_one(KeyInfoWidget).update_key(event.value) |
| 32 | + self.refresh() |
| 33 | + |
| 34 | + |
| 35 | +class FileNameHint(Widget): |
| 36 | + filename = reactive("none") |
| 37 | + |
| 38 | + def render(self) -> str: |
| 39 | + return f"Press the button above to download the value for the selected key to {self.filename}" |
| 40 | + |
| 41 | + |
| 42 | +class KeyInfoWidget(Widget): |
| 43 | + key = "" |
| 44 | + key_save_filename = "" |
| 45 | + versions = [] |
| 46 | + |
| 47 | + def sanitize_filename(self, name): |
| 48 | + import re |
| 49 | + |
| 50 | + s = str(name).strip().replace(" ", "_") |
| 51 | + return re.sub(r"(?u)[^-\w.]", "_", s) |
| 52 | + |
| 53 | + def update_key(self, key): |
| 54 | + self.key = key |
| 55 | + |
| 56 | + self.query_one("#write-button").styles.visibility = "visible" |
| 57 | + self.query_one("#write-label").styles.visibility = "visible" |
| 58 | + |
| 59 | + log = self.query_one("#key-info-label") |
| 60 | + |
| 61 | + log.clear() |
| 62 | + log.write(Text("Key:", style="bold magenta")) |
| 63 | + log.write(key) |
| 64 | + |
| 65 | + if key == "More keys on the next page...": |
| 66 | + return |
| 67 | + |
| 68 | + try: |
| 69 | + self.versions = listVersions(stub, self.collection, key) |
| 70 | + log.write(Text("Versions:", style="bold magenta")) |
| 71 | + log.write(",".join(map(str, self.versions))) |
| 72 | + self.key_save_filename = self.sanitize_filename( |
| 73 | + f"{self.collection}_{key}_{self.versions[-1]}" |
| 74 | + ) |
| 75 | + self.query_one("#write-label").filename = self.key_save_filename |
| 76 | + except Exception as e: |
| 77 | + log.write("Could not load versions: " + str(e)) |
| 78 | + |
| 79 | + def write_key(self): |
| 80 | + try: |
| 81 | + value = getKey(stub, self.collection, self.key, self.versions[-1]) |
| 82 | + with open(self.key_save_filename, "wb") as f: |
| 83 | + f.write(value) |
| 84 | + self.query_one("#key-info-label").write( |
| 85 | + f"Wrote data to {self.key_save_filename}" |
| 86 | + ) |
| 87 | + except Exception as e: |
| 88 | + self.query_one("#key-info-label").write("Could not write key: " + str(e)) |
| 89 | + |
| 90 | + def on_button_pressed(self, event): |
| 91 | + if event.button.id == "write-button": |
| 92 | + self.write_key() |
| 93 | + |
| 94 | + def compose(self) -> ComposeResult: |
| 95 | + with Vertical(): |
| 96 | + yield RichLog(id="key-info-label", wrap=True) |
| 97 | + |
| 98 | + writeButton = Button(label="Save latest version in file", id="write-button") |
| 99 | + writeButton.styles.visibility = "hidden" |
| 100 | + writeLabel = FileNameHint(id="write-label") |
| 101 | + writeLabel.styles.visibility = "hidden" |
| 102 | + yield writeButton |
| 103 | + yield writeLabel |
| 104 | + |
| 105 | + |
| 106 | +class FossilDBClient(App): |
| 107 | + |
| 108 | + """A Textual app to manage FossilDB databases.""" |
| 109 | + |
| 110 | + BINDINGS = [ |
| 111 | + ("d", "toggle_dark", "Toggle dark mode"), |
| 112 | + ("q", "quit", "Quit the client"), |
| 113 | + ("r", "refresh", "Refresh the data"), |
| 114 | + Binding( |
| 115 | + "pagedown", |
| 116 | + "show_next", |
| 117 | + f"Show next page of keys", |
| 118 | + priority=True, |
| 119 | + show=True, |
| 120 | + ), |
| 121 | + Binding( |
| 122 | + "j", |
| 123 | + "show_next", |
| 124 | + f"Show next page of keys", |
| 125 | + show=True, |
| 126 | + ), |
| 127 | + Binding( |
| 128 | + "pageup", |
| 129 | + "show_prev", |
| 130 | + f"Show previous page of keys", |
| 131 | + priority=True, |
| 132 | + show=True, |
| 133 | + ), |
| 134 | + Binding( |
| 135 | + "k", |
| 136 | + "show_prev", |
| 137 | + f"Show previous page of keys", |
| 138 | + show=True, |
| 139 | + ), |
| 140 | + Binding("down", "next_key", "Select the next key", priority=True, show=False), |
| 141 | + Binding("up", "prev_key", "Select the previous key", priority=True, show=False), |
| 142 | + ] |
| 143 | + |
| 144 | + after_key = "" |
| 145 | + prefix = "" |
| 146 | + collection = "volumeData" |
| 147 | + CSS_PATH = "client.tcss" |
| 148 | + |
| 149 | + last_keys = [""] |
| 150 | + |
| 151 | + def __init__(self, stub, collection, count): |
| 152 | + super().__init__() |
| 153 | + self.stub = stub |
| 154 | + self.collection = collection |
| 155 | + self.key_list_limit = int(count) |
| 156 | + |
| 157 | + def compose(self) -> ComposeResult: |
| 158 | + """Create child widgets for the app.""" |
| 159 | + yield Header() |
| 160 | + yield Input( |
| 161 | + placeholder="Select collection:", id="collection", value=self.collection |
| 162 | + ) |
| 163 | + yield Input( |
| 164 | + placeholder="Find keys with prefix: (leave empty to list all keys)", |
| 165 | + id="prefix", |
| 166 | + ) |
| 167 | + yield ListKeysWidget(id="list-keys", stub=self.stub) |
| 168 | + |
| 169 | + yield Footer() |
| 170 | + |
| 171 | + @on(Input.Submitted) |
| 172 | + def on_input_submitted(self, event: Input.Submitted) -> None: |
| 173 | + if event.input.id == "collection": |
| 174 | + self.collection = event.input.value |
| 175 | + if event.input.id == "prefix": |
| 176 | + self.prefix = event.input.value |
| 177 | + self.refresh_data() |
| 178 | + |
| 179 | + def refresh_data(self) -> None: |
| 180 | + """Refresh the data in the table.""" |
| 181 | + table = self.query_one(DataTable) |
| 182 | + self.query_one(KeyInfoWidget).collection = self.collection |
| 183 | + table.clear(columns=True) |
| 184 | + table.add_column("key") |
| 185 | + try: |
| 186 | + if self.prefix != "": |
| 187 | + keys = getMultipleKeys( |
| 188 | + self.stub, |
| 189 | + self.collection, |
| 190 | + self.prefix, |
| 191 | + self.after_key, |
| 192 | + self.key_list_limit + 1, # +1 to check if there are more keys |
| 193 | + ) |
| 194 | + else: |
| 195 | + keys = listKeys( |
| 196 | + self.stub, self.collection, self.after_key, self.key_list_limit + 1 |
| 197 | + ) |
| 198 | + overlength = False |
| 199 | + if len(keys) > self.key_list_limit: |
| 200 | + keys = keys[:-1] |
| 201 | + overlength = True |
| 202 | + |
| 203 | + for i, key in enumerate(keys): |
| 204 | + label = Text(str(i), style="#B0FC38 italic") |
| 205 | + table.add_row(key, label=label) |
| 206 | + if overlength: |
| 207 | + table.add_row( |
| 208 | + "More keys on the next page...", |
| 209 | + label=Text("...", style="#B0FC38 italic"), |
| 210 | + ) |
| 211 | + self.last_keys.append(keys[-1]) |
| 212 | + table.focus() |
| 213 | + except Exception as e: |
| 214 | + table.add_row("Could not load keys: " + str(e)) |
| 215 | + |
| 216 | + def action_toggle_dark(self) -> None: |
| 217 | + """An action to toggle dark mode.""" |
| 218 | + self.dark = not self.dark |
| 219 | + |
| 220 | + def action_quit(self) -> None: |
| 221 | + """An action to quit the app.""" |
| 222 | + self.exit() |
| 223 | + |
| 224 | + def action_refresh(self) -> None: |
| 225 | + """An action to refresh the data.""" |
| 226 | + self.refresh_data() |
| 227 | + |
| 228 | + def action_show_next(self) -> None: |
| 229 | + """An action to show the next key_list_limit keys.""" |
| 230 | + self.after_key = self.last_keys[-1] |
| 231 | + self.refresh_data() |
| 232 | + |
| 233 | + def action_show_prev(self) -> None: |
| 234 | + """An action to show the previous key_list_limit keys.""" |
| 235 | + if len(self.last_keys) > 2: |
| 236 | + self.last_keys.pop() |
| 237 | + self.last_keys.pop() |
| 238 | + self.after_key = self.last_keys[-1] |
| 239 | + self.refresh_data() |
| 240 | + |
| 241 | + def action_next_key(self) -> None: |
| 242 | + """An action to select the next key.""" |
| 243 | + table = self.query_one(DataTable) |
| 244 | + current_row = table.cursor_coordinate.row |
| 245 | + if current_row < self.key_list_limit - 1: |
| 246 | + table.cursor_coordinate = (current_row + 1, table.cursor_coordinate.column) |
| 247 | + else: |
| 248 | + self.action_show_next() |
| 249 | + |
| 250 | + def action_prev_key(self) -> None: |
| 251 | + """An action to select the previous key.""" |
| 252 | + table = self.query_one(DataTable) |
| 253 | + current_row = table.cursor_coordinate.row |
| 254 | + if current_row > 0: |
| 255 | + table.cursor_coordinate = (current_row - 1, table.cursor_coordinate.column) |
| 256 | + else: |
| 257 | + if self.after_key != "": |
| 258 | + self.action_show_prev() |
| 259 | + table.cursor_coordinate = ( |
| 260 | + len(table.rows) - 2, # -1 for last row, -1 for the More keys row |
| 261 | + table.cursor_coordinate.column, |
| 262 | + ) |
| 263 | + |
| 264 | + |
| 265 | +def init_argument_parser(): |
| 266 | + parser = argparse.ArgumentParser() |
| 267 | + parser.add_argument( |
| 268 | + "host", |
| 269 | + help="fossildb host and ip, e.g. localhost:7155", |
| 270 | + default="localhost:7155", |
| 271 | + nargs="?", |
| 272 | + ) |
| 273 | + parser.add_argument("-c", "--collection", help="collection to use", default="") |
| 274 | + parser.add_argument("-n", "--count", help="number of keys to list", default=40) |
| 275 | + return parser |
| 276 | + |
| 277 | + |
| 278 | +if __name__ == "__main__": |
| 279 | + parser = init_argument_parser() |
| 280 | + args = parser.parse_args() |
| 281 | + stub = connect(args.host) |
| 282 | + app = FossilDBClient(stub, args.collection, args.count) |
| 283 | + app.run() |
0 commit comments