cli-todo-jd 0.1.0__py3-none-any.whl → 0.2.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.
- cli_todo_jd/cli_entry.py +86 -48
- cli_todo_jd/main.py +280 -54
- cli_todo_jd/storage/__init__.py +6 -0
- cli_todo_jd/storage/migrate.py +111 -0
- cli_todo_jd/storage/schema.py +58 -0
- {cli_todo_jd-0.1.0.dist-info → cli_todo_jd-0.2.0.dist-info}/METADATA +23 -1
- cli_todo_jd-0.2.0.dist-info/RECORD +11 -0
- {cli_todo_jd-0.1.0.dist-info → cli_todo_jd-0.2.0.dist-info}/WHEEL +1 -1
- cli_todo_jd-0.2.0.dist-info/entry_points.txt +3 -0
- cli_todo_jd-0.1.0.dist-info/RECORD +0 -8
- cli_todo_jd-0.1.0.dist-info/entry_points.txt +0 -6
- {cli_todo_jd-0.1.0.dist-info → cli_todo_jd-0.2.0.dist-info}/top_level.txt +0 -0
cli_todo_jd/cli_entry.py
CHANGED
|
@@ -1,75 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from argparse import ArgumentParser
|
|
2
4
|
from cli_todo_jd.main import (
|
|
3
5
|
add_item_to_list,
|
|
4
6
|
remove_item_from_list,
|
|
5
7
|
list_items_on_list,
|
|
6
8
|
clear_list_of_items,
|
|
9
|
+
cli_menu,
|
|
10
|
+
mark_item_as_done,
|
|
11
|
+
mark_item_as_not_done,
|
|
7
12
|
)
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import typer
|
|
8
15
|
|
|
16
|
+
app = typer.Typer(help="A tiny todo CLI built with Typer.")
|
|
9
17
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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"),
|
|
13
24
|
"--filepath",
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
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.")
|
|
17
32
|
|
|
33
|
+
add_item_to_list(full_text, filepath)
|
|
34
|
+
typer.echo(f"Added: {full_text}")
|
|
18
35
|
|
|
19
|
-
def add_item():
|
|
20
|
-
parser = ArgumentParser(description="Add a todo item")
|
|
21
|
-
parser.add_argument(
|
|
22
|
-
"item",
|
|
23
|
-
nargs="+",
|
|
24
|
-
help="The todo item to add (use quotes or multiple words)",
|
|
25
|
-
)
|
|
26
|
-
parser_optional_args(parser)
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
@app.command(name="list")
|
|
38
|
+
def list_(
|
|
39
|
+
filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
|
|
40
|
+
) -> None:
|
|
41
|
+
list_items_on_list(filepath)
|
|
31
42
|
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
parser_optional_args(parser)
|
|
44
|
+
@app.command()
|
|
45
|
+
def remove(
|
|
46
|
+
index: int = typer.Argument(..., help="1-based index of item to remove."),
|
|
47
|
+
filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
|
|
48
|
+
) -> None:
|
|
49
|
+
remove_item_from_list(index, filepath)
|
|
41
50
|
|
|
42
|
-
args = parser.parse_args()
|
|
43
|
-
remove_item_from_list(args.index, args.filepath)
|
|
44
51
|
|
|
52
|
+
@app.command()
|
|
53
|
+
def clear(
|
|
54
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
55
|
+
filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
|
|
56
|
+
) -> None:
|
|
57
|
+
if not yes and not typer.confirm(f"Clear all todos in {filepath}?"):
|
|
58
|
+
typer.echo("Cancelled.")
|
|
59
|
+
raise typer.Exit(code=1)
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
parser = ArgumentParser(description="List all todo items")
|
|
48
|
-
parser_optional_args(parser)
|
|
61
|
+
clear_list_of_items(filepath)
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
|
|
64
|
+
@app.command(name="menu")
|
|
65
|
+
def menu_(
|
|
66
|
+
filepath: Path = typer.Option(
|
|
67
|
+
Path(".todo_list.db"),
|
|
68
|
+
"--filepath",
|
|
69
|
+
"-f",
|
|
70
|
+
help="Path to the JSON file used for storage.",
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
cli_menu(filepath)
|
|
74
|
+
typer.echo("Exited menu.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def done(
|
|
79
|
+
index: int = typer.Argument(..., help="1-based index of item to mark as done."),
|
|
80
|
+
filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
|
|
81
|
+
) -> None:
|
|
82
|
+
mark_item_as_done(index, filepath)
|
|
83
|
+
list_(filepath=filepath)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def not_done(
|
|
88
|
+
index: int = typer.Argument(..., help="1-based index of item to mark as done."),
|
|
89
|
+
filepath: Path = typer.Option(Path(".todo_list.db"), "--filepath", "-f"),
|
|
90
|
+
) -> None:
|
|
91
|
+
mark_item_as_not_done(index, filepath)
|
|
92
|
+
list_(filepath=filepath)
|
|
52
93
|
|
|
53
94
|
|
|
54
|
-
def
|
|
55
|
-
parser = ArgumentParser(description="Clear all todo items")
|
|
95
|
+
def parser_optional_args(parser: ArgumentParser):
|
|
56
96
|
parser.add_argument(
|
|
57
|
-
"-
|
|
58
|
-
"--
|
|
59
|
-
|
|
60
|
-
|
|
97
|
+
"-f",
|
|
98
|
+
"--filepath",
|
|
99
|
+
help="Path to the file to process",
|
|
100
|
+
default="./.todo_list.db",
|
|
61
101
|
)
|
|
62
|
-
parser_optional_args(parser)
|
|
63
102
|
|
|
103
|
+
|
|
104
|
+
def todo_menu():
|
|
105
|
+
parser = ArgumentParser(description="Todo List CLI Menu")
|
|
106
|
+
parser_optional_args(parser)
|
|
64
107
|
args = parser.parse_args()
|
|
65
|
-
list_items_on_list(args.filepath)
|
|
66
108
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
input(f"Clear all todo items in '{args.filepath}'? [y/n]: ").strip().lower()
|
|
70
|
-
)
|
|
71
|
-
if resp not in ("y", "yes"):
|
|
72
|
-
return
|
|
109
|
+
cli_menu(filepath=args.filepath)
|
|
110
|
+
|
|
73
111
|
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
app()
|
cli_todo_jd/main.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import json
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
import questionary
|
|
4
3
|
from rich.console import Console
|
|
5
4
|
from rich.table import Table
|
|
6
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
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
def main():
|
|
@@ -15,43 +18,109 @@ class TodoApp:
|
|
|
15
18
|
A simple command-line todo application.
|
|
16
19
|
"""
|
|
17
20
|
|
|
18
|
-
def __init__(self,
|
|
21
|
+
def __init__(self, file_path_to_db="./.todo_list.db"):
|
|
19
22
|
self.todos = []
|
|
20
|
-
self.
|
|
21
|
-
self.
|
|
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)
|
|
22
26
|
self._console = Console()
|
|
23
27
|
|
|
24
|
-
def add_todo(self, item):
|
|
25
|
-
|
|
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
|
+
|
|
26
45
|
print(f'Added todo: "{item}"')
|
|
46
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
27
47
|
|
|
28
|
-
def list_todos(self):
|
|
48
|
+
def list_todos(self) -> None:
|
|
49
|
+
# Always read fresh so output reflects the DB
|
|
50
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
29
51
|
if not self.todos:
|
|
30
52
|
print("No todos found.")
|
|
31
53
|
return
|
|
32
54
|
self._table_print()
|
|
33
55
|
|
|
34
|
-
def remove_todo(self, index):
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
def remove_todo(self, index: int) -> None:
|
|
57
|
+
# Maintain current UX: index refers to the displayed (1-based) ordering.
|
|
58
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
59
|
+
|
|
60
|
+
if index < 1 or index > len(self.todos):
|
|
39
61
|
print("Error: Invalid todo index.")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with sqlite3.connect(self.file_path_to_db) as conn:
|
|
66
|
+
ensure_schema(conn)
|
|
67
|
+
|
|
68
|
+
row = conn.execute(
|
|
69
|
+
"SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
|
|
70
|
+
(index - 1,),
|
|
71
|
+
).fetchone()
|
|
72
|
+
if row is None:
|
|
73
|
+
print("Error: Invalid todo index.")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
todo_id, removed_item = row
|
|
77
|
+
with conn:
|
|
78
|
+
conn.execute("DELETE FROM todos WHERE id = ?;", (todo_id,))
|
|
79
|
+
except sqlite3.Error as e:
|
|
80
|
+
print(f"Error: Failed to remove todo. ({e})")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
print(f'Removed todo: "{removed_item}"')
|
|
84
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
85
|
+
|
|
86
|
+
def clear_all(self) -> None:
|
|
87
|
+
try:
|
|
88
|
+
with sqlite3.connect(self.file_path_to_db) as conn:
|
|
89
|
+
ensure_schema(conn)
|
|
90
|
+
with conn:
|
|
91
|
+
conn.execute("DELETE FROM todos;")
|
|
92
|
+
except sqlite3.Error as e:
|
|
93
|
+
print(f"Error: Failed to clear todos. ({e})")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
self.todos = []
|
|
97
|
+
print("Cleared all todos.")
|
|
40
98
|
|
|
41
|
-
def _check_and_load_todos(self, file_path):
|
|
42
|
-
if
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
99
|
+
def _check_and_load_todos(self, file_path: Path) -> None:
|
|
100
|
+
# Create parent directory if needed
|
|
101
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
# Optional one-time migration: if the user still has a legacy JSON file and
|
|
104
|
+
# the DB is empty/new, import the items. This keeps upgrades smooth.
|
|
105
|
+
json_path = file_path.with_suffix(".json")
|
|
106
|
+
if json_path.exists() and file_path.suffix == ".db":
|
|
107
|
+
migrate_from_json(json_path=json_path, db_path=file_path, backup=True)
|
|
48
108
|
|
|
49
|
-
def write_todos(self):
|
|
50
109
|
try:
|
|
51
|
-
with
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
110
|
+
with sqlite3.connect(file_path) as conn:
|
|
111
|
+
ensure_schema(conn)
|
|
112
|
+
rows = conn.execute(
|
|
113
|
+
"SELECT id, item, done, created_at, done_at FROM todos ORDER BY id"
|
|
114
|
+
).fetchall()
|
|
115
|
+
|
|
116
|
+
# In-memory list is used by the interactive menu for selection.
|
|
117
|
+
# Keep it as a simple list[str] for now.
|
|
118
|
+
self.todos = [row[1] for row in rows]
|
|
119
|
+
self.status = [row[2] for row in rows]
|
|
120
|
+
except sqlite3.Error as e:
|
|
121
|
+
print(f"Warning: Failed to load existing todos. Starting fresh. ({e})")
|
|
122
|
+
self.todos = []
|
|
123
|
+
self.status = []
|
|
55
124
|
|
|
56
125
|
def _table_print(
|
|
57
126
|
self,
|
|
@@ -61,29 +130,166 @@ class TodoApp:
|
|
|
61
130
|
table = Table(
|
|
62
131
|
title=title, header_style=style, border_style=style, show_lines=True
|
|
63
132
|
)
|
|
64
|
-
columns = ["ID", "Todo Item"]
|
|
133
|
+
columns = ["ID", "Todo Item", "Done"]
|
|
65
134
|
for col in columns:
|
|
66
135
|
table.add_column(str(col))
|
|
136
|
+
|
|
67
137
|
for idx, todo in enumerate(self.todos, start=1):
|
|
68
|
-
table.add_row(
|
|
138
|
+
table.add_row(
|
|
139
|
+
f"{idx}.",
|
|
140
|
+
str(todo),
|
|
141
|
+
"[green]✔[/green]" if self.status[idx - 1] else "[red]✖[/red]",
|
|
142
|
+
)
|
|
143
|
+
|
|
69
144
|
self._console.print(Padding(table, (2, 2)))
|
|
70
145
|
|
|
146
|
+
def mark_as_not_done(self, index: int) -> None:
|
|
147
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
148
|
+
|
|
149
|
+
if index < 1 or index > len(self.todos):
|
|
150
|
+
print("Error: Invalid todo index.")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with sqlite3.connect(self.file_path_to_db) as conn:
|
|
155
|
+
ensure_schema(conn)
|
|
156
|
+
|
|
157
|
+
row = conn.execute(
|
|
158
|
+
"SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
|
|
159
|
+
(index - 1,),
|
|
160
|
+
).fetchone()
|
|
161
|
+
if row is None:
|
|
162
|
+
print("Error: Invalid todo index.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
todo_id, item = row
|
|
166
|
+
with conn:
|
|
167
|
+
conn.execute(
|
|
168
|
+
"UPDATE todos SET done = 0, done_at = NULL WHERE id = ?;",
|
|
169
|
+
(todo_id,),
|
|
170
|
+
)
|
|
171
|
+
except sqlite3.Error as e:
|
|
172
|
+
print(f"Error: Failed to mark todo as not done. ({e})")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
print(f'Marked todo as not done: "{item}"')
|
|
176
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
177
|
+
|
|
178
|
+
def mark_as_done(self, index: int) -> None:
|
|
179
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
180
|
+
|
|
181
|
+
if index < 1 or index > len(self.todos):
|
|
182
|
+
print("Error: Invalid todo index.")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
with sqlite3.connect(self.file_path_to_db) as conn:
|
|
187
|
+
ensure_schema(conn)
|
|
188
|
+
|
|
189
|
+
row = conn.execute(
|
|
190
|
+
"SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
|
|
191
|
+
(index - 1,),
|
|
192
|
+
).fetchone()
|
|
193
|
+
if row is None:
|
|
194
|
+
print("Error: Invalid todo index.")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
todo_id, item = row
|
|
198
|
+
with conn:
|
|
199
|
+
conn.execute(
|
|
200
|
+
"UPDATE todos SET done = ?, done_at = datetime('now') WHERE id = ?;",
|
|
201
|
+
(1, todo_id),
|
|
202
|
+
)
|
|
203
|
+
except sqlite3.Error as e:
|
|
204
|
+
print(f"Error: Failed to mark todo as done. ({e})")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
print(f'Marked todo as done: "{item}"')
|
|
208
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
71
209
|
|
|
72
|
-
def
|
|
210
|
+
def update_done_data(self, index, done_value, done_at_value, todo_id):
|
|
211
|
+
text_done_value = "done" if done_value == 1 else "not done"
|
|
212
|
+
try:
|
|
213
|
+
with sqlite3.connect(self.file_path_to_db) as conn:
|
|
214
|
+
ensure_schema(conn)
|
|
215
|
+
|
|
216
|
+
row = conn.execute(
|
|
217
|
+
"SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
|
|
218
|
+
(index - 1,),
|
|
219
|
+
).fetchone()
|
|
220
|
+
if row is None:
|
|
221
|
+
print("Error: Invalid todo index.")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
todo_id, item = row
|
|
225
|
+
with conn:
|
|
226
|
+
if done_value:
|
|
227
|
+
conn.execute(
|
|
228
|
+
"UPDATE todos SET done = ?, done_at = datetime('now') WHERE id = ?;",
|
|
229
|
+
(1, todo_id),
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
conn.execute(
|
|
233
|
+
"UPDATE todos SET done = ?, done_at = NULL WHERE id = ?;",
|
|
234
|
+
(0, todo_id),
|
|
235
|
+
)
|
|
236
|
+
except sqlite3.Error as e:
|
|
237
|
+
print(f"Error: Failed to mark todo as {text_done_value}. ({e})")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
def edit_entry(self, index: int, new_text: str) -> None:
|
|
241
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
242
|
+
|
|
243
|
+
if index < 1 or index > len(self.todos):
|
|
244
|
+
print("Error: Invalid todo index.")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
new_text = (new_text or "").strip()
|
|
248
|
+
if not new_text:
|
|
249
|
+
print("Error: Todo item cannot be empty.")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
with sqlite3.connect(self.file_path_to_db) as conn:
|
|
254
|
+
ensure_schema(conn)
|
|
255
|
+
|
|
256
|
+
row = conn.execute(
|
|
257
|
+
"SELECT id, item FROM todos ORDER BY id LIMIT 1 OFFSET ?;",
|
|
258
|
+
(index - 1,),
|
|
259
|
+
).fetchone()
|
|
260
|
+
if row is None:
|
|
261
|
+
print("Error: Invalid todo index.")
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
todo_id, old_item = row
|
|
265
|
+
with conn:
|
|
266
|
+
conn.execute(
|
|
267
|
+
"UPDATE todos SET item = ? WHERE id = ?;",
|
|
268
|
+
(new_text, todo_id),
|
|
269
|
+
)
|
|
270
|
+
except sqlite3.Error as e:
|
|
271
|
+
print(f"Error: Failed to edit todo. ({e})")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
print(f'Edited todo: "{old_item}" to "{new_text}"')
|
|
275
|
+
self._check_and_load_todos(self.file_path_to_db)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def create_list(file_path_to_db: str = "./.todo_list.db"):
|
|
73
279
|
"""
|
|
74
280
|
Create a new todo list.
|
|
75
281
|
|
|
76
282
|
Parameters
|
|
77
283
|
----------
|
|
78
284
|
file_path_to_json : str, optional
|
|
79
|
-
The file path to the JSON file for storing todos, by default "./.todo_list.
|
|
285
|
+
The file path to the JSON file for storing todos, by default "./.todo_list.db"
|
|
80
286
|
|
|
81
287
|
Returns
|
|
82
288
|
-------
|
|
83
289
|
TodoApp
|
|
84
290
|
An instance of the TodoApp class.
|
|
85
291
|
"""
|
|
86
|
-
app = TodoApp(
|
|
292
|
+
app = TodoApp(file_path_to_db=file_path_to_db)
|
|
87
293
|
return app
|
|
88
294
|
|
|
89
295
|
|
|
@@ -98,10 +304,9 @@ def add_item_to_list(item: str, filepath: str):
|
|
|
98
304
|
filepath : str
|
|
99
305
|
The file path to the JSON file for storing todos.
|
|
100
306
|
"""
|
|
101
|
-
app = create_list(
|
|
307
|
+
app = create_list(file_path_to_db=filepath)
|
|
102
308
|
app.add_todo(item)
|
|
103
309
|
app.list_todos()
|
|
104
|
-
app.write_todos()
|
|
105
310
|
|
|
106
311
|
|
|
107
312
|
def list_items_on_list(filepath: str):
|
|
@@ -113,7 +318,7 @@ def list_items_on_list(filepath: str):
|
|
|
113
318
|
filepath : str
|
|
114
319
|
The file path to the JSON file for storing todos.
|
|
115
320
|
"""
|
|
116
|
-
app = create_list(
|
|
321
|
+
app = create_list(file_path_to_db=filepath)
|
|
117
322
|
app.list_todos()
|
|
118
323
|
|
|
119
324
|
|
|
@@ -128,10 +333,9 @@ def remove_item_from_list(index: int, filepath: str):
|
|
|
128
333
|
filepath : str
|
|
129
334
|
The file path to the JSON file for storing todos.
|
|
130
335
|
"""
|
|
131
|
-
app = create_list(
|
|
336
|
+
app = create_list(file_path_to_db=filepath)
|
|
132
337
|
app.remove_todo(index)
|
|
133
338
|
app.list_todos()
|
|
134
|
-
app.write_todos()
|
|
135
339
|
|
|
136
340
|
|
|
137
341
|
def clear_list_of_items(filepath: str):
|
|
@@ -143,28 +347,37 @@ def clear_list_of_items(filepath: str):
|
|
|
143
347
|
filepath : str
|
|
144
348
|
The file path to the JSON file for storing todos.
|
|
145
349
|
"""
|
|
146
|
-
app = create_list(
|
|
147
|
-
app.
|
|
148
|
-
|
|
149
|
-
|
|
350
|
+
app = create_list(file_path_to_db=filepath)
|
|
351
|
+
app.clear_all()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def mark_item_as_done(index: int, filepath: str):
|
|
355
|
+
app = create_list(file_path_to_db=filepath)
|
|
356
|
+
app.mark_as_done(index)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def mark_item_as_not_done(index: int, filepath: str):
|
|
360
|
+
app = create_list(file_path_to_db=filepath)
|
|
361
|
+
app.mark_as_not_done(index)
|
|
150
362
|
|
|
151
363
|
|
|
152
|
-
def cli_menu(filepath="./.todo_list.
|
|
364
|
+
def cli_menu(filepath="./.todo_list.db"):
|
|
153
365
|
"""
|
|
154
366
|
Display the command-line interface menu for the todo list.
|
|
155
367
|
|
|
156
368
|
Parameters
|
|
157
369
|
----------
|
|
158
370
|
filepath : str, optional
|
|
159
|
-
The file path to the JSON file for storing todos, by default "./.todo_list.
|
|
371
|
+
The file path to the JSON file for storing todos, by default "./.todo_list.db"
|
|
160
372
|
"""
|
|
161
|
-
app = create_list(
|
|
373
|
+
app = create_list(file_path_to_db=filepath)
|
|
162
374
|
while True:
|
|
163
375
|
action = questionary.select(
|
|
164
376
|
"What would you like to do?",
|
|
165
377
|
choices=[
|
|
166
378
|
"Add todo",
|
|
167
379
|
"List todos",
|
|
380
|
+
"Update todo status",
|
|
168
381
|
"Remove todo",
|
|
169
382
|
"Clear all todos",
|
|
170
383
|
"Exit",
|
|
@@ -174,9 +387,33 @@ def cli_menu(filepath="./.todo_list.json"):
|
|
|
174
387
|
if action == "Add todo":
|
|
175
388
|
item = questionary.text("Enter the todo item:").ask()
|
|
176
389
|
app.add_todo(item)
|
|
177
|
-
app.write_todos()
|
|
178
390
|
elif action == "List todos":
|
|
179
391
|
app.list_todos()
|
|
392
|
+
elif action == "Update todo status":
|
|
393
|
+
if not app.todos:
|
|
394
|
+
print("No todos to update.")
|
|
395
|
+
continue
|
|
396
|
+
todo_choice = questionary.select(
|
|
397
|
+
"Select the todo to update:",
|
|
398
|
+
choices=["<Back>"] + app.todos,
|
|
399
|
+
).ask()
|
|
400
|
+
|
|
401
|
+
if todo_choice == "<Back>":
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
todo_index = app.todos.index(todo_choice) + 1
|
|
405
|
+
status_choice = questionary.select(
|
|
406
|
+
"Mark as:",
|
|
407
|
+
choices=["Done", "Not Done", "<Back>"],
|
|
408
|
+
).ask()
|
|
409
|
+
|
|
410
|
+
if status_choice == "<Back>":
|
|
411
|
+
continue
|
|
412
|
+
elif status_choice == "Done":
|
|
413
|
+
app.mark_as_done(todo_index)
|
|
414
|
+
elif status_choice == "Not Done":
|
|
415
|
+
app.mark_as_not_done(todo_index)
|
|
416
|
+
app.list_todos()
|
|
180
417
|
elif action == "Remove todo":
|
|
181
418
|
if not app.todos:
|
|
182
419
|
print("No todos to remove.")
|
|
@@ -191,24 +428,13 @@ def cli_menu(filepath="./.todo_list.json"):
|
|
|
191
428
|
|
|
192
429
|
todo_to_remove = app.todos.index(todo_choice) + 1
|
|
193
430
|
app.remove_todo(todo_to_remove)
|
|
194
|
-
app.write_todos()
|
|
195
431
|
|
|
196
432
|
elif action == "Clear all todos":
|
|
197
433
|
confirm = questionary.confirm(
|
|
198
434
|
"Are you sure you want to clear all todos?"
|
|
199
435
|
).ask()
|
|
200
436
|
if confirm:
|
|
201
|
-
app.
|
|
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()
|
|
437
|
+
app.clear_all()
|
|
212
438
|
elif action == "Exit":
|
|
213
439
|
break
|
|
214
440
|
else:
|
|
@@ -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.
|
|
3
|
+
Version: 0.2.0
|
|
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"
|
|
@@ -15,6 +16,27 @@ Requires-Dist: bump-my-version; extra == "dev"
|
|
|
15
16
|
|
|
16
17
|
A command line to do list with interactive menu
|
|
17
18
|
|
|
19
|
+
## What is`cli-todo-jd`?
|
|
20
|
+
|
|
21
|
+
This is a command line interface todo list. Once installed, there are two ways to interact
|
|
22
|
+
with the list.
|
|
23
|
+
|
|
24
|
+
### `todo_menu`
|
|
25
|
+
|
|
26
|
+
Once installed use `todo_menu` to launch into the interactive menu. From here you can add,
|
|
27
|
+
remove, list, or clear your todo list. Items in your list are stored (by default) as
|
|
28
|
+
`.todo_list.json`. The menu does also support optional filepaths using `-f` or `--filepath`.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### interacting with todo list without menu
|
|
32
|
+
|
|
33
|
+
Alternately you can interact directly using the following commands (`--filepath can be substituted for -f`)
|
|
34
|
+
|
|
35
|
+
- `todo_add text --filepath optional_path_to_json` used to add an item to your list
|
|
36
|
+
- `todo_remove index --filepath optional_path_to_json` used to remove item number `index`
|
|
37
|
+
- `todo_list --filepath optional_path_to_json` used to view list
|
|
38
|
+
- `todo_clear --filepath optional_path_to_json` used to clear list (prompts y/n to confirm)
|
|
39
|
+
|
|
18
40
|
## Getting started
|
|
19
41
|
|
|
20
42
|
To start using this project, first make sure your system meets its
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
cli_todo_jd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
cli_todo_jd/cli_entry.py,sha256=sKp-aIWVldH8DiXv22bokjlIiJcAZshKHdLEqjM09HA,2877
|
|
3
|
+
cli_todo_jd/main.py,sha256=AN22ojW0iIXArJQmtEjQ1jSdAb7Z-xr8i4NIEE4xAQ0,13965
|
|
4
|
+
cli_todo_jd/storage/__init__.py,sha256=u0jMfDuUIEy9mor1dVeIiAE0Cap6FL77tW9GlsyskYU,210
|
|
5
|
+
cli_todo_jd/storage/migrate.py,sha256=Ij_0OwTvibow79KVilf2O2nfMuohj0raarVV7OcjwbY,3063
|
|
6
|
+
cli_todo_jd/storage/schema.py,sha256=r5BTtcRn8J72_b8NJlryYnd_aiuy0y00eX01QavKE98,1824
|
|
7
|
+
cli_todo_jd-0.2.0.dist-info/METADATA,sha256=rra5GQZYrK8xCKX3pUaW0wnmw_pujlKR8bpOIVH-a1U,2982
|
|
8
|
+
cli_todo_jd-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
9
|
+
cli_todo_jd-0.2.0.dist-info/entry_points.txt,sha256=BIfrMKcC340A79aXHg34nnhiDsXh7c9hPck1k-Rb28c,95
|
|
10
|
+
cli_todo_jd-0.2.0.dist-info/top_level.txt,sha256=hOnYr7w1JdQs6MlD1Uzjt24Ca8nvriOWNNq6NaqgHqM,12
|
|
11
|
+
cli_todo_jd-0.2.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
cli_todo_jd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
cli_todo_jd/cli_entry.py,sha256=hchk50z0fLwJXp0EtJnOQJnL_N3epapri3oRfHubHW4,1912
|
|
3
|
-
cli_todo_jd/main.py,sha256=PfzsSz6whW8BMQ79PoCRvJO5oBN7qhpDFut_HtgjJns,5877
|
|
4
|
-
cli_todo_jd-0.1.0.dist-info/METADATA,sha256=6arB2OpzkroM2RRWft3eYyuJKsXA0s1VRAuU8HmySN0,2044
|
|
5
|
-
cli_todo_jd-0.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
6
|
-
cli_todo_jd-0.1.0.dist-info/entry_points.txt,sha256=pt-uqvxVlLV4OWzK-fnEG9O5TtVB_aEwO1QEDYAhx5I,237
|
|
7
|
-
cli_todo_jd-0.1.0.dist-info/top_level.txt,sha256=hOnYr7w1JdQs6MlD1Uzjt24Ca8nvriOWNNq6NaqgHqM,12
|
|
8
|
-
cli_todo_jd-0.1.0.dist-info/RECORD,,
|
|
File without changes
|