easydone 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.
- easydone/__init__.py +8 -0
- easydone/__main__.py +25 -0
- easydone/cli.py +155 -0
- easydone/format.py +164 -0
- easydone/logic.py +94 -0
- easydone/storage.py +178 -0
- easydone-0.2.0.dist-info/METADATA +270 -0
- easydone-0.2.0.dist-info/RECORD +12 -0
- easydone-0.2.0.dist-info/WHEEL +5 -0
- easydone-0.2.0.dist-info/entry_points.txt +2 -0
- easydone-0.2.0.dist-info/licenses/LICENSE +21 -0
- easydone-0.2.0.dist-info/top_level.txt +1 -0
easydone/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# use the distribution name from pyproject 'project.name' (make sure they match)
|
|
5
|
+
__version__ = version("easydone-task-tracker")
|
|
6
|
+
except PackageNotFoundError:
|
|
7
|
+
# Package isn't installed (dev environment). Optional fallback:
|
|
8
|
+
__version__ = "0.0.0+dev"
|
easydone/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .cli import Parser
|
|
2
|
+
from .logic import TasksManager
|
|
3
|
+
from .storage import JSONHandler
|
|
4
|
+
from .format import describe_load_result, report_backup
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
# JSONHandler instance for load/save in .json.
|
|
8
|
+
handler = JSONHandler()
|
|
9
|
+
load_result = handler.load()
|
|
10
|
+
describe_load_result(load_result)
|
|
11
|
+
|
|
12
|
+
# TasksManager instance based on tasks loaded
|
|
13
|
+
manager = TasksManager(tasks_from_file=load_result.tasks)
|
|
14
|
+
|
|
15
|
+
# Parser instance for cli interface and argumments
|
|
16
|
+
cli = Parser(manager)
|
|
17
|
+
mutated = cli.start_parsing()
|
|
18
|
+
|
|
19
|
+
# save tasks before exiting only if mutated status
|
|
20
|
+
if mutated:
|
|
21
|
+
backup_result = handler.save(manager.tasks)
|
|
22
|
+
report_backup(backup_result)
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
easydone/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from argparse import ArgumentParser, Namespace, SUPPRESS
|
|
2
|
+
from .logic import TasksManager
|
|
3
|
+
from .format import print_table
|
|
4
|
+
from . import __version__
|
|
5
|
+
|
|
6
|
+
SUPPORTED_STATUS = ["not-done", "done", "in-progress"]
|
|
7
|
+
SUPPORTED_PRIORITIES = ["low", "normal", "high", "urgent"]
|
|
8
|
+
|
|
9
|
+
class Parser():
|
|
10
|
+
""" A class to manage all the argument parser and command features. """
|
|
11
|
+
|
|
12
|
+
def __init__(self, manager: TasksManager) -> None:
|
|
13
|
+
"""Initialize the CLIApp attributes"""
|
|
14
|
+
if not isinstance (manager, TasksManager):
|
|
15
|
+
raise TypeError()
|
|
16
|
+
|
|
17
|
+
self.tasks_manager = manager
|
|
18
|
+
self.build_parser()
|
|
19
|
+
|
|
20
|
+
def build_parser(self) -> None:
|
|
21
|
+
""" Create the main parser and sub-commands. """
|
|
22
|
+
description = "Simple and powerfull task tracker. Helps you manage your to-do list directly from the terminal."
|
|
23
|
+
self.main_parser = ArgumentParser(
|
|
24
|
+
prog = "EasyDone", description = description, allow_abbrev = False,
|
|
25
|
+
epilog = "Thanks for using %(prog)s! :)\nAll feedback is appreciated.", )
|
|
26
|
+
|
|
27
|
+
# Show the current version running.
|
|
28
|
+
self.main_parser.add_argument(
|
|
29
|
+
'-v', '--version', help='Display app version.', action="version",
|
|
30
|
+
version="%(prog)s " + __version__)
|
|
31
|
+
|
|
32
|
+
# Sub-commands parser
|
|
33
|
+
sub_pars = self.main_parser.add_subparsers(title="actions")
|
|
34
|
+
|
|
35
|
+
# 'NEW' command
|
|
36
|
+
# ====================
|
|
37
|
+
|
|
38
|
+
# Create parser for 'new' command
|
|
39
|
+
new_pars = sub_pars.add_parser("new", help="Create a new task")
|
|
40
|
+
new_pars.add_argument(
|
|
41
|
+
"description", type=str,
|
|
42
|
+
help="Description of the task to be added.")
|
|
43
|
+
|
|
44
|
+
# optional flag for indicating initial status
|
|
45
|
+
new_pars.add_argument(
|
|
46
|
+
"-s", "--status", type=str,
|
|
47
|
+
help="Optional initial status: defaults to 'not-done'.",
|
|
48
|
+
choices=SUPPORTED_STATUS, default=SUPPORTED_STATUS[0])
|
|
49
|
+
|
|
50
|
+
# optional flag for indicating initial priority
|
|
51
|
+
new_pars.add_argument(
|
|
52
|
+
"-p", "--priority", type=str,
|
|
53
|
+
help="Optional priority: defaults to 'low'.",
|
|
54
|
+
choices=SUPPORTED_PRIORITIES, default=SUPPORTED_PRIORITIES[0])
|
|
55
|
+
|
|
56
|
+
# assign 'new' method from TasksManager to func attr of the Namespace returned by parser
|
|
57
|
+
new_pars.set_defaults(func=self.tasks_manager.new)
|
|
58
|
+
|
|
59
|
+
# 'UPDATE' command
|
|
60
|
+
# ====================
|
|
61
|
+
update_pars = sub_pars.add_parser("update", help="Update a task.", argument_default=SUPPRESS)
|
|
62
|
+
|
|
63
|
+
update_pars.add_argument(
|
|
64
|
+
"id", type=str,
|
|
65
|
+
help="ID of the task to be updated.")
|
|
66
|
+
|
|
67
|
+
update_pars.add_argument(
|
|
68
|
+
"-d", "--description", metavar="new-description", type=str,
|
|
69
|
+
help="Update description.")
|
|
70
|
+
|
|
71
|
+
update_pars.add_argument(
|
|
72
|
+
"-p", "--priority", metavar="new-priority",
|
|
73
|
+
type=str, help="Update priority.")
|
|
74
|
+
|
|
75
|
+
update_pars.set_defaults(func=self.tasks_manager.update)
|
|
76
|
+
|
|
77
|
+
# 'MARK' command
|
|
78
|
+
# ====================
|
|
79
|
+
mark_pars = sub_pars.add_parser("mark", help="Mark a task with a new status.")
|
|
80
|
+
|
|
81
|
+
mark_pars.add_argument(
|
|
82
|
+
"id", type=str,
|
|
83
|
+
help="ID of the task to be marked.")
|
|
84
|
+
|
|
85
|
+
mark_pars.add_argument(
|
|
86
|
+
"new_status", type=str,
|
|
87
|
+
help="The new status for the task.",
|
|
88
|
+
choices=SUPPORTED_STATUS)
|
|
89
|
+
|
|
90
|
+
mark_pars.set_defaults(func=self.tasks_manager.mark)
|
|
91
|
+
|
|
92
|
+
# 'DELETE' command
|
|
93
|
+
# ====================
|
|
94
|
+
del_pars = sub_pars.add_parser("delete", help="Deletes a task.")
|
|
95
|
+
|
|
96
|
+
del_pars.add_argument(
|
|
97
|
+
"ids", type=str, nargs='+', metavar='id',
|
|
98
|
+
help="IDs of the tasks to be deleted.")
|
|
99
|
+
|
|
100
|
+
del_pars.add_argument(
|
|
101
|
+
"-f", "--forced", action="store_true",
|
|
102
|
+
help="If not used, the user will be prompted for confirmation.")
|
|
103
|
+
|
|
104
|
+
del_pars.set_defaults(func=self.tasks_manager.delete)
|
|
105
|
+
|
|
106
|
+
# 'LIST' command
|
|
107
|
+
# ====================
|
|
108
|
+
list_pars = sub_pars.add_parser("list", help="List all tasks.")
|
|
109
|
+
|
|
110
|
+
list_pars.add_argument(
|
|
111
|
+
"-s", "--status", type=str,
|
|
112
|
+
help="To list all tasks with a given status.",
|
|
113
|
+
choices=SUPPORTED_STATUS, default=None)
|
|
114
|
+
|
|
115
|
+
list_pars.add_argument(
|
|
116
|
+
"-p", "--priority", type=str,
|
|
117
|
+
help="To list all tasks with a given priority.",
|
|
118
|
+
choices=SUPPORTED_PRIORITIES, default=None)
|
|
119
|
+
|
|
120
|
+
list_pars.add_argument(
|
|
121
|
+
"--no-dates", action="store_true",
|
|
122
|
+
help="Not ouput dates.",)
|
|
123
|
+
|
|
124
|
+
list_pars.set_defaults(func=self.tasks_manager.list)
|
|
125
|
+
|
|
126
|
+
def start_parsing(self) -> bool:
|
|
127
|
+
""" Parses the arguments passed. Returns true if some command mutated state of any task. """
|
|
128
|
+
args: Namespace
|
|
129
|
+
try:
|
|
130
|
+
args = self.main_parser.parse_args()
|
|
131
|
+
except ValueError as exc:
|
|
132
|
+
self.main_parser.error(str(exc))
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
if getattr(args, 'func', None) == self.tasks_manager.list:
|
|
136
|
+
filtered_ids = args.func(args)
|
|
137
|
+
print_table(self.tasks_manager.tasks, filtered_ids, no_dates=args.no_dates)
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
if getattr(args, 'func', None) == self.tasks_manager.update:
|
|
141
|
+
has_update_target = hasattr(args, 'description') or hasattr(args, 'priority')
|
|
142
|
+
if not has_update_target:
|
|
143
|
+
self.main_parser.error("the update command requires at least one field change: --description or --priority")
|
|
144
|
+
|
|
145
|
+
if not hasattr(args, 'func'):
|
|
146
|
+
# in case user invokes the program without arguments like:
|
|
147
|
+
# >>> easydone
|
|
148
|
+
self.main_parser.print_help()
|
|
149
|
+
return False
|
|
150
|
+
else:
|
|
151
|
+
try:
|
|
152
|
+
args.func(args)
|
|
153
|
+
return True
|
|
154
|
+
except (ValueError, KeyError, AttributeError, TypeError) as exc:
|
|
155
|
+
self.main_parser.error(str(exc))
|
easydone/format.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Output formatting helpers.
|
|
2
|
+
|
|
3
|
+
This module centralizes all user-facing presentation logic so the CLI
|
|
4
|
+
parsing and task-management logic remain separate. The primary function
|
|
5
|
+
`print_table` prints a table of tasks. It attempts to use Rich for nicely
|
|
6
|
+
styled tables and colors; when Rich is not available it falls back to a
|
|
7
|
+
plain-text table so the application remains dependency-light.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List, Any
|
|
11
|
+
from .storage import LoadingResult, LoadStatus, CURRENT_SCHEMA_VERSION
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
# Import the specific Rich helpers we need. If they are not available,
|
|
16
|
+
# the import will raise and the code will fall back to plain text.
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
RICH_AVAILABLE = True
|
|
21
|
+
except Exception:
|
|
22
|
+
RICH_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _plain_print(tasks: Dict[str, dict],
|
|
26
|
+
ids: List[str],
|
|
27
|
+
no_dates: bool = False
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Plain-text fallback used when Rich is unavailable."""
|
|
30
|
+
print("====================================")
|
|
31
|
+
print("EasyDone: Task-Tracker")
|
|
32
|
+
print("====================================")
|
|
33
|
+
for task_id in ids:
|
|
34
|
+
# Use .get() so older or partially missing task dictionaries still print.
|
|
35
|
+
task = tasks[task_id]
|
|
36
|
+
desc = task.get('description', 'unknown')
|
|
37
|
+
prior = task.get('priority', 'unknown')
|
|
38
|
+
stat = task.get('status', 'unknown')
|
|
39
|
+
create = '[' + str(task.get('created-at', 'unknown')) + ']' if not no_dates else ""
|
|
40
|
+
update = '[' + str(task.get('updated-at', 'unknown')) + ']' if not no_dates else ""
|
|
41
|
+
print(f"ID: {task_id} \"{desc}\" [{prior}] [{stat}] {create} {update}")
|
|
42
|
+
print("====================================")
|
|
43
|
+
|
|
44
|
+
def print_table(tasks: Dict[str, dict],
|
|
45
|
+
ids: List[str],
|
|
46
|
+
no_dates: bool = False
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Print a tasks table using Rich when available, otherwise use plain text.
|
|
49
|
+
|
|
50
|
+
Columns: ID, Description, Priority, Status, Created, Updated.
|
|
51
|
+
The status and priority values are highlighted in color when Rich is present.
|
|
52
|
+
"""
|
|
53
|
+
if not ids:
|
|
54
|
+
print("No tasks to show.")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if not RICH_AVAILABLE:
|
|
58
|
+
_plain_print(tasks, ids, no_dates=no_dates)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Build a Rich table in the presentation layer. This keeps formatting and
|
|
62
|
+
# colors separated from task logic and CLI argument parsing.
|
|
63
|
+
console = Console() # type: ignore
|
|
64
|
+
table = Table(show_header=True, header_style="bold magenta") # type: ignore
|
|
65
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
66
|
+
table.add_column("Description")
|
|
67
|
+
table.add_column("Priority", no_wrap=True)
|
|
68
|
+
table.add_column("Status", no_wrap=True)
|
|
69
|
+
if not no_dates:
|
|
70
|
+
table.add_column("Created", no_wrap=True)
|
|
71
|
+
table.add_column("Updated", no_wrap=True)
|
|
72
|
+
|
|
73
|
+
priority_styles = {
|
|
74
|
+
"low": "dim",
|
|
75
|
+
"normal": "",
|
|
76
|
+
"high": "bold yellow",
|
|
77
|
+
"urgent": "bold red",
|
|
78
|
+
}
|
|
79
|
+
status_styles = {
|
|
80
|
+
"not-done": "yellow",
|
|
81
|
+
"in-progress": "cyan",
|
|
82
|
+
"done": "green",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for task_id in ids:
|
|
86
|
+
task = tasks[task_id]
|
|
87
|
+
desc = task.get('description', 'unknown')
|
|
88
|
+
prior = task.get('priority', 'unknown')
|
|
89
|
+
stat = task.get('status', 'unknown')
|
|
90
|
+
create: str
|
|
91
|
+
update: str
|
|
92
|
+
if not no_dates:
|
|
93
|
+
create = task.get('created-at', 'unknown')
|
|
94
|
+
update = task.get('updated-at', 'unknown')
|
|
95
|
+
update = '-' if update is None else update
|
|
96
|
+
table.add_row(
|
|
97
|
+
task_id,
|
|
98
|
+
Text(desc, overflow='ellipsis'), # type: ignore
|
|
99
|
+
Text(prior, style=priority_styles.get(prior, "")), # type: ignore
|
|
100
|
+
Text(stat, style=status_styles.get(stat, "")), # type: ignore
|
|
101
|
+
create,
|
|
102
|
+
update,
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
table.add_row(
|
|
106
|
+
task_id,
|
|
107
|
+
Text(desc, overflow='ellipsis'), # type: ignore
|
|
108
|
+
Text(prior, style=priority_styles.get(prior, "")), # type: ignore
|
|
109
|
+
Text(stat, style=status_styles.get(stat, "")), # type: ignore
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
console.print(table)
|
|
113
|
+
|
|
114
|
+
def describe_load_result(result: LoadingResult) -> None:
|
|
115
|
+
msg = ""
|
|
116
|
+
if result.status is LoadStatus.MISSING:
|
|
117
|
+
msg = f"{result.file_path} does not exist. Starting with an empty task list."
|
|
118
|
+
|
|
119
|
+
if result.status is LoadStatus.CORRUPTED:
|
|
120
|
+
msg = f"Warning: {result.file_path} is unreadable or malformed."
|
|
121
|
+
if result.backup_path:
|
|
122
|
+
msg += f" A copy was saved to {result.backup_path} for review."
|
|
123
|
+
else:
|
|
124
|
+
msg += f" Unable to save corrupted file: ({result.backup_exception})"
|
|
125
|
+
|
|
126
|
+
lines = []
|
|
127
|
+
if result.schema_mismatch:
|
|
128
|
+
lines.append(
|
|
129
|
+
f"Found schema version {result.found_schema_version} "
|
|
130
|
+
f"(app expects {CURRENT_SCHEMA_VERSION})"
|
|
131
|
+
)
|
|
132
|
+
if result.app_mismatch:
|
|
133
|
+
lines.append(
|
|
134
|
+
f"written by EasyDone {result.found_app_version} "
|
|
135
|
+
f"(running Easydone {__version__})"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if lines:
|
|
139
|
+
msg = "Warning: " + " and ".join(lines) + "."
|
|
140
|
+
|
|
141
|
+
if msg:
|
|
142
|
+
style = 'yellow'
|
|
143
|
+
else:
|
|
144
|
+
style = 'green'
|
|
145
|
+
msg = "Tasks loaded successfully."
|
|
146
|
+
|
|
147
|
+
if RICH_AVAILABLE:
|
|
148
|
+
console = Console() # type: ignore
|
|
149
|
+
console.print(Text(msg, style=style)) # type: ignore
|
|
150
|
+
else:
|
|
151
|
+
print(msg)
|
|
152
|
+
|
|
153
|
+
def report_backup(backup_result: dict[str, Any]):
|
|
154
|
+
if backup_result['backup_path']:
|
|
155
|
+
text = "Backup succesfully done"
|
|
156
|
+
style = 'green'
|
|
157
|
+
else:
|
|
158
|
+
text = f"Warning: Couldn't backup ({backup_result['backup_exception']})"
|
|
159
|
+
style = 'yellow'
|
|
160
|
+
if RICH_AVAILABLE:
|
|
161
|
+
console = Console() # type: ignore
|
|
162
|
+
console.print(Text(text=text, style=style)) # type: ignore
|
|
163
|
+
else:
|
|
164
|
+
print(text)
|
easydone/logic.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from argparse import Namespace
|
|
2
|
+
from random import randint
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
class TasksManager():
|
|
6
|
+
""" A class to manage all tasks logic. """
|
|
7
|
+
def __init__(self, tasks_from_file:dict[str, dict]):
|
|
8
|
+
self.tasks = tasks_from_file # internal dict for tracking tasks
|
|
9
|
+
self.ID_SIZE = 3 # number of digits to generate for an ID
|
|
10
|
+
|
|
11
|
+
def new(self, args: Namespace):
|
|
12
|
+
""" Create a new task. """
|
|
13
|
+
self.tasks[self.task_id()] = {
|
|
14
|
+
"description": args.description,
|
|
15
|
+
"status": args.status,
|
|
16
|
+
"priority": args.priority,
|
|
17
|
+
"created-at": str(datetime.now()).split(" ")[0],
|
|
18
|
+
"updated-at": None
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
def update(self, args: Namespace):
|
|
22
|
+
""" Updates a task. """
|
|
23
|
+
if args.id not in self.tasks:
|
|
24
|
+
raise KeyError("Unexistent task")
|
|
25
|
+
|
|
26
|
+
task = self.tasks[args.id]
|
|
27
|
+
if hasattr(args, 'description') and args.description == task['description']:
|
|
28
|
+
raise ValueError("New description must be different from the current description.")
|
|
29
|
+
|
|
30
|
+
if hasattr(args, 'priority') and args.priority == task['priority']:
|
|
31
|
+
raise ValueError("New priority must be different from the current priority.")
|
|
32
|
+
|
|
33
|
+
if hasattr(args, 'description'):
|
|
34
|
+
task['description'] = args.description
|
|
35
|
+
|
|
36
|
+
if hasattr(args, 'priority'):
|
|
37
|
+
task['priority'] = args.priority
|
|
38
|
+
|
|
39
|
+
task['updated-at'] = str(datetime.now()).split(" ")[0]
|
|
40
|
+
|
|
41
|
+
def mark(self, args: Namespace):
|
|
42
|
+
"""Marking task as done, not done or in progress"""
|
|
43
|
+
if (id := args.id) not in self.tasks:
|
|
44
|
+
raise KeyError("Unexistent task")
|
|
45
|
+
|
|
46
|
+
self.tasks[args.id]["status"] = args.new_status
|
|
47
|
+
self.tasks[args.id]["updated-at"] = str(datetime.now()).split(" ")[0]
|
|
48
|
+
|
|
49
|
+
def delete(self, args: Namespace):
|
|
50
|
+
""" Deletes a list of tasks. """
|
|
51
|
+
for id in args.ids:
|
|
52
|
+
if id not in self.tasks:
|
|
53
|
+
raise KeyError(f"Unexistent task ({id})")
|
|
54
|
+
|
|
55
|
+
for id in set(args.ids):
|
|
56
|
+
if not args.forced:
|
|
57
|
+
if not yes_no(f"are you sure u want to delete the task {id}:\"{self.tasks[id]['description']}\" ?"):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
self.tasks.pop(id)
|
|
61
|
+
|
|
62
|
+
def list(self, args: Namespace) -> list[str]:
|
|
63
|
+
""" Shows all tasks. Filtered according to args.status and args.priority. """
|
|
64
|
+
ids = []
|
|
65
|
+
|
|
66
|
+
s_filt = args.status
|
|
67
|
+
p_filt = args.priority
|
|
68
|
+
for key, value in self.tasks.items():
|
|
69
|
+
# check if task passes filters
|
|
70
|
+
if (s_filt is None or value['status'] == s_filt) and (p_filt is None or value['priority'] == p_filt):
|
|
71
|
+
ids.append(key)
|
|
72
|
+
|
|
73
|
+
return ids
|
|
74
|
+
|
|
75
|
+
def task_id(self) -> str:
|
|
76
|
+
""" Returns a random id, formed by digits, the number of digits is determined by self.ID_SIZE"""
|
|
77
|
+
id = None
|
|
78
|
+
# keeps generating until gets an id that's not already present.
|
|
79
|
+
while id is None or id in self.tasks:
|
|
80
|
+
id = ""
|
|
81
|
+
for _ in range(self.ID_SIZE):
|
|
82
|
+
id += str(randint(0, 9))
|
|
83
|
+
return id
|
|
84
|
+
|
|
85
|
+
def yes_no(prompt: str) -> bool:
|
|
86
|
+
"""Asks the user a yes/no question and returns True for yes and False for no."""
|
|
87
|
+
while True:
|
|
88
|
+
response = input(prompt + " (y/n): ").strip().lower()
|
|
89
|
+
if response in ['y', 'yes']:
|
|
90
|
+
return True
|
|
91
|
+
elif response in ['n', 'no']:
|
|
92
|
+
return False
|
|
93
|
+
else:
|
|
94
|
+
print("Invalid input. Please enter 'y' or 'n'.")
|
easydone/storage.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, NamedTuple, Any
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
|
|
13
|
+
CURRENT_SCHEMA_VERSION = 1
|
|
14
|
+
|
|
15
|
+
def default_storage_path() -> Path:
|
|
16
|
+
"""Return a stable, user-scoped path for EasyDone task data.
|
|
17
|
+
|
|
18
|
+
Behavior and rationale:
|
|
19
|
+
- If the EASYDONE_DATA_FILE environment variable is set, use it. This allows CI, tests, and advanced users to redirect storage to a custom file.
|
|
20
|
+
- Otherwise choose a platform-appropriate per-user application data directory:
|
|
21
|
+
* Windows: %APPDATA% (e.g. C:/Users/<user>/AppData/Roaming)
|
|
22
|
+
* macOS: ~/Library/Application Support
|
|
23
|
+
* Linux/other: XDG_DATA_HOME or ~/.local/share as a fallback
|
|
24
|
+
- The final file is placed under <base>/easydone/tasks.json so the data is predictable and independent of the current working directory.
|
|
25
|
+
"""
|
|
26
|
+
# Allow an explicit override for testing or advanced usage. Expand ~ if present.
|
|
27
|
+
custom_path = os.environ.get("EASYDONE_DATA_FILE")
|
|
28
|
+
if custom_path:
|
|
29
|
+
return Path(custom_path).expanduser()
|
|
30
|
+
|
|
31
|
+
# Select a sensible base directory depending on the platform. Using a
|
|
32
|
+
# per-user application data directory avoids scattering data files across
|
|
33
|
+
# arbitrary working directories and follows common OS conventions.
|
|
34
|
+
if os.name == "nt":
|
|
35
|
+
# On Windows prefer APPDATA; fall back to a reasonable user path when
|
|
36
|
+
# APPDATA is not available in the environment.
|
|
37
|
+
base_dir = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
38
|
+
elif sys.platform == "darwin":
|
|
39
|
+
# macOS convention for app data
|
|
40
|
+
base_dir = Path.home() / "Library" / "Application Support"
|
|
41
|
+
else:
|
|
42
|
+
# Follow XDG Base Directory Specification when possible, otherwise use
|
|
43
|
+
# ~/.local/share as a conventional fallback for Linux and other Unix-like OSes.
|
|
44
|
+
base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
45
|
+
|
|
46
|
+
# Keep the final data file path deterministic and easy to locate.
|
|
47
|
+
return base_dir / "easydone" / "tasks.json"
|
|
48
|
+
|
|
49
|
+
class LoadStatus(Enum):
|
|
50
|
+
OK = "ok"
|
|
51
|
+
MISSING = "missing"
|
|
52
|
+
CORRUPTED = "corrupted"
|
|
53
|
+
|
|
54
|
+
class LoadingResult(NamedTuple):
|
|
55
|
+
tasks: dict[str, dict] = {}
|
|
56
|
+
status: LoadStatus = LoadStatus.OK
|
|
57
|
+
file_path: Optional[Path] = None
|
|
58
|
+
backup_path: Optional[Path] = None
|
|
59
|
+
backup_exception: Optional[Exception] = None
|
|
60
|
+
found_schema_version: Optional[int] = None
|
|
61
|
+
found_app_version: Optional[str] = None
|
|
62
|
+
schema_mismatch: bool = False
|
|
63
|
+
app_mismatch: bool = False
|
|
64
|
+
|
|
65
|
+
class JSONHandler():
|
|
66
|
+
def __init__(self, json_file: Optional[str] = None):
|
|
67
|
+
"""
|
|
68
|
+
Initialize a storage handler with a stable absolute data file path.
|
|
69
|
+
"""
|
|
70
|
+
self.app_version = __version__
|
|
71
|
+
self.json_file = Path(json_file).expanduser() if json_file else default_storage_path()
|
|
72
|
+
|
|
73
|
+
def _quarantine_path(self) -> Path:
|
|
74
|
+
""" Returns a quarantine path. """
|
|
75
|
+
file_path = self.json_file
|
|
76
|
+
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
|
|
77
|
+
quarantine_path = file_path.with_name(
|
|
78
|
+
f"{file_path.stem}.corrupted-{timestamp}{file_path.suffix}"
|
|
79
|
+
)
|
|
80
|
+
return quarantine_path
|
|
81
|
+
|
|
82
|
+
def _backup_path(self) -> Path:
|
|
83
|
+
""" Returns a backup path. """
|
|
84
|
+
file_path = self.json_file
|
|
85
|
+
backup_path = file_path.with_suffix(file_path.suffix + '.bak')
|
|
86
|
+
return backup_path
|
|
87
|
+
|
|
88
|
+
def _backup(self, quarantine=False) -> dict[str, Any]:
|
|
89
|
+
"""
|
|
90
|
+
Backups a file, returns the backup path if succeed, else returns the exception raised.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
file_path = self.json_file
|
|
94
|
+
backup_path = self._quarantine_path() if quarantine else self._backup_path()
|
|
95
|
+
shutil.copy2(file_path, backup_path)
|
|
96
|
+
return {"backup_path": backup_path, "backup_exception": None}
|
|
97
|
+
except (PermissionError, MemoryError, FileNotFoundError) as exc:
|
|
98
|
+
# remove backup file if any error happened
|
|
99
|
+
backup_path.unlink(missing_ok=True) # type: ignore
|
|
100
|
+
return {"backup_path": None, "backup_exception": exc}
|
|
101
|
+
|
|
102
|
+
def load(self) -> LoadingResult:
|
|
103
|
+
"""Load tasks from the JSON file."""
|
|
104
|
+
# load file's content into payload or handle exception
|
|
105
|
+
try:
|
|
106
|
+
with open(self.json_file, 'r', encoding='utf-8') as file:
|
|
107
|
+
payload = json.load(file)
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
return LoadingResult(
|
|
110
|
+
tasks={}, status=LoadStatus.MISSING, file_path=self.json_file
|
|
111
|
+
)
|
|
112
|
+
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
|
113
|
+
backup_result = self._backup(quarantine=True)
|
|
114
|
+
return LoadingResult(
|
|
115
|
+
tasks={}, status=LoadStatus.CORRUPTED,
|
|
116
|
+
file_path=self.json_file, **backup_result
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# unexpected format of content loaded
|
|
120
|
+
if not isinstance(payload, dict):
|
|
121
|
+
backup_result = self._backup(quarantine=True)
|
|
122
|
+
return LoadingResult(
|
|
123
|
+
tasks={}, status=LoadStatus.CORRUPTED,
|
|
124
|
+
file_path=self.json_file, **backup_result
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# try to get metadata from dict loaded
|
|
128
|
+
tasks = payload.get("tasks")
|
|
129
|
+
schema_version = payload.get("schema_version", 0)
|
|
130
|
+
app_version = payload.get("app_version", "unknown")
|
|
131
|
+
|
|
132
|
+
# unexpected format of tasks
|
|
133
|
+
if not isinstance(tasks, dict):
|
|
134
|
+
backup_result = self._backup(quarantine=True)
|
|
135
|
+
return LoadingResult(
|
|
136
|
+
tasks={}, status=LoadStatus.CORRUPTED,
|
|
137
|
+
file_path=self.json_file, **backup_result
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return LoadingResult(
|
|
141
|
+
tasks=tasks, status=LoadStatus.OK, file_path=self.json_file,
|
|
142
|
+
found_schema_version = schema_version,
|
|
143
|
+
found_app_version = app_version,
|
|
144
|
+
schema_mismatch = schema_version != CURRENT_SCHEMA_VERSION,
|
|
145
|
+
app_mismatch = app_version != self.app_version
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def save(self, tasks: dict[str, dict]) -> dict[str, Any]:
|
|
149
|
+
"""Persist tasks with metadata so upgrades can be reviewed safely. Returns a dict containing backup results."""
|
|
150
|
+
if not isinstance(tasks, dict):
|
|
151
|
+
raise TypeError("tasks must be a dictionary of task records")
|
|
152
|
+
|
|
153
|
+
self.json_file.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
payload = {
|
|
156
|
+
"schema_version": CURRENT_SCHEMA_VERSION,
|
|
157
|
+
"app_version": self.app_version,
|
|
158
|
+
"saved_at": datetime.now().date().isoformat(),
|
|
159
|
+
"tasks": tasks,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Keep the last known-good file before touching it.
|
|
163
|
+
backup_result = self._backup()
|
|
164
|
+
|
|
165
|
+
# Write to a temp file in the SAME directory (matters: os.replace across
|
|
166
|
+
# filesystems isn't atomic), then swap it in as one step.
|
|
167
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
168
|
+
dir=self.json_file.parent, prefix=".tasks-", suffix=".tmp"
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:
|
|
172
|
+
json.dump(payload, tmp_file, indent=4)
|
|
173
|
+
os.replace(tmp_path, self.json_file) # atomic on POSIX AND Windows
|
|
174
|
+
except Exception:
|
|
175
|
+
os.unlink(tmp_path) # don't leave stray .tmp files on failure
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
return backup_result
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easydone
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A CLI app made with python for easy tasks management.
|
|
5
|
+
Author-email: Pedro Alberto Rosquete Ares <rosquetearespedro06@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Pedro Alberto Rosquete Ares
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Documentation, https://github.com/prares-dev/easydone.git
|
|
29
|
+
Project-URL: Homepage, https://github.com/prares-dev/easydone.git
|
|
30
|
+
Project-URL: Repository, https://github.com/prares-dev/easydone.git
|
|
31
|
+
Requires-Python: >=3.9
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Requires-Dist: rich>=13.0
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# easydone - Task Tracker App
|
|
40
|
+
|
|
41
|
+
> A focused command-line task manager for turning a messy to-do list into a clear next action.
|
|
42
|
+
|
|
43
|
+
easydone is a lightweight Python CLI for creating, updating, completing, deleting, and filtering tasks directly from your terminal. Tasks are saved as readable JSON, and the storage layer now includes **automatic backups, corruption quarantine, and atomic writes** to keep your data safe.
|
|
44
|
+
|
|
45
|
+
## Why easydone?
|
|
46
|
+
|
|
47
|
+
- Fast terminal-first workflow
|
|
48
|
+
- Statuses for work in motion: `not-done`, `in-progress`, and `done`
|
|
49
|
+
- Four priority levels: `low`, `normal`, `high`, and `urgent`
|
|
50
|
+
- Filter tasks by status, priority, or both
|
|
51
|
+
- Human-readable JSON storage with no database setup
|
|
52
|
+
- **Bulletproof data handling**: atomic writes, automatic `.bak` backups, and quarantine of corrupted files
|
|
53
|
+
- Pretty terminal output with [Rich](https://github.com/Textualize/rich) (falls back to plain text if unavailable)
|
|
54
|
+
|
|
55
|
+
## Project Structure
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
easydone/
|
|
59
|
+
├── easydone/
|
|
60
|
+
│ ├── __init__.py # Package metadata (version, etc.)
|
|
61
|
+
│ ├── __main__.py # Application entry point
|
|
62
|
+
│ ├── cli.py # Argument parser and command dispatch
|
|
63
|
+
│ ├── logic.py # Task-management operations
|
|
64
|
+
│ ├── storage.py # JSON loading/saving with backup & quarantine
|
|
65
|
+
│ └── format.py # Output formatting (Rich / plain)
|
|
66
|
+
├── tests/
|
|
67
|
+
│ ├── test_cli.py
|
|
68
|
+
│ ├── test_logic.py
|
|
69
|
+
│ ├── test_storage.py
|
|
70
|
+
│ └── test_format.py
|
|
71
|
+
├── LICENSE
|
|
72
|
+
├── pyproject.toml # Packaging and pytest configuration
|
|
73
|
+
└── README.md
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
### Requirements
|
|
79
|
+
|
|
80
|
+
- Python 3.9 or newer
|
|
81
|
+
- Windows PowerShell, macOS, or Linux terminal
|
|
82
|
+
|
|
83
|
+
From the project root, create a virtual environment and install `easydone` in editable mode:
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
py -m venv .venv
|
|
87
|
+
.\.venv\Scripts\Activate.ps1
|
|
88
|
+
py -m pip install -e .
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
On macOS or Linux:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python3 -m venv .venv
|
|
95
|
+
source .venv/bin/activate
|
|
96
|
+
python -m pip install -e .
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or install directly from PyPI:
|
|
100
|
+
|
|
101
|
+
```powershell
|
|
102
|
+
py -m pip install easydone
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
You can now run the application:
|
|
106
|
+
|
|
107
|
+
```shell
|
|
108
|
+
easydone
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
To see all available commands:
|
|
112
|
+
|
|
113
|
+
```shell
|
|
114
|
+
easydone --help
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Everyday Workflow
|
|
118
|
+
|
|
119
|
+
Create a task:
|
|
120
|
+
|
|
121
|
+
```shell
|
|
122
|
+
easydone new "Read a book"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Create a task with a status and priority:
|
|
126
|
+
|
|
127
|
+
```shell
|
|
128
|
+
easydone new "Finish project report" --status in-progress --priority high
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
List everything:
|
|
132
|
+
|
|
133
|
+
```shell
|
|
134
|
+
easydone list
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Focus on urgent unfinished work:
|
|
138
|
+
|
|
139
|
+
```shell
|
|
140
|
+
easydone list --status not-done --priority urgent
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Mark a task as complete:
|
|
144
|
+
|
|
145
|
+
```shell
|
|
146
|
+
easydone mark 123 done
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Command Reference
|
|
150
|
+
|
|
151
|
+
### `new`
|
|
152
|
+
|
|
153
|
+
Create a task. The description is required.
|
|
154
|
+
|
|
155
|
+
```shell
|
|
156
|
+
easydone new DESCRIPTION [--status STATUS] [--priority PRIORITY]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Options:
|
|
160
|
+
|
|
161
|
+
- `-s`, `--status`: `not-done`, `done`, or `in-progress`; defaults to `not-done`
|
|
162
|
+
- `-p`, `--priority`: `low`, `normal`, `high`, or `urgent`; defaults to `low`
|
|
163
|
+
|
|
164
|
+
### `update`
|
|
165
|
+
|
|
166
|
+
Change the description and/or priority of an existing task.
|
|
167
|
+
The `--priority` option now validates against the four allowed values.
|
|
168
|
+
|
|
169
|
+
```shell
|
|
170
|
+
easydone update TASK_ID [--description NEW_DESCRIPTION] [--priority NEW_PRIORITY]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Examples:
|
|
174
|
+
|
|
175
|
+
```shell
|
|
176
|
+
easydone update 123 --description "Read a novel"
|
|
177
|
+
easydone update 123 --priority high
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `mark`
|
|
181
|
+
|
|
182
|
+
Change the status of an existing task.
|
|
183
|
+
|
|
184
|
+
```shell
|
|
185
|
+
easydone mark TASK_ID new-status
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### `delete`
|
|
189
|
+
|
|
190
|
+
Delete one or more existing tasks. easydone tasks for confirmation for each ID unless `-f` or `--forced` is used.
|
|
191
|
+
|
|
192
|
+
```shell
|
|
193
|
+
easydone delete TASK_ID [TASK_ID ...]
|
|
194
|
+
easydone delete TASK_ID [TASK_ID ...] --forced
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
If you supply multiple IDs, all of them are validated before any deletion occurs. If any ID is invalid, the entire operation is aborted and no tasks are removed.
|
|
198
|
+
|
|
199
|
+
### `list`
|
|
200
|
+
|
|
201
|
+
List all tasks or filter them by status and priority. You can omit dates with the `--no-dates` option.
|
|
202
|
+
|
|
203
|
+
```shell
|
|
204
|
+
easydone list [--status STATUS] [--priority PRIORITY] [--no-dates]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
When both filters are supplied, a task must match both of them.
|
|
208
|
+
|
|
209
|
+
## Data Storage
|
|
210
|
+
|
|
211
|
+
`easydone` stores task data in a user‑scoped application directory so it does not depend on where the command is launched from.
|
|
212
|
+
|
|
213
|
+
- **Windows**: `%APPDATA%\easydone\tasks.json`
|
|
214
|
+
- **macOS**: `~/Library/Application Support/easydone/tasks.json`
|
|
215
|
+
- **Linux**: `~/.local/share/easydone/tasks.json`
|
|
216
|
+
|
|
217
|
+
The app writes a small metadata wrapper with the file schema version and the version of easydone that saved it. This makes future upgrades safer and compatibility warnings explicit.
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"schema_version": 1,
|
|
222
|
+
"app_version": "0.2.0",
|
|
223
|
+
"saved_at": "2026-08-28",
|
|
224
|
+
"tasks": {
|
|
225
|
+
"123": {
|
|
226
|
+
"description": "Finish project report",
|
|
227
|
+
"status": "in-progress",
|
|
228
|
+
"priority": "high",
|
|
229
|
+
"created-at": "2026-08-28",
|
|
230
|
+
"updated-at": null
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Safety & Recovery
|
|
237
|
+
|
|
238
|
+
`easydone` now protects your data in three ways:
|
|
239
|
+
|
|
240
|
+
1. **Atomic writes**: Every save writes to a temporary file first, then swaps it atomically. A crash mid‑write never leaves a half‑written file.
|
|
241
|
+
2. **Automatic backups**: Before every save, the current `tasks.json` is copied to `tasks.json.bak`. If something goes wrong, you can restore from this backup.
|
|
242
|
+
3. **Corruption quarantine**: If easydone encounters an unreadable or malformed file on load, it copies that file to `tasks.corrupted-<timestamp>.json` instead of discarding it. You can inspect the quarantined file and recover data manually.
|
|
243
|
+
|
|
244
|
+
If an older file is found (different schema or app version), `easydone` still loads it but prints a detailed warning so you can review the data before saving again.
|
|
245
|
+
|
|
246
|
+
## Development
|
|
247
|
+
|
|
248
|
+
Install the development dependency group:
|
|
249
|
+
|
|
250
|
+
```shell
|
|
251
|
+
py -m pip install -e ".[dev]"
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Run the complete test suite:
|
|
255
|
+
|
|
256
|
+
```shell
|
|
257
|
+
py -m pytest
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Run a specific test module:
|
|
261
|
+
|
|
262
|
+
```shell
|
|
263
|
+
py -m pytest tests/logic_test.py
|
|
264
|
+
py -m pytest tests/storage_test.py
|
|
265
|
+
py -m pytest tests/format_test.py
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
This project is available under the license in [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
easydone/__init__.py,sha256=jfQbh7XOfARzUhtfwmWJ0n7_kINOSPZGPJpd-u6-55U,336
|
|
2
|
+
easydone/__main__.py,sha256=_X3PblOW4riqT1E7Jk1hKfgn6_Kooon8OeoN3lIsVLU,780
|
|
3
|
+
easydone/cli.py,sha256=IJbDOvjFrE37GeS5WY-UT20ZsZchzJuHzIdT_lSeI0I,6253
|
|
4
|
+
easydone/format.py,sha256=BiR-SoN4OpweG_HTZfGTG3dDcQQ00HpsFtJFu35HCBE,6116
|
|
5
|
+
easydone/logic.py,sha256=WpPl-UwTIN-1x0aZ1FR3-D6kn39XFqd0ameds7R6IZY,3656
|
|
6
|
+
easydone/storage.py,sha256=9wKoy6PQ2Htb60drp_y2Sq2F7BO0sSJklF9lNORgwkg,7582
|
|
7
|
+
easydone-0.2.0.dist-info/licenses/LICENSE,sha256=r6Bn3fQKU3hdDQE6nnYXNpI2sWnROJSE4BWlpRRNeh4,1105
|
|
8
|
+
easydone-0.2.0.dist-info/METADATA,sha256=MakHe69sr7FMAPIOdtXg9Bc_-EiK9bVzU8mE1mw7RAY,8202
|
|
9
|
+
easydone-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
easydone-0.2.0.dist-info/entry_points.txt,sha256=d9D0I3gq_ultiFP0U7czEj_k5EVkubxsST7ejbFTnoQ,52
|
|
11
|
+
easydone-0.2.0.dist-info/top_level.txt,sha256=PR_U4pT1FjXHw5vP9d2sggbqPSnx-SS_IcvXv3g2LPQ,9
|
|
12
|
+
easydone-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pedro Alberto Rosquete Ares
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
easydone
|