redmine-worklog 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """redmine_worklog — a tiny retro CLI to log daily tasks to Redmine."""
2
+
3
+ __version__ = "0.1.0"
redmine_worklog/cli.py ADDED
@@ -0,0 +1,405 @@
1
+ """
2
+ cli.py — the `rmlog` command. Retro CLI to log tasks to Redmine.
3
+
4
+ First run -> setup wizard (name, Redmine URL, API token, project).
5
+ After that -> greets by first name and logs tasks in the strict format.
6
+ """
7
+
8
+ import sys
9
+
10
+ import requests
11
+
12
+ from . import __version__, csv_backfill, engine, retro, userconfig
13
+ from . import worklog_parser as wl
14
+
15
+
16
+ def _verify_token(url, api_key):
17
+ """Return (user_dict, error). Confirms the token works."""
18
+ try:
19
+ client = engine.RedmineClient(url, api_key)
20
+ return client.current_user(), None
21
+ except requests.HTTPError as e:
22
+ code = e.response.status_code if e.response is not None else "?"
23
+ if str(code) == "401":
24
+ return None, "Invalid API token (401 Unauthorized)."
25
+ return None, f"Redmine returned HTTP {code}."
26
+ except requests.RequestException as e:
27
+ return None, f"Could not reach Redmine: {e}"
28
+
29
+
30
+ def run_setup():
31
+ print(retro.banner())
32
+ print(retro.box([
33
+ retro.c("Welcome, player one!", retro.BOLD + retro.WHITE),
34
+ "",
35
+ "Let's set up your worklog cartridge.",
36
+ "You'll need your Redmine URL and API access key",
37
+ "(Redmine -> My account -> API access key).",
38
+ ], color=retro.MAGENTA, title="NEW GAME"))
39
+ print()
40
+
41
+ cfg = userconfig.load()
42
+
43
+ name = retro.prompt("Your name >").strip()
44
+ while not name:
45
+ name = retro.prompt("Please enter your name >").strip()
46
+
47
+ url = retro.prompt("Redmine URL >").strip().rstrip("/")
48
+ while not url:
49
+ url = retro.prompt("Redmine URL (e.g. http://redmine.local) >").strip().rstrip("/")
50
+
51
+ # Loop until the token verifies.
52
+ while True:
53
+ token = retro.prompt("API access key >").strip()
54
+ if not token:
55
+ continue
56
+ retro.typewriter(" ...connecting to Redmine...", retro.DIM + retro.CYAN)
57
+ user, err = _verify_token(url, token)
58
+ if err:
59
+ retro.typewriter(" X " + err, retro.RED)
60
+ retry = retro.prompt("Try again? (y/n) >").strip().lower()
61
+ if retry not in ("y", "yes", ""):
62
+ retro.typewriter("Setup cancelled.", retro.YELLOW)
63
+ sys.exit(1)
64
+ # allow re-entering URL too
65
+ new_url = retro.prompt(f"Redmine URL [{url}] >").strip().rstrip("/")
66
+ if new_url:
67
+ url = new_url
68
+ continue
69
+ redmine_name = f"{user.get('firstname','')} {user.get('lastname','')}".strip()
70
+ retro.typewriter(f" OK Connected as {redmine_name} "
71
+ f"(id {user.get('id')}).", retro.GREEN)
72
+ break
73
+
74
+ project = retro.prompt("Default project name/identifier >").strip()
75
+ while not project:
76
+ project = retro.prompt("Default project (required) >").strip()
77
+
78
+ tracker = retro.prompt("Tracker [Tasks] >").strip() or "Tasks"
79
+
80
+ cfg.update({
81
+ "name": name,
82
+ "url": url,
83
+ "api_key": token,
84
+ "project": project,
85
+ "tracker": tracker,
86
+ })
87
+ userconfig.save(cfg)
88
+
89
+ print()
90
+ print(retro.box([
91
+ retro.c("Saved! Your progress is stored at:", retro.GREEN),
92
+ retro.c(userconfig.CONFIG_PATH, retro.DIM + retro.WHITE),
93
+ "",
94
+ "Run rmlog again any time to log tasks.",
95
+ ], color=retro.GREEN, title="SAVE COMPLETE"))
96
+ print()
97
+ return cfg
98
+
99
+
100
+ def _print_help(first):
101
+ print(retro.box([
102
+ retro.c(f"How to play, {first}:", retro.BOLD + retro.WHITE),
103
+ "",
104
+ "Type ONE task per line, like:",
105
+ retro.c(" upload module discovery 3 hr today", retro.YELLOW),
106
+ retro.c(" fix login bug 2h yesterday", retro.YELLOW),
107
+ retro.c(" sprint planning 1.5 hr 15-09-2026 16-09-2026", retro.YELLOW),
108
+ "",
109
+ "dates: today | yesterday | dd-mm-yyyy",
110
+ "end date is optional (defaults to start date).",
111
+ "",
112
+ "Commands:",
113
+ retro.c(" csv", retro.YELLOW) + " bulk upload from a CSV",
114
+ retro.c(" change project", retro.YELLOW) + " switch default project",
115
+ retro.c(" change user", retro.YELLOW) + " switch saved account",
116
+ retro.c(" help", retro.YELLOW) + " show this screen",
117
+ retro.c(" quit", retro.YELLOW) + " exit",
118
+ "",
119
+ retro.c("Tip: run rmlog --plain on old terminals.",
120
+ retro.DIM + retro.WHITE),
121
+ ], color=retro.CYAN, title="INSTRUCTIONS"))
122
+
123
+
124
+ def switch_user():
125
+ """
126
+ Let the user pick a saved profile (no token re-entry) or add a new one.
127
+ Returns the newly-active config dict, or None if unchanged/cancelled.
128
+ """
129
+ profiles = userconfig.list_profiles()
130
+ active = userconfig.active_key()
131
+
132
+ lines = [retro.c("Saved accounts:", retro.BOLD + retro.WHITE)]
133
+ for i, (key, display) in enumerate(profiles, 1):
134
+ mark = retro.c(" (active)", retro.GREEN) if key == active else ""
135
+ lines.append(f" {i}. {display}{mark}")
136
+ lines.append("")
137
+ lines.append(retro.c(" N. add a new account", retro.YELLOW))
138
+ print(retro.box(lines, color=retro.MAGENTA, title="CHANGE USER"))
139
+
140
+ choice = retro.prompt("Pick a number, 'N' for new, or 'cancel' >").strip().lower()
141
+ if not choice or choice == "cancel":
142
+ retro.typewriter(" User unchanged.", retro.YELLOW)
143
+ return None
144
+ if choice in ("n", "new"):
145
+ return run_setup() # adds/updates a profile and makes it active
146
+ if choice.isdigit():
147
+ idx = int(choice)
148
+ if 1 <= idx <= len(profiles):
149
+ key = profiles[idx - 1][0]
150
+ cfg = userconfig.switch_to(key)
151
+ if cfg:
152
+ return cfg
153
+ retro.typewriter(" Not a valid choice. User unchanged.", retro.YELLOW)
154
+ return None
155
+
156
+
157
+ def change_project(cfg, client):
158
+ """Prompt for a new default project, verify it exists, save. Returns bool."""
159
+ print(retro.box([
160
+ retro.c(f"Current project: {cfg.get('project','(none)')}", retro.WHITE),
161
+ "",
162
+ "Enter a new project name or identifier.",
163
+ ], color=retro.MAGENTA, title="CHANGE PROJECT"))
164
+ new_project = retro.prompt("New project (or 'cancel') >").strip()
165
+ if not new_project or new_project.lower() == "cancel":
166
+ retro.typewriter(" Project unchanged.", retro.YELLOW)
167
+ return False
168
+ retro.typewriter(" ...checking project in Redmine...", retro.DIM + retro.CYAN)
169
+ try:
170
+ client.project_id(new_project) # raises if not found
171
+ except Exception as e: # noqa: BLE001
172
+ retro.typewriter(" X " + str(e), retro.RED)
173
+ return False
174
+ cfg["project"] = new_project
175
+ userconfig.save(cfg)
176
+ retro.typewriter(f" OK Default project is now '{new_project}'.", retro.GREEN)
177
+ return True
178
+
179
+
180
+ def run_csv_backfill(cfg, client, ids, holidays, first):
181
+ """Interactive CSV backfill. Returns nothing; posts on confirm."""
182
+ print(retro.box(
183
+ [retro.c(ln, retro.WHITE) for ln in csv_backfill.INSTRUCTIONS],
184
+ color=retro.CYAN, title="CSV BACKFILL"))
185
+
186
+ # Sample shown as a column | value table (generic, easy to read).
187
+ req = {"subject", "hours", "date"}
188
+ col_w = max(len(c) for c, _ in csv_backfill.SAMPLE_ROW)
189
+ sample_lines = [
190
+ retro.c(f"{'COLUMN':<{col_w}}", retro.BOLD + retro.CYAN)
191
+ + " | " + retro.c("EXAMPLE VALUE", retro.BOLD + retro.CYAN),
192
+ retro.c("-" * col_w + "--+--" + "-" * 28, retro.DIM + retro.WHITE),
193
+ ]
194
+ for col, val in csv_backfill.SAMPLE_ROW:
195
+ tag = retro.c(" *", retro.YELLOW) if col in req else " "
196
+ sample_lines.append(
197
+ retro.c(f"{col:<{col_w}}", retro.WHITE) + " | "
198
+ + retro.c(val, retro.GREEN) + tag)
199
+ sample_lines.append("")
200
+ sample_lines.append(retro.c(" * = required in every row", retro.YELLOW))
201
+ print(retro.box(sample_lines, color=retro.GREEN, title="SAMPLE ROW"))
202
+
203
+ path = retro.prompt("\nPath to your CSV (or 'cancel') >").strip().strip('"')
204
+ if not path or path.lower() == "cancel":
205
+ retro.typewriter(" CSV upload cancelled.", retro.YELLOW)
206
+ return
207
+
208
+ entries, errors = csv_backfill.read_and_validate(path, cfg["defaults"])
209
+
210
+ # File/format-level problems (no entries at all)
211
+ if not entries and errors:
212
+ retro.typewriter(" X Could not use this CSV:", retro.RED)
213
+ for e in errors:
214
+ retro.typewriter(" " + e, retro.YELLOW)
215
+ return
216
+
217
+ # Apply org rules across all rows. Hard errors block; warnings need override.
218
+ rule_errors, warnings = engine.validate_entries(
219
+ entries, cfg["rules"], holidays, client, ids["user_id"])
220
+ all_errors = errors + rule_errors
221
+ if all_errors:
222
+ retro.typewriter(f" Found {len(all_errors)} issue(s). "
223
+ f"Nothing uploaded. Fix and try again:", retro.RED)
224
+ for e in all_errors:
225
+ retro.typewriter(" - " + e, retro.YELLOW)
226
+ return
227
+
228
+ # Summary + confirm
229
+ total_hours = sum(float(e["hours"]) for e in entries)
230
+ days = sorted({str(e["date"]) for e in entries})
231
+ print(retro.box([
232
+ retro.c(f"Rows OK : {len(entries)} task(s)", retro.WHITE),
233
+ retro.c(f"Hours : {total_hours}h total", retro.WHITE),
234
+ retro.c(f"Dates : {days[0]} -> {days[-1]}", retro.WHITE),
235
+ ], color=retro.YELLOW, title="CSV VALIDATED"))
236
+
237
+ if warnings:
238
+ warn_lines = [retro.c("Over the daily hours limit:", retro.BOLD + retro.YELLOW)]
239
+ warn_lines += [retro.c(" " + w, retro.WHITE) for w in warnings]
240
+ print(retro.box(warn_lines, color=retro.RED, title="! WARNING"))
241
+ ow = retro.prompt("Log more than the limit anyway? (yes/no) >").strip().lower()
242
+ if ow not in ("yes", "y"):
243
+ retro.typewriter(" Cancelled. Nothing was uploaded.", retro.YELLOW)
244
+ return
245
+
246
+ ans = retro.prompt(f"Upload all {len(entries)} tasks to Redmine? "
247
+ f"(yes/no) >").strip().lower()
248
+ if ans not in ("yes", "y"):
249
+ retro.typewriter(" Cancelled. Nothing was uploaded.", retro.YELLOW)
250
+ return
251
+
252
+ ok_count, fail_count = 0, 0
253
+ for e in entries:
254
+ ok, res = engine.post_entry(client, ids, e, cfg["defaults"])
255
+ if ok:
256
+ ok_count += 1
257
+ retro.typewriter(f" {retro.c('★', retro.GREEN)} {e['subject']}: {res}",
258
+ retro.WHITE)
259
+ else:
260
+ fail_count += 1
261
+ retro.typewriter(f" {retro.c('✖', retro.RED)} {e['subject']}: {res}",
262
+ retro.WHITE)
263
+ print(retro.box([
264
+ retro.c(f"Uploaded: {ok_count}", retro.GREEN),
265
+ retro.c(f"Failed : {fail_count}",
266
+ retro.RED if fail_count else retro.DIM + retro.WHITE),
267
+ ], color=retro.GREEN, title="BACKFILL COMPLETE"))
268
+
269
+
270
+ def _connect(cfg):
271
+ """Create client + resolve ids. Exits with a friendly message on failure."""
272
+ client = engine.RedmineClient(cfg["url"], cfg["api_key"])
273
+ try:
274
+ ids = engine.resolve_ids(client, cfg)
275
+ except Exception as e: # noqa: BLE001
276
+ retro.typewriter(f"Could not connect: {e}", retro.RED)
277
+ retro.typewriter("Run rmlog --setup to fix your settings.", retro.YELLOW)
278
+ sys.exit(1)
279
+ return client, ids
280
+
281
+
282
+ def run_chat(cfg):
283
+ client, ids = _connect(cfg)
284
+ holidays = engine.parse_holidays(cfg.get("holidays", []))
285
+ first = userconfig.first_name(cfg["name"]) or "player"
286
+
287
+ print(retro.banner())
288
+ print(retro.box([
289
+ retro.c(f"Player 1: {first}", retro.BOLD + retro.WHITE),
290
+ retro.c(f"Redmine : {cfg['url']}", retro.DIM + retro.WHITE),
291
+ retro.c(f"Project : {cfg['project']}", retro.DIM + retro.WHITE),
292
+ "",
293
+ f"Ready when you are, {first}.",
294
+ "Type a task, 'csv' to bulk upload, or 'help'.",
295
+ ], color=retro.GREEN, title=f"PRESS START - v{__version__}"))
296
+
297
+ pending = None
298
+ while True:
299
+ try:
300
+ msg = retro.prompt(f"\n{retro.c('◄' + first + '►', retro.MAGENTA)} >").strip()
301
+ except (EOFError, KeyboardInterrupt):
302
+ print()
303
+ break
304
+
305
+ if not msg:
306
+ continue
307
+ low = msg.lower()
308
+ if low in ("quit", "exit", "q"):
309
+ retro.typewriter(f"GAME OVER. See you, {first}!", retro.YELLOW)
310
+ break
311
+ if low in ("help", "?"):
312
+ _print_help(first)
313
+ continue
314
+ if low in ("csv", "upload", "backfill"):
315
+ run_csv_backfill(cfg, client, ids, holidays, first)
316
+ pending = None
317
+ continue
318
+ if low in ("change user", "switch user", "logout"):
319
+ new_cfg = switch_user()
320
+ if new_cfg:
321
+ cfg = new_cfg
322
+ client, ids = _connect(cfg)
323
+ first = userconfig.first_name(cfg["name"]) or "player"
324
+ holidays = engine.parse_holidays(cfg.get("holidays", []))
325
+ retro.typewriter(f" Switched. Welcome, {first}!", retro.GREEN)
326
+ pending = None
327
+ continue
328
+ if low in ("change project", "switch project"):
329
+ if change_project(cfg, client):
330
+ ids = engine.resolve_ids(client, cfg) # re-resolve for new project
331
+ pending = None
332
+ continue
333
+
334
+ # confirmation of a pending task
335
+ if pending is not None:
336
+ if low in ("yes", "y", "ok", "confirm"):
337
+ ok, res = engine.post_entry(client, ids, pending, cfg["defaults"])
338
+ mark = retro.c("★ WIN", retro.GREEN) if ok else retro.c("✖ MISS", retro.RED)
339
+ print(f" {mark} {res}")
340
+ else:
341
+ retro.typewriter(" Cancelled. Nothing was logged.", retro.YELLOW)
342
+ pending = None
343
+ continue
344
+
345
+ entries, err = wl.parse(msg)
346
+ if err:
347
+ retro.typewriter(" ! " + err.splitlines()[0], retro.RED)
348
+ for extra in err.splitlines()[1:]:
349
+ retro.typewriter(" " + extra, retro.DIM + retro.WHITE)
350
+ continue
351
+
352
+ verrs, warnings = engine.validate_entries(
353
+ entries, cfg["rules"], holidays, client, ids["user_id"])
354
+ if verrs:
355
+ retro.typewriter(" BLOCKED by the rules:", retro.RED)
356
+ for v in verrs:
357
+ retro.typewriter(" - " + v, retro.YELLOW)
358
+ continue
359
+
360
+ # show preview
361
+ d = engine.describe_entry(entries[0], cfg["defaults"])
362
+ lines = [retro.c(f"{k:<9}", retro.CYAN) + ": " + str(v)
363
+ for k, v in d.items()]
364
+ print(retro.box(lines, color=retro.YELLOW, title="CONFIRM TASK"))
365
+
366
+ # Over the daily limit -> warn and require an explicit override first.
367
+ if warnings:
368
+ warn_lines = [retro.c("Over the daily hours limit:",
369
+ retro.BOLD + retro.YELLOW)]
370
+ warn_lines += [retro.c(" " + w, retro.WHITE) for w in warnings]
371
+ print(retro.box(warn_lines, color=retro.RED, title="! WARNING"))
372
+ ow = retro.prompt("Log more than the limit anyway? (yes/no) >").strip().lower()
373
+ if ow not in ("yes", "y"):
374
+ retro.typewriter(" Cancelled. Nothing was logged.", retro.YELLOW)
375
+ pending = None
376
+ continue
377
+
378
+ retro.typewriter(" Push to Redmine? type 'yes' to confirm.",
379
+ retro.BOLD + retro.WHITE)
380
+ pending = entries[0]
381
+
382
+
383
+ def main():
384
+ args = sys.argv[1:]
385
+ if "--version" in args:
386
+ print(f"redmine-worklog {__version__}")
387
+ return
388
+
389
+ # Plain mode for old terminals / piping: --plain flag or NO_COLOR env var.
390
+ import os as _os
391
+ if "--plain" in args or _os.environ.get("NO_COLOR"):
392
+ retro.set_plain(True)
393
+
394
+ if "--setup" in args or not userconfig.exists():
395
+ cfg = run_setup()
396
+ else:
397
+ cfg = userconfig.load()
398
+ # guard against half-set config
399
+ if not (cfg.get("url") and cfg.get("api_key") and cfg.get("project")):
400
+ cfg = run_setup()
401
+ run_chat(cfg)
402
+
403
+
404
+ if __name__ == "__main__":
405
+ main()
@@ -0,0 +1,173 @@
1
+ """
2
+ csv_backfill.py — read and validate a CSV of tasks for bulk backfill.
3
+
4
+ Expected header (order doesn't matter; case-insensitive):
5
+ subject,description,priority,status,start_date,due_date,
6
+ estimated_hours,date,hours,activity,comment
7
+
8
+ Required per row: subject, hours, date.
9
+ Everything else is optional and falls back to the user's defaults.
10
+
11
+ Dates accepted: YYYY-MM-DD or dd-mm-yyyy or dd/mm/yyyy.
12
+ """
13
+
14
+ import csv
15
+ import datetime as dt
16
+ import os
17
+
18
+ REQUIRED = ["subject", "hours", "date"]
19
+ KNOWN_COLUMNS = [
20
+ "subject", "description", "priority", "status", "start_date",
21
+ "due_date", "estimated_hours", "date", "hours", "activity", "comment",
22
+ ]
23
+
24
+ # Raw CSV template (written to file when the user asks for a sample).
25
+ TEMPLATE = (
26
+ "subject,description,priority,status,start_date,due_date,"
27
+ "estimated_hours,date,hours,activity,comment\n"
28
+ "Task title,short description of the work,Normal,"
29
+ "Closed,2026-09-17,2026-09-18,8,2026-09-18,4,Development,"
30
+ "worked on the task\n"
31
+ )
32
+
33
+ # A generic sample shown as a vertical table (column -> value), which reads
34
+ # far better in a terminal than a single very wide CSV line.
35
+ SAMPLE_ROW = [
36
+ ("subject", "Task title"),
37
+ ("description", "short description of the work"),
38
+ ("priority", "Normal"),
39
+ ("status", "Closed"),
40
+ ("start_date", "2026-09-17"),
41
+ ("due_date", "2026-09-18"),
42
+ ("estimated_hours", "8"),
43
+ ("date", "2026-09-18"),
44
+ ("hours", "4"),
45
+ ("activity", "Development"),
46
+ ("comment", "worked on the task"),
47
+ ]
48
+
49
+ INSTRUCTIONS = [
50
+ "CSV backfill lets you upload many tasks at once (e.g. last 3 months).",
51
+ "",
52
+ "Header row (exactly these column names):",
53
+ " subject,description,priority,status,start_date,due_date,",
54
+ " estimated_hours,date,hours,activity,comment",
55
+ "",
56
+ "Required in every row : subject, hours, date",
57
+ "Optional (use default): description, priority, status, start_date,",
58
+ " due_date, estimated_hours, activity, comment",
59
+ "",
60
+ "Dates: YYYY-MM-DD or dd-mm-yyyy",
61
+ "One row = one task. Rules (9h/day, weekends, holidays) still apply.",
62
+ ]
63
+
64
+
65
+ def _parse_date(value):
66
+ v = str(value).strip()
67
+ # try ISO first
68
+ try:
69
+ return dt.date.fromisoformat(v)
70
+ except ValueError:
71
+ pass
72
+ # dd-mm-yyyy or dd/mm/yyyy
73
+ for sep in ("-", "/"):
74
+ parts = v.split(sep)
75
+ if len(parts) == 3 and len(parts[0]) <= 2:
76
+ d, m, y = parts
77
+ return dt.date(int(y), int(m), int(d))
78
+ raise ValueError(v)
79
+
80
+
81
+ def read_and_validate(path, defaults):
82
+ """
83
+ Returns (entries, errors).
84
+ - entries: list of entry dicts ready for engine.post_entry
85
+ - errors : list of human-readable problems (empty => file is valid)
86
+ If the file/headers are unusable, entries is [] and errors explains why.
87
+ """
88
+ if not os.path.exists(path):
89
+ return [], [f"File not found: {path}"]
90
+
91
+ try:
92
+ with open(path, newline="", encoding="utf-8-sig") as f:
93
+ reader = csv.DictReader(f)
94
+ if reader.fieldnames is None:
95
+ return [], ["The CSV appears to be empty."]
96
+ headers = [(h or "").strip().lower() for h in reader.fieldnames]
97
+ rows = list(reader)
98
+ except (OSError, csv.Error) as e:
99
+ return [], [f"Could not read the CSV: {e}"]
100
+
101
+ # header check
102
+ missing_required_cols = [c for c in REQUIRED if c not in headers]
103
+ if missing_required_cols:
104
+ return [], [f"Missing required column(s): {', '.join(missing_required_cols)}.",
105
+ "Expected header: " + ",".join(KNOWN_COLUMNS)]
106
+
107
+ entries, errors = [], []
108
+ for i, raw in enumerate(rows, start=2): # line 1 is the header
109
+ row = {(k or "").strip().lower(): (v.strip() if isinstance(v, str) else v)
110
+ for k, v in raw.items()}
111
+
112
+ # skip fully blank lines
113
+ if not any((row.get(c) or "").strip() for c in KNOWN_COLUMNS):
114
+ continue
115
+
116
+ subject = row.get("subject") or ""
117
+ if not subject:
118
+ errors.append(f"Line {i}: 'subject' is required.")
119
+ continue
120
+
121
+ # hours
122
+ hraw = row.get("hours") or ""
123
+ try:
124
+ hours = float(hraw)
125
+ if hours <= 0:
126
+ errors.append(f"Line {i}: 'hours' must be > 0.")
127
+ continue
128
+ except ValueError:
129
+ errors.append(f"Line {i}: 'hours' ('{hraw}') is not a number.")
130
+ continue
131
+
132
+ # date (logging date, required)
133
+ draw = row.get("date") or ""
134
+ try:
135
+ day = _parse_date(draw)
136
+ except ValueError:
137
+ errors.append(f"Line {i}: 'date' ('{draw}') is invalid "
138
+ f"(use YYYY-MM-DD or dd-mm-yyyy).")
139
+ continue
140
+
141
+ entry = {"subject": subject, "hours": hours, "date": day}
142
+
143
+ # optional dates
144
+ for key in ("start_date", "due_date"):
145
+ val = row.get(key)
146
+ if val:
147
+ try:
148
+ entry[key] = _parse_date(val)
149
+ except ValueError:
150
+ errors.append(f"Line {i}: '{key}' ('{val}') is invalid.")
151
+ entry.setdefault("start_date", day)
152
+ entry.setdefault("due_date", entry.get("start_date", day))
153
+
154
+ # optional estimated_hours
155
+ est = row.get("estimated_hours")
156
+ if est:
157
+ try:
158
+ entry["estimated_hours"] = float(est)
159
+ except ValueError:
160
+ errors.append(f"Line {i}: 'estimated_hours' ('{est}') "
161
+ f"is not a number.")
162
+
163
+ # optional free-text / lookup fields
164
+ for key in ("description", "priority", "status", "activity", "comment"):
165
+ if row.get(key):
166
+ entry[key] = row[key]
167
+
168
+ entries.append(entry)
169
+
170
+ if not entries and not errors:
171
+ errors.append("No task rows found in the CSV.")
172
+
173
+ return entries, errors