cli-todo-jd 0.1.1__tar.gz → 0.2.1__tar.gz

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.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-todo-jd
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: questionary>=2.1.1
8
8
  Requires-Dist: rich>=14.2.0
9
+ Requires-Dist: typer>=0.21.1
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: pre-commit; extra == "dev"
11
12
  Requires-Dist: pytest; extra == "dev"
@@ -20,16 +21,16 @@ A command line to do list with interactive menu
20
21
  This is a command line interface todo list. Once installed, there are two ways to interact
21
22
  with the list.
22
23
 
23
- ### `todo-menu`
24
+ ### `todo_menu`
24
25
 
25
- Once installed us `todo_menu` to launch into the interactive menu. From here you can add,
26
+ Once installed use `todo_menu` to launch into the interactive menu. From here you can add,
26
27
  remove, list, or clear your todo list. Items in your list are stored (by default) as
27
28
  `.todo_list.json`. The menu does also support optional filepaths using `-f` or `--filepath`.
28
29
 
29
30
 
30
31
  ### interacting with todo list without menu
31
32
 
32
- Alternatly you can interact directly using the following commands (`--filepath can be substituted for -f`)
33
+ Alternately you can interact directly using the following commands (`--filepath can be substituted for -f`)
33
34
 
34
35
  - `todo_add text --filepath optional_path_to_json` used to add an item to your list
35
36
  - `todo_remove index --filepath optional_path_to_json` used to remove item number `index`
@@ -7,16 +7,16 @@ A command line to do list with interactive menu
7
7
  This is a command line interface todo list. Once installed, there are two ways to interact
8
8
  with the list.
9
9
 
10
- ### `todo-menu`
10
+ ### `todo_menu`
11
11
 
12
- Once installed us `todo_menu` to launch into the interactive menu. From here you can add,
12
+ Once installed use `todo_menu` to launch into the interactive menu. From here you can add,
13
13
  remove, list, or clear your todo list. Items in your list are stored (by default) as
14
14
  `.todo_list.json`. The menu does also support optional filepaths using `-f` or `--filepath`.
15
15
 
16
16
 
17
17
  ### interacting with todo list without menu
18
18
 
19
- Alternatly you can interact directly using the following commands (`--filepath can be substituted for -f`)
19
+ Alternately you can interact directly using the following commands (`--filepath can be substituted for -f`)
20
20
 
21
21
  - `todo_add text --filepath optional_path_to_json` used to add an item to your list
22
22
  - `todo_remove index --filepath optional_path_to_json` used to remove item number `index`
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from argparse import ArgumentParser
4
+ from cli_todo_jd.main import (
5
+ add_item_to_list,
6
+ remove_item_from_list,
7
+ list_items_on_list,
8
+ clear_list_of_items,
9
+ cli_menu,
10
+ mark_item_as_done,
11
+ mark_item_as_not_done,
12
+ )
13
+ from pathlib import Path
14
+ import typer
15
+
16
+ app = typer.Typer(help="A tiny todo CLI built with Typer.")
17
+
18
+
19
+ @app.command()
20
+ def add(
21
+ text: list[str] = typer.Argument(..., help="Todo item text (no quotes needed)."),
22
+ filepath: Path = typer.Option(
23
+ Path(".todo_list.db"),
24
+ "--filepath",
25
+ "-f",
26
+ help="Path to the JSON file used for storage.",
27
+ ),
28
+ ) -> None:
29
+ full_text = " ".join(text).strip()
30
+ if not full_text:
31
+ raise typer.BadParameter("Todo item text cannot be empty.")
32
+
33
+ add_item_to_list(full_text, filepath)
34
+ typer.echo(f"Added: {full_text}")
35
+
36
+
37
+ @app.command(name="list")
38
+ def list_(
39
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
40
+ show_all: bool = typer.Option(
41
+ False, "--all", "-a", help="Show all todos (open + done)."
42
+ ),
43
+ show_done: bool = typer.Option(
44
+ False, "--done", "-d", help="Show only completed todos."
45
+ ),
46
+ show_open: bool = typer.Option(
47
+ False, "--open", "-o", help="Show only open todos (default)."
48
+ ),
49
+ ) -> None:
50
+ """List todos.
51
+
52
+ Examples
53
+ --------
54
+ - todo list
55
+ - todo list --done
56
+ - todo list --all
57
+ - todo list -a
58
+ """
59
+
60
+ # Choose filter. If nothing specified, default to open.
61
+ # If the user specifies multiple flags, error out.
62
+ flags = [show_all, show_done, show_open]
63
+ if sum(1 for f in flags if f) > 1:
64
+ raise typer.BadParameter(
65
+ "Use only one of: --all / -a, --done / -d, --open / -o"
66
+ )
67
+
68
+ if show_all:
69
+ show = "all"
70
+ elif show_done:
71
+ show = "done"
72
+ else:
73
+ # default is open (or explicit --open)
74
+ show = "open"
75
+
76
+ list_items_on_list(filepath, show=show)
77
+
78
+
79
+ @app.command()
80
+ def remove(
81
+ index: int = typer.Argument(..., help="1-based index of item to remove."),
82
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
83
+ ) -> None:
84
+ remove_item_from_list(index, filepath)
85
+
86
+
87
+ @app.command()
88
+ def clear(
89
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
90
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
91
+ ) -> None:
92
+ if not yes and not typer.confirm(f"Clear all todos in {filepath}?"):
93
+ typer.echo("Cancelled.")
94
+ raise typer.Exit(code=1)
95
+
96
+ clear_list_of_items(filepath)
97
+
98
+
99
+ @app.command(name="menu")
100
+ def menu_(
101
+ filepath: Path = typer.Option(
102
+ Path(".todo_list.db"),
103
+ "--filepath",
104
+ "-f",
105
+ help="Path to the JSON file used for storage.",
106
+ ),
107
+ ) -> None:
108
+ cli_menu(filepath)
109
+ typer.echo("Exited menu.")
110
+
111
+
112
+ @app.command()
113
+ def done(
114
+ index: int = typer.Argument(..., help="1-based index of item to mark as done."),
115
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
116
+ ) -> None:
117
+ mark_item_as_done(index, filepath)
118
+ list_items_on_list(filepath=filepath, show="all")
119
+
120
+
121
+ @app.command()
122
+ def not_done(
123
+ index: int = typer.Argument(..., help="1-based index of item to mark as done."),
124
+ filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
125
+ ) -> None:
126
+ mark_item_as_not_done(index, filepath)
127
+ list_items_on_list(filepath=filepath, show="all")
128
+
129
+
130
+ def parser_optional_args(parser: ArgumentParser):
131
+ parser.add_argument(
132
+ "-f",
133
+ "--filepath",
134
+ help="Path to the file to process",
135
+ default="./.todo_list.db",
136
+ )
137
+
138
+
139
+ def todo_menu():
140
+ parser = ArgumentParser(description="Todo List CLI Menu")
141
+ parser_optional_args(parser)
142
+ args = parser.parse_args()
143
+
144
+ cli_menu(filepath=args.filepath)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ app()
@@ -0,0 +1,480 @@
1
+ from pathlib import Path
2
+ import questionary
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from rich.padding import Padding
6
+ import sqlite3
7
+
8
+ from cli_todo_jd.storage.schema import ensure_schema
9
+ from cli_todo_jd.storage.migrate import migrate_from_json
10
+
11
+
12
+ def main():
13
+ TodoApp()
14
+
15
+
16
+ class TodoApp:
17
+ """
18
+ A simple command-line todo application.
19
+ """
20
+
21
+ def __init__(self, file_path_to_db="./.todo_list.db"):
22
+ self.todos = []
23
+ self.status = []
24
+ self.file_path_to_db = Path(file_path_to_db)
25
+ self._check_and_load_todos(self.file_path_to_db)
26
+ self._console = Console()
27
+
28
+ def add_todo(self, item: str) -> None:
29
+ item = (item or "").strip()
30
+ if not item:
31
+ print("Error: Todo item cannot be empty.")
32
+ return
33
+
34
+ try:
35
+ with sqlite3.connect(self.file_path_to_db) as conn:
36
+ ensure_schema(conn)
37
+ with conn:
38
+ conn.execute(
39
+ "INSERT INTO todos(item, done) VALUES (?, 0);", (item,)
40
+ )
41
+ except sqlite3.Error as e:
42
+ print(f"Error: Failed to add todo. ({e})")
43
+ return
44
+
45
+ print(f'Added todo: "{item}"')
46
+ self._check_and_load_todos(self.file_path_to_db)
47
+
48
+ def list_todos(self, *, show: str = "open") -> None:
49
+ """List todos.
50
+
51
+ Parameters
52
+ ----------
53
+ show:
54
+ "open" (default), "done", or "all".
55
+ """
56
+ show = (show or "open").lower()
57
+ if show not in {"open", "done", "all"}:
58
+ print("Error: show must be one of: open, done, all")
59
+ return
60
+
61
+ # Always read fresh so output reflects the DB
62
+ self._check_and_load_todos(self.file_path_to_db)
63
+ if not self.todos:
64
+ print("No todos found.")
65
+ return
66
+
67
+ if show == "all":
68
+ self._table_print(title="Todos")
69
+ return
70
+
71
+ # Filter in-memory to keep this change minimal. (You can later filter in SQL.)
72
+ filtered_todos: list[str] = []
73
+ filtered_status: list[int] = []
74
+ for todo, done in zip(self.todos, self.status, strict=False):
75
+ if show == "open" and not done:
76
+ filtered_todos.append(todo)
77
+ filtered_status.append(done)
78
+ elif show == "done" and done:
79
+ filtered_todos.append(todo)
80
+ filtered_status.append(done)
81
+
82
+ if not filtered_todos:
83
+ print("No todos found.")
84
+ return
85
+
86
+ original_todos, original_status = self.todos, self.status
87
+ try:
88
+ self.todos, self.status = filtered_todos, filtered_status
89
+ title = "Open todos" if show == "open" else "Completed todos"
90
+ self._table_print(title=title)
91
+ finally:
92
+ self.todos, self.status = original_todos, original_status
93
+
94
+ def remove_todo(self, index: int) -> None:
95
+ # Maintain current UX: index refers to the displayed (1-based) ordering.
96
+ self._check_and_load_todos(self.file_path_to_db)
97
+
98
+ if index < 1 or index > len(self.todos):
99
+ print("Error: Invalid todo index.")
100
+ return
101
+
102
+ try:
103
+ with sqlite3.connect(self.file_path_to_db) as conn:
104
+ ensure_schema(conn)
105
+
106
+ row = conn.execute(
107
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
108
+ (index - 1,),
109
+ ).fetchone()
110
+ if row is None:
111
+ print("Error: Invalid todo index.")
112
+ return
113
+
114
+ todo_id, removed_item = row
115
+ with conn:
116
+ conn.execute("DELETE FROM todos WHERE id = ?;", (todo_id,))
117
+ except sqlite3.Error as e:
118
+ print(f"Error: Failed to remove todo. ({e})")
119
+ return
120
+
121
+ print(f'Removed todo: "{removed_item}"')
122
+ self._check_and_load_todos(self.file_path_to_db)
123
+
124
+ def clear_all(self) -> None:
125
+ try:
126
+ with sqlite3.connect(self.file_path_to_db) as conn:
127
+ ensure_schema(conn)
128
+ with conn:
129
+ conn.execute("DELETE FROM todos;")
130
+ except sqlite3.Error as e:
131
+ print(f"Error: Failed to clear todos. ({e})")
132
+ return
133
+
134
+ self.todos = []
135
+ print("Cleared all todos.")
136
+
137
+ def _check_and_load_todos(self, file_path: Path) -> None:
138
+ # Create parent directory if needed
139
+ file_path.parent.mkdir(parents=True, exist_ok=True)
140
+
141
+ # Optional one-time migration: if the user still has a legacy JSON file and
142
+ # the DB is empty/new, import the items. This keeps upgrades smooth.
143
+ json_path = file_path.with_suffix(".json")
144
+ if json_path.exists() and file_path.suffix == ".db":
145
+ migrate_from_json(json_path=json_path, db_path=file_path, backup=True)
146
+
147
+ try:
148
+ with sqlite3.connect(file_path) as conn:
149
+ ensure_schema(conn)
150
+ rows = conn.execute(
151
+ "SELECT id, item, done, created_at, done_at FROM todos ORDER BY id"
152
+ ).fetchall()
153
+
154
+ # In-memory list is used by the interactive menu for selection.
155
+ # Keep it as a simple list[str] for now.
156
+ self.todos = [row[1] for row in rows]
157
+ self.status = [row[2] for row in rows]
158
+ except sqlite3.Error as e:
159
+ print(f"Warning: Failed to load existing todos. Starting fresh. ({e})")
160
+ self.todos = []
161
+ self.status = []
162
+
163
+ def _table_print(
164
+ self,
165
+ title: str | None = None,
166
+ style: str = "bold cyan",
167
+ ):
168
+ table = Table(
169
+ title=title, header_style=style, border_style=style, show_lines=True
170
+ )
171
+ columns = ["ID", "Todo Item", "Done"]
172
+ for col in columns:
173
+ table.add_column(str(col))
174
+
175
+ for idx, todo in enumerate(self.todos, start=1):
176
+ table.add_row(
177
+ f"{idx}.",
178
+ str(todo),
179
+ "[green]✔[/green]" if self.status[idx - 1] else "[red]✖[/red]",
180
+ )
181
+
182
+ self._console.print(Padding(table, (2, 2)))
183
+
184
+ def mark_as_not_done(self, index: int) -> None:
185
+ self._check_and_load_todos(self.file_path_to_db)
186
+
187
+ if index < 1 or index > len(self.todos):
188
+ print("Error: Invalid todo index.")
189
+ return
190
+
191
+ try:
192
+ with sqlite3.connect(self.file_path_to_db) as conn:
193
+ ensure_schema(conn)
194
+
195
+ row = conn.execute(
196
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
197
+ (index - 1,),
198
+ ).fetchone()
199
+ if row is None:
200
+ print("Error: Invalid todo index.")
201
+ return
202
+
203
+ todo_id, item = row
204
+ with conn:
205
+ conn.execute(
206
+ "UPDATE todos SET done = 0, done_at = NULL WHERE id = ?;",
207
+ (todo_id,),
208
+ )
209
+ except sqlite3.Error as e:
210
+ print(f"Error: Failed to mark todo as not done. ({e})")
211
+ return
212
+
213
+ print(f'Marked todo as not done: "{item}"')
214
+ self._check_and_load_todos(self.file_path_to_db)
215
+
216
+ def mark_as_done(self, index: int) -> None:
217
+ self._check_and_load_todos(self.file_path_to_db)
218
+
219
+ if index < 1 or index > len(self.todos):
220
+ print("Error: Invalid todo index.")
221
+ return
222
+
223
+ try:
224
+ with sqlite3.connect(self.file_path_to_db) as conn:
225
+ ensure_schema(conn)
226
+
227
+ row = conn.execute(
228
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
229
+ (index - 1,),
230
+ ).fetchone()
231
+ if row is None:
232
+ print("Error: Invalid todo index.")
233
+ return
234
+
235
+ todo_id, item = row
236
+ with conn:
237
+ conn.execute(
238
+ "UPDATE todos SET done = ?, done_at = datetime('now') WHERE id = ?;",
239
+ (1, todo_id),
240
+ )
241
+ except sqlite3.Error as e:
242
+ print(f"Error: Failed to mark todo as done. ({e})")
243
+ return
244
+
245
+ print(f'Marked todo as done: "{item}"')
246
+ self._check_and_load_todos(self.file_path_to_db)
247
+
248
+ def update_done_data(self, index, done_value, done_at_value, todo_id):
249
+ text_done_value = "done" if done_value == 1 else "not done"
250
+ try:
251
+ with sqlite3.connect(self.file_path_to_db) as conn:
252
+ ensure_schema(conn)
253
+
254
+ row = conn.execute(
255
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
256
+ (index - 1,),
257
+ ).fetchone()
258
+ if row is None:
259
+ print("Error: Invalid todo index.")
260
+ return
261
+
262
+ todo_id, item = row
263
+ with conn:
264
+ if done_value:
265
+ conn.execute(
266
+ "UPDATE todos SET done = ?, done_at = datetime('now') WHERE id = ?;",
267
+ (1, todo_id),
268
+ )
269
+ else:
270
+ conn.execute(
271
+ "UPDATE todos SET done = ?, done_at = NULL WHERE id = ?;",
272
+ (0, todo_id),
273
+ )
274
+ except sqlite3.Error as e:
275
+ print(f"Error: Failed to mark todo as {text_done_value}. ({e})")
276
+ return
277
+
278
+ def edit_entry(self, index: int, new_text: str) -> None:
279
+ self._check_and_load_todos(self.file_path_to_db)
280
+
281
+ if index < 1 or index > len(self.todos):
282
+ print("Error: Invalid todo index.")
283
+ return
284
+
285
+ new_text = (new_text or "").strip()
286
+ if not new_text:
287
+ print("Error: Todo item cannot be empty.")
288
+ return
289
+
290
+ try:
291
+ with sqlite3.connect(self.file_path_to_db) as conn:
292
+ ensure_schema(conn)
293
+
294
+ row = conn.execute(
295
+ "SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
296
+ (index - 1,),
297
+ ).fetchone()
298
+ if row is None:
299
+ print("Error: Invalid todo index.")
300
+ return
301
+
302
+ todo_id, old_item = row
303
+ with conn:
304
+ conn.execute(
305
+ "UPDATE todos SET item = ? WHERE id = ?;",
306
+ (new_text, todo_id),
307
+ )
308
+ except sqlite3.Error as e:
309
+ print(f"Error: Failed to edit todo. ({e})")
310
+ return
311
+
312
+ print(f'Edited todo: "{old_item}" to "{new_text}"')
313
+ self._check_and_load_todos(self.file_path_to_db)
314
+
315
+
316
+ def create_list(file_path_to_db: str = "./.todo_list.db"):
317
+ """
318
+ Create a new todo list.
319
+
320
+ Parameters
321
+ ----------
322
+ file_path_to_db : str, optional
323
+ The file path to the JSON file for storing todos, by default "./.todo_list.db"
324
+
325
+ Returns
326
+ -------
327
+ TodoApp
328
+ An instance of the TodoApp class.
329
+ """
330
+ app = TodoApp(file_path_to_db=file_path_to_db)
331
+ return app
332
+
333
+
334
+ def add_item_to_list(item: str, filepath: str):
335
+ """
336
+ Add a new item to the todo list.
337
+
338
+ Parameters
339
+ ----------
340
+ item : str
341
+ The todo item to add.
342
+ filepath : str
343
+ The file path to the JSON file for storing todos.
344
+ """
345
+ app = create_list(file_path_to_db=filepath)
346
+ app.add_todo(item)
347
+ app.list_todos()
348
+
349
+
350
+ def list_items_on_list(filepath: str, show: str = "open"):
351
+ """List items in the todo list.
352
+
353
+ Parameters
354
+ ----------
355
+ filepath:
356
+ The SQLite database path.
357
+ show:
358
+ "open" (default), "done", or "all".
359
+ """
360
+ app = create_list(file_path_to_db=filepath)
361
+ app.list_todos(show=show)
362
+
363
+
364
+ def remove_item_from_list(index: int, filepath: str):
365
+ """
366
+ remove an item from the todo list using index
367
+
368
+ Parameters
369
+ ----------
370
+ index : int
371
+ The index of the todo item to remove.
372
+ filepath : str
373
+ The file path to the JSON file for storing todos.
374
+ """
375
+ app = create_list(file_path_to_db=filepath)
376
+ app.remove_todo(index)
377
+ app.list_todos()
378
+
379
+
380
+ def clear_list_of_items(filepath: str):
381
+ """
382
+ Clear all items from the todo list.
383
+
384
+ Parameters
385
+ ----------
386
+ filepath : str
387
+ The file path to the JSON file for storing todos.
388
+ """
389
+ app = create_list(file_path_to_db=filepath)
390
+ app.clear_all()
391
+
392
+
393
+ def mark_item_as_done(index: int, filepath: str):
394
+ app = create_list(file_path_to_db=filepath)
395
+ app.mark_as_done(index)
396
+
397
+
398
+ def mark_item_as_not_done(index: int, filepath: str):
399
+ app = create_list(file_path_to_db=filepath)
400
+ app.mark_as_not_done(index)
401
+
402
+
403
+ def cli_menu(filepath="./.todo_list.db"):
404
+ """
405
+ Display the command-line interface menu for the todo list.
406
+
407
+ Parameters
408
+ ----------
409
+ filepath : str, optional
410
+ The file path to the JSON file for storing todos, by default "./.todo_list.db"
411
+ """
412
+ app = create_list(file_path_to_db=filepath)
413
+ while True:
414
+ action = questionary.select(
415
+ "What would you like to do?",
416
+ choices=[
417
+ "Add todo",
418
+ "List todos",
419
+ "Update todo status",
420
+ "Remove todo",
421
+ "Clear all todos",
422
+ "Exit",
423
+ ],
424
+ ).ask()
425
+
426
+ if action == "Add todo":
427
+ item = questionary.text("Enter the todo item:").ask()
428
+ app.add_todo(item)
429
+ elif action == "List todos":
430
+ app.list_todos(show="all")
431
+ elif action == "Update todo status":
432
+ if not app.todos:
433
+ print("No todos to update.")
434
+ continue
435
+ todo_choice = questionary.select(
436
+ "Select the todo to update:",
437
+ choices=["<Back>"] + app.todos,
438
+ ).ask()
439
+
440
+ if todo_choice == "<Back>" or todo_choice is None:
441
+ continue
442
+
443
+ todo_index = app.todos.index(todo_choice) + 1
444
+ status_choice = questionary.select(
445
+ "Mark as:",
446
+ choices=["Done", "Not Done", "<Back>"],
447
+ ).ask()
448
+
449
+ if status_choice == "<Back>" or status_choice is None:
450
+ continue
451
+ elif status_choice == "Done":
452
+ app.mark_as_done(todo_index)
453
+ elif status_choice == "Not Done":
454
+ app.mark_as_not_done(todo_index)
455
+ app.list_todos(show="all")
456
+ elif action == "Remove todo":
457
+ if not app.todos:
458
+ print("No todos to remove.")
459
+ continue
460
+ todo_choice = questionary.select(
461
+ "Select the todo to remove:",
462
+ choices=["<Back>"] + app.todos,
463
+ ).ask()
464
+
465
+ if todo_choice == "<Back>" or todo_choice is None:
466
+ continue
467
+
468
+ todo_to_remove = app.todos.index(todo_choice) + 1
469
+ app.remove_todo(todo_to_remove)
470
+
471
+ elif action == "Clear all todos":
472
+ confirm = questionary.confirm(
473
+ "Are you sure you want to clear all todos?"
474
+ ).ask()
475
+ if confirm:
476
+ app.clear_all()
477
+ elif action == "Exit":
478
+ break
479
+ else:
480
+ break
@@ -0,0 +1,6 @@
1
+ """Storage helpers (SQLite schema + migrations)."""
2
+
3
+ from .schema import ensure_schema, SCHEMA_VERSION
4
+ from .migrate import migrate_from_json
5
+
6
+ __all__ = ["ensure_schema", "SCHEMA_VERSION", "migrate_from_json"]
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ from .schema import ensure_schema
9
+
10
+
11
+ def _iter_json_items(data: object) -> Iterable[str]:
12
+ """Yield todo item strings from supported legacy JSON formats.
13
+
14
+ Supported:
15
+ - ["item1", "item2", ...]
16
+ - [{"item": "..."}, {"text": "..."}, ...]
17
+ """
18
+ if isinstance(data, list):
19
+ for entry in data:
20
+ if isinstance(entry, str):
21
+ text = entry
22
+ elif isinstance(entry, dict):
23
+ text = entry.get("item") or entry.get("text")
24
+ if not isinstance(text, str):
25
+ continue
26
+ else:
27
+ continue
28
+
29
+ text = text.strip()
30
+ if text:
31
+ yield text
32
+
33
+
34
+ def migrate_from_json(
35
+ *,
36
+ json_path: Path,
37
+ db_path: Path,
38
+ backup: bool = True,
39
+ ) -> int:
40
+ """Migrate todos from a legacy JSON file into a SQLite database.
41
+
42
+ Parameters
43
+ ----------
44
+ json_path:
45
+ Path to legacy JSON file (e.g. `.todo_list.json`).
46
+ db_path:
47
+ Path to SQLite file (e.g. `.todo_list.db`).
48
+ backup:
49
+ If True, rename the JSON file to `.bak` after successful import.
50
+
51
+ Returns
52
+ -------
53
+ int
54
+ Number of rows inserted.
55
+
56
+ Behavior
57
+ --------
58
+ - If the JSON file doesn't exist, returns 0.
59
+ - If the database already has todos, does not import (returns 0).
60
+ (This avoids duplicate imports when multiple commands run.)
61
+ """
62
+
63
+ if not json_path.exists():
64
+ return 0
65
+
66
+ db_path.parent.mkdir(parents=True, exist_ok=True)
67
+
68
+ try:
69
+ raw = json_path.read_text(encoding="utf-8")
70
+ data = json.loads(raw) if raw.strip() else []
71
+ except (OSError, json.JSONDecodeError):
72
+ # Fail safe: don't destroy/rename the user's file.
73
+ return 0
74
+
75
+ items = list(_iter_json_items(data))
76
+ if not items:
77
+ return 0
78
+
79
+ inserted = 0
80
+ with sqlite3.connect(db_path) as conn:
81
+ ensure_schema(conn)
82
+
83
+ # Guard against double-import
84
+ existing = conn.execute("SELECT 1 FROM todos LIMIT 1;").fetchone()
85
+ if existing is not None:
86
+ return 0
87
+
88
+ with conn:
89
+ conn.executemany(
90
+ "INSERT INTO todos(item, done) VALUES (?, 0);", [(t,) for t in items]
91
+ )
92
+ inserted = conn.execute("SELECT changes();").fetchone()[0]
93
+
94
+ if backup:
95
+ try:
96
+ bak_path = json_path.with_suffix(json_path.suffix + ".bak")
97
+ if bak_path.exists():
98
+ # Avoid overwrite; add a numeric suffix
99
+ i = 1
100
+ while True:
101
+ candidate = json_path.with_suffix(json_path.suffix + f".bak{i}")
102
+ if not candidate.exists():
103
+ bak_path = candidate
104
+ break
105
+ i += 1
106
+ json_path.rename(bak_path)
107
+ except OSError:
108
+ # Backup failure shouldn't invalidate a successful migration
109
+ pass
110
+
111
+ return inserted
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+
6
+ SCHEMA_VERSION = 1
7
+
8
+
9
+ def ensure_schema(conn: sqlite3.Connection) -> None:
10
+ """Ensure required SQLite schema exists and is migrated.
11
+
12
+ Uses `PRAGMA user_version` for lightweight, in-app migrations.
13
+
14
+ Parameters
15
+ ----------
16
+ conn:
17
+ An open sqlite3 connection.
18
+
19
+ Notes
20
+ -----
21
+ - Call this once per process/command, right after connecting.
22
+ - Keep migrations idempotent and wrapped in a transaction.
23
+ """
24
+
25
+ # Improve concurrent CLI usage (separate processes) and durability.
26
+ # WAL is persistent for the database file once set.
27
+ conn.execute("PRAGMA journal_mode = WAL;")
28
+ conn.execute("PRAGMA foreign_keys = ON;")
29
+
30
+ current_version = conn.execute("PRAGMA user_version;").fetchone()[0]
31
+
32
+ # Fresh database
33
+ if current_version == 0:
34
+ with conn:
35
+ conn.execute(
36
+ """
37
+ CREATE TABLE IF NOT EXISTS todos (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ item TEXT NOT NULL,
40
+ done INTEGER NOT NULL DEFAULT 0,
41
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
42
+ done_at TEXT
43
+ );
44
+ """
45
+ )
46
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_todos_done ON todos(done);")
47
+ conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)};")
48
+ return
49
+
50
+ # Incremental migrations
51
+ if current_version < 1:
52
+ # Example placeholder for future migrations.
53
+ # Keep each migration block small and bump user_version accordingly.
54
+ with conn:
55
+ conn.execute("PRAGMA user_version = 1;")
56
+ current_version = 1
57
+
58
+ # If you bump SCHEMA_VERSION, add `if current_version < N:` blocks above.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-todo-jd
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: questionary>=2.1.1
8
8
  Requires-Dist: rich>=14.2.0
9
+ Requires-Dist: typer>=0.21.1
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: pre-commit; extra == "dev"
11
12
  Requires-Dist: pytest; extra == "dev"
@@ -20,16 +21,16 @@ A command line to do list with interactive menu
20
21
  This is a command line interface todo list. Once installed, there are two ways to interact
21
22
  with the list.
22
23
 
23
- ### `todo-menu`
24
+ ### `todo_menu`
24
25
 
25
- Once installed us `todo_menu` to launch into the interactive menu. From here you can add,
26
+ Once installed use `todo_menu` to launch into the interactive menu. From here you can add,
26
27
  remove, list, or clear your todo list. Items in your list are stored (by default) as
27
28
  `.todo_list.json`. The menu does also support optional filepaths using `-f` or `--filepath`.
28
29
 
29
30
 
30
31
  ### interacting with todo list without menu
31
32
 
32
- Alternatly you can interact directly using the following commands (`--filepath can be substituted for -f`)
33
+ Alternately you can interact directly using the following commands (`--filepath can be substituted for -f`)
33
34
 
34
35
  - `todo_add text --filepath optional_path_to_json` used to add an item to your list
35
36
  - `todo_remove index --filepath optional_path_to_json` used to remove item number `index`
@@ -8,4 +8,7 @@ cli_todo_jd.egg-info/SOURCES.txt
8
8
  cli_todo_jd.egg-info/dependency_links.txt
9
9
  cli_todo_jd.egg-info/entry_points.txt
10
10
  cli_todo_jd.egg-info/requires.txt
11
- cli_todo_jd.egg-info/top_level.txt
11
+ cli_todo_jd.egg-info/top_level.txt
12
+ cli_todo_jd/storage/__init__.py
13
+ cli_todo_jd/storage/migrate.py
14
+ cli_todo_jd/storage/schema.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ todo = cli_todo_jd.cli_entry:app
3
+ todo_menu = cli_todo_jd.cli_entry:todo_menu
@@ -1,5 +1,6 @@
1
1
  questionary>=2.1.1
2
2
  rich>=14.2.0
3
+ typer>=0.21.1
3
4
 
4
5
  [dev]
5
6
  pre-commit
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "cli-todo-jd"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "questionary>=2.1.1",
9
9
  "rich>=14.2.0",
10
+ "typer>=0.21.1",
10
11
  ]
11
12
 
12
13
  [project.optional-dependencies]
@@ -17,14 +18,12 @@ dev = [
17
18
  ]
18
19
 
19
20
  [project.scripts]
20
- todo_add = "cli_todo_jd.cli_entry:add_item"
21
- todo_remove = "cli_todo_jd.cli_entry:remove_item"
22
- todo_list = "cli_todo_jd.cli_entry:list_items"
23
- todo_clear = "cli_todo_jd.cli_entry:clear_list"
24
21
  todo_menu = "cli_todo_jd.cli_entry:todo_menu"
22
+ todo = "cli_todo_jd.cli_entry:app"
23
+
25
24
 
26
25
  [tool.bumpversion]
27
- current_version = "0.1.1"
26
+ current_version = "0.2.1"
28
27
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
29
28
  serialize = ["{major}.{minor}.{patch}"]
30
29
  search = "{current_version}"
@@ -1,84 +0,0 @@
1
- from argparse import ArgumentParser
2
- from cli_todo_jd.main import (
3
- add_item_to_list,
4
- remove_item_from_list,
5
- list_items_on_list,
6
- clear_list_of_items,
7
- cli_menu,
8
- )
9
-
10
-
11
- def parser_optional_args(parser: ArgumentParser):
12
- parser.add_argument(
13
- "-f",
14
- "--filepath",
15
- help="Path to the file to process",
16
- default="./.todo_list.json",
17
- )
18
-
19
-
20
- def add_item():
21
- parser = ArgumentParser(description="Add a todo item")
22
- parser.add_argument(
23
- "item",
24
- nargs="+",
25
- help="The todo item to add (use quotes or multiple words)",
26
- )
27
- parser_optional_args(parser)
28
-
29
- args = parser.parse_args()
30
- args.item = " ".join(args.item)
31
- add_item_to_list(args.item, args.filepath)
32
-
33
-
34
- def remove_item():
35
- parser = ArgumentParser(description="Remove a todo item by index")
36
- parser.add_argument(
37
- "index",
38
- type=int,
39
- help="The index of the todo item to remove (1-based)",
40
- )
41
- parser_optional_args(parser)
42
-
43
- args = parser.parse_args()
44
- remove_item_from_list(args.index, args.filepath)
45
-
46
-
47
- def list_items():
48
- parser = ArgumentParser(description="List all todo items")
49
- parser_optional_args(parser)
50
-
51
- args = parser.parse_args()
52
- list_items_on_list(args.filepath)
53
-
54
-
55
- def clear_list():
56
- parser = ArgumentParser(description="Clear all todo items")
57
- parser.add_argument(
58
- "-y",
59
- "--yes",
60
- action="store_true",
61
- help="Do not prompt for confirmation",
62
- )
63
- parser_optional_args(parser)
64
-
65
- args = parser.parse_args()
66
- list_items_on_list(args.filepath)
67
-
68
- if not args.yes:
69
- resp = (
70
- input(f"Clear all todo items in '{args.filepath}'? [y/n]: ").strip().lower()
71
- )
72
- if resp not in ("y", "yes"):
73
- return
74
-
75
- # assuming remove_item_from_list(0/None) clears; otherwise replace with your clear implementation
76
- clear_list_of_items(args.filepath)
77
-
78
-
79
- def todo_menu():
80
- parser = ArgumentParser(description="Todo List CLI Menu")
81
- parser_optional_args(parser)
82
- args = parser.parse_args()
83
-
84
- cli_menu(filepath=args.filepath)
@@ -1,215 +0,0 @@
1
- import json
2
- from pathlib import Path
3
- import questionary
4
- from rich.console import Console
5
- from rich.table import Table
6
- from rich.padding import Padding
7
-
8
-
9
- def main():
10
- TodoApp()
11
-
12
-
13
- class TodoApp:
14
- """
15
- A simple command-line todo application.
16
- """
17
-
18
- def __init__(self, file_path_to_json="./.todo_list.json"):
19
- self.todos = []
20
- self.file_path_to_json = Path(file_path_to_json)
21
- self._check_and_load_todos(self.file_path_to_json)
22
- self._console = Console()
23
-
24
- def add_todo(self, item):
25
- self.todos.append(item)
26
- print(f'Added todo: "{item}"')
27
-
28
- def list_todos(self):
29
- if not self.todos:
30
- print("No todos found.")
31
- return
32
- self._table_print()
33
-
34
- def remove_todo(self, index):
35
- try:
36
- removed = self.todos.pop(index - 1)
37
- print(f'Removed todo: "{removed}"')
38
- except IndexError:
39
- print("Error: Invalid todo index.")
40
-
41
- def _check_and_load_todos(self, file_path):
42
- if file_path.exists():
43
- try:
44
- with file_path.open("r", encoding="utf-8") as f:
45
- self.todos = json.load(f)
46
- except (json.JSONDecodeError, OSError):
47
- print("Warning: Failed to load existing todos. Starting fresh.")
48
-
49
- def write_todos(self):
50
- try:
51
- with self.file_path_to_json.open("w", encoding="utf-8") as f:
52
- json.dump(self.todos, f, ensure_ascii=False, indent=2)
53
- except OSError as e:
54
- print(f"Warning: failed to save todos: {e}")
55
-
56
- def _table_print(
57
- self,
58
- title: str | None = None,
59
- style: str = "bold cyan",
60
- ):
61
- table = Table(
62
- title=title, header_style=style, border_style=style, show_lines=True
63
- )
64
- columns = ["ID", "Todo Item"]
65
- for col in columns:
66
- table.add_column(str(col))
67
- for idx, todo in enumerate(self.todos, start=1):
68
- table.add_row(f"{idx}.", todo)
69
- self._console.print(Padding(table, (2, 2)))
70
-
71
-
72
- def create_list(file_path_to_json: str = "./.todo_list.json"):
73
- """
74
- Create a new todo list.
75
-
76
- Parameters
77
- ----------
78
- file_path_to_json : str, optional
79
- The file path to the JSON file for storing todos, by default "./.todo_list.json"
80
-
81
- Returns
82
- -------
83
- TodoApp
84
- An instance of the TodoApp class.
85
- """
86
- app = TodoApp(file_path_to_json=file_path_to_json)
87
- return app
88
-
89
-
90
- def add_item_to_list(item: str, filepath: str):
91
- """
92
- Add a new item to the todo list.
93
-
94
- Parameters
95
- ----------
96
- item : str
97
- The todo item to add.
98
- filepath : str
99
- The file path to the JSON file for storing todos.
100
- """
101
- app = create_list(file_path_to_json=filepath)
102
- app.add_todo(item)
103
- app.list_todos()
104
- app.write_todos()
105
-
106
-
107
- def list_items_on_list(filepath: str):
108
- """
109
- List all items in the todo list.
110
-
111
- Parameters
112
- ----------
113
- filepath : str
114
- The file path to the JSON file for storing todos.
115
- """
116
- app = create_list(file_path_to_json=filepath)
117
- app.list_todos()
118
-
119
-
120
- def remove_item_from_list(index: int, filepath: str):
121
- """
122
- remove an item from the todo list using index
123
-
124
- Parameters
125
- ----------
126
- index : int
127
- The index of the todo item to remove.
128
- filepath : str
129
- The file path to the JSON file for storing todos.
130
- """
131
- app = create_list(file_path_to_json=filepath)
132
- app.remove_todo(index)
133
- app.list_todos()
134
- app.write_todos()
135
-
136
-
137
- def clear_list_of_items(filepath: str):
138
- """
139
- Clear all items from the todo list.
140
-
141
- Parameters
142
- ----------
143
- filepath : str
144
- The file path to the JSON file for storing todos.
145
- """
146
- app = create_list(file_path_to_json=filepath)
147
- app.todos = []
148
- print("Cleared all todos.")
149
- app.write_todos()
150
-
151
-
152
- def cli_menu(filepath="./.todo_list.json"):
153
- """
154
- Display the command-line interface menu for the todo list.
155
-
156
- Parameters
157
- ----------
158
- filepath : str, optional
159
- The file path to the JSON file for storing todos, by default "./.todo_list.json"
160
- """
161
- app = create_list(file_path_to_json=filepath)
162
- while True:
163
- action = questionary.select(
164
- "What would you like to do?",
165
- choices=[
166
- "Add todo",
167
- "List todos",
168
- "Remove todo",
169
- "Clear all todos",
170
- "Exit",
171
- ],
172
- ).ask()
173
-
174
- if action == "Add todo":
175
- item = questionary.text("Enter the todo item:").ask()
176
- app.add_todo(item)
177
- app.write_todos()
178
- elif action == "List todos":
179
- app.list_todos()
180
- elif action == "Remove todo":
181
- if not app.todos:
182
- print("No todos to remove.")
183
- continue
184
- todo_choice = questionary.select(
185
- "Select the todo to remove:",
186
- choices=["<Back>"] + app.todos,
187
- ).ask()
188
-
189
- if todo_choice == "<Back>":
190
- continue
191
-
192
- todo_to_remove = app.todos.index(todo_choice) + 1
193
- app.remove_todo(todo_to_remove)
194
- app.write_todos()
195
-
196
- elif action == "Clear all todos":
197
- confirm = questionary.confirm(
198
- "Are you sure you want to clear all todos?"
199
- ).ask()
200
- if confirm:
201
- app.todos = []
202
- print("Cleared all todos.")
203
- app.write_todos()
204
- elif action == "Clear all todos":
205
- confirm = questionary.confirm(
206
- "Are you sure you want to clear all todos?"
207
- ).ask()
208
- if confirm:
209
- app.todos = []
210
- print("Cleared all todos.")
211
- app.write_todos()
212
- elif action == "Exit":
213
- break
214
- else:
215
- break
@@ -1,6 +0,0 @@
1
- [console_scripts]
2
- todo_add = cli_todo_jd.cli_entry:add_item
3
- todo_clear = cli_todo_jd.cli_entry:clear_list
4
- todo_list = cli_todo_jd.cli_entry:list_items
5
- todo_menu = cli_todo_jd.cli_entry:todo_menu
6
- todo_remove = cli_todo_jd.cli_entry:remove_item
File without changes