phase4
This commit is contained in:
parent
e651369587
commit
67e35bcffd
14 changed files with 1174 additions and 14 deletions
15
README.md
15
README.md
|
|
@ -4,7 +4,8 @@ Steady is an ADHD-friendly task manager built with Flask. It provides a modular
|
||||||
application foundation, secure authentication, task and subtask management, a
|
application foundation, secure authentication, task and subtask management, a
|
||||||
seven-item Today view, status filters, priorities, progress indicators, and a
|
seven-item Today view, status filters, priorities, progress indicators, and a
|
||||||
single-task Focus Mode with countdowns, time blocks, context cues, positive
|
single-task Focus Mode with countdowns, time blocks, context cues, positive
|
||||||
completion feedback, and forgiving streaks.
|
completion feedback, forgiving streaks, bulk text capture, and portable JSON
|
||||||
|
backups with reversible completed-task clearing.
|
||||||
|
|
||||||
## Local setup
|
## Local setup
|
||||||
|
|
||||||
|
|
@ -40,3 +41,15 @@ python -m pytest
|
||||||
|
|
||||||
Tests use an isolated in-memory SQLite database and never write to the local
|
Tests use an isolated in-memory SQLite database and never write to the local
|
||||||
development database.
|
development database.
|
||||||
|
|
||||||
|
## Data and backups
|
||||||
|
|
||||||
|
After signing in, open `/tasks/transfer` to:
|
||||||
|
|
||||||
|
- import up to 200 UTF-8 Markdown or plain-text task lines;
|
||||||
|
- download a versioned JSON backup without credentials or account identifiers;
|
||||||
|
- merge or replace tasks from a validated Steady backup; and
|
||||||
|
- clear completed tasks with an immediate user-scoped undo action.
|
||||||
|
|
||||||
|
JSON restore validates the complete file before changing tasks. Keep downloaded
|
||||||
|
backups private because task descriptions may contain personal information.
|
||||||
|
|
|
||||||
|
|
@ -538,6 +538,60 @@ input:focus-visible {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.undo-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid var(--blue);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--blue) 24%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-banner h2,
|
||||||
|
.undo-banner p {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-card {
|
||||||
|
padding: var(--space);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-example {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: var(--beige);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-field--warning {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-left: 0.3rem solid var(--yellow);
|
||||||
|
background: color-mix(in srgb, var(--yellow) 20%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-field small {
|
||||||
|
display: block;
|
||||||
|
margin-left: 1.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes gentle-arrival {
|
@keyframes gentle-arrival {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -628,4 +682,13 @@ input {
|
||||||
.streak-card {
|
.streak-card {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transfer-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-banner {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import IntegerField, SelectField, StringField, SubmitField, TextAreaField
|
from flask_wtf.file import FileAllowed, FileField, FileRequired
|
||||||
|
from wtforms import (
|
||||||
|
BooleanField,
|
||||||
|
IntegerField,
|
||||||
|
SelectField,
|
||||||
|
StringField,
|
||||||
|
SubmitField,
|
||||||
|
TextAreaField,
|
||||||
|
)
|
||||||
from wtforms.fields import DateTimeLocalField
|
from wtforms.fields import DateTimeLocalField
|
||||||
from wtforms.validators import Length, NumberRange, Optional, ValidationError
|
from wtforms.validators import (
|
||||||
|
DataRequired,
|
||||||
|
Length,
|
||||||
|
NumberRange,
|
||||||
|
Optional,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
from .models import TASK_PRIORITIES, TASK_STATUSES
|
from .models import TASK_PRIORITIES, TASK_STATUSES
|
||||||
|
|
||||||
|
|
@ -100,3 +114,33 @@ class FocusSettingsForm(FlaskForm):
|
||||||
int(value.strip())
|
int(value.strip())
|
||||||
for value in self.chunk_plan.data.split(",")
|
for value in self.chunk_plan.data.split(",")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownImportForm(FlaskForm):
|
||||||
|
markdown_file = FileField(
|
||||||
|
"Markdown or text file",
|
||||||
|
validators=[
|
||||||
|
FileRequired(),
|
||||||
|
FileAllowed(["md", "markdown", "txt"], "Use a .md or .txt file."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
confirm = BooleanField(
|
||||||
|
"I reviewed the import format and want to add these tasks.",
|
||||||
|
validators=[DataRequired()],
|
||||||
|
)
|
||||||
|
submit = SubmitField("Import task list")
|
||||||
|
|
||||||
|
|
||||||
|
class BackupImportForm(FlaskForm):
|
||||||
|
backup_file = FileField(
|
||||||
|
"Steady JSON backup",
|
||||||
|
validators=[FileRequired(), FileAllowed(["json"], "Use a .json file.")],
|
||||||
|
)
|
||||||
|
replace_existing = BooleanField(
|
||||||
|
"Replace all of my existing tasks instead of merging"
|
||||||
|
)
|
||||||
|
confirm = BooleanField(
|
||||||
|
"I understand that restore may add or replace tasks and preferences.",
|
||||||
|
validators=[DataRequired()],
|
||||||
|
)
|
||||||
|
submit = SubmitField("Restore backup")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ class Task(db.Model):
|
||||||
)
|
)
|
||||||
due_date: Mapped[datetime | None]
|
due_date: Mapped[datetime | None]
|
||||||
completed_at: Mapped[datetime | None]
|
completed_at: Mapped[datetime | None]
|
||||||
|
cleared_at: Mapped[datetime | None]
|
||||||
|
clear_batch_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("user.id", ondelete="CASCADE"), index=True
|
ForeignKey("user.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
from flask import abort, flash, redirect, render_template, request, url_for
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
abort,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
send_file,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from .forms import (
|
from .forms import (
|
||||||
ActionForm,
|
ActionForm,
|
||||||
|
BackupImportForm,
|
||||||
FocusSettingsForm,
|
FocusSettingsForm,
|
||||||
|
MarkdownImportForm,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
SubtaskForm,
|
SubtaskForm,
|
||||||
TaskForm,
|
TaskForm,
|
||||||
|
|
@ -14,6 +28,7 @@ from .services import (
|
||||||
TaskNotFoundError,
|
TaskNotFoundError,
|
||||||
add_subtask,
|
add_subtask,
|
||||||
calculate_forgiving_streak,
|
calculate_forgiving_streak,
|
||||||
|
clear_completed_tasks,
|
||||||
complete_task,
|
complete_task,
|
||||||
configure_focus,
|
configure_focus,
|
||||||
create_task,
|
create_task,
|
||||||
|
|
@ -23,8 +38,15 @@ from .services import (
|
||||||
list_today_tasks,
|
list_today_tasks,
|
||||||
toggle_chunk_session,
|
toggle_chunk_session,
|
||||||
toggle_subtask,
|
toggle_subtask,
|
||||||
|
undo_clear_completed,
|
||||||
update_task,
|
update_task,
|
||||||
)
|
)
|
||||||
|
from .transfer import (
|
||||||
|
TransferValidationError,
|
||||||
|
export_backup,
|
||||||
|
import_backup,
|
||||||
|
import_markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _owned_task_or_404(task_id: int):
|
def _owned_task_or_404(task_id: int):
|
||||||
|
|
@ -233,3 +255,106 @@ def complete(task_id: int):
|
||||||
complete_task(task)
|
complete_task(task)
|
||||||
flash(f"You completed {completed_title}. That counts.", "success")
|
flash(f"You completed {completed_title}. That counts.", "success")
|
||||||
return redirect(url_for("tasks.focus", celebrate=1))
|
return redirect(url_for("tasks.focus", celebrate=1))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/transfer")
|
||||||
|
@login_required
|
||||||
|
def transfer():
|
||||||
|
undo_batch = None
|
||||||
|
raw_undo = request.args.get("undo")
|
||||||
|
if raw_undo:
|
||||||
|
try:
|
||||||
|
undo_batch = UUID(raw_undo)
|
||||||
|
except ValueError:
|
||||||
|
abort(400)
|
||||||
|
return render_template(
|
||||||
|
"tasks/transfer.html",
|
||||||
|
markdown_form=MarkdownImportForm(),
|
||||||
|
backup_form=BackupImportForm(),
|
||||||
|
action_form=ActionForm(),
|
||||||
|
undo_batch=undo_batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/import/markdown")
|
||||||
|
@login_required
|
||||||
|
def import_markdown_file():
|
||||||
|
form = MarkdownImportForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
_flash_form_errors(form)
|
||||||
|
return redirect(url_for("tasks.transfer"))
|
||||||
|
try:
|
||||||
|
count = import_markdown(current_user.id, form.markdown_file.data.read())
|
||||||
|
except TransferValidationError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
else:
|
||||||
|
flash(f"Imported {count} tasks.", "success")
|
||||||
|
return redirect(url_for("tasks.transfer"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/export/json")
|
||||||
|
@login_required
|
||||||
|
def export_json():
|
||||||
|
content = export_backup(current_user)
|
||||||
|
return send_file(
|
||||||
|
BytesIO(content),
|
||||||
|
mimetype="application/json",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"steady-backup-{date.today().isoformat()}.json",
|
||||||
|
max_age=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/import/json")
|
||||||
|
@login_required
|
||||||
|
def import_json():
|
||||||
|
form = BackupImportForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
_flash_form_errors(form)
|
||||||
|
return redirect(url_for("tasks.transfer"))
|
||||||
|
try:
|
||||||
|
count = import_backup(
|
||||||
|
current_user,
|
||||||
|
form.backup_file.data.read(),
|
||||||
|
replace_existing=form.replace_existing.data,
|
||||||
|
)
|
||||||
|
except TransferValidationError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
else:
|
||||||
|
action = "Restored" if form.replace_existing.data else "Merged"
|
||||||
|
flash(f"{action} {count} tasks from backup.", "success")
|
||||||
|
return redirect(url_for("tasks.transfer"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/completed/clear")
|
||||||
|
@login_required
|
||||||
|
def clear_completed():
|
||||||
|
form = ActionForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
abort(400)
|
||||||
|
batch_id, count = clear_completed_tasks(current_user.id)
|
||||||
|
if batch_id is None:
|
||||||
|
flash("There are no completed tasks to clear.", "info")
|
||||||
|
return redirect(url_for("tasks.transfer"))
|
||||||
|
flash(f"Cleared {count} completed tasks. You can undo this below.", "info")
|
||||||
|
return redirect(url_for("tasks.transfer", undo=batch_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/completed/undo/<uuid:batch_id>")
|
||||||
|
@login_required
|
||||||
|
def undo_completed_clear(batch_id):
|
||||||
|
form = ActionForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
abort(400)
|
||||||
|
count = undo_clear_completed(current_user.id, str(batch_id))
|
||||||
|
if count:
|
||||||
|
flash(f"Restored {count} completed tasks.", "success")
|
||||||
|
else:
|
||||||
|
flash("That clear operation is no longer available to undo.", "info")
|
||||||
|
return redirect(url_for("tasks.transfer"))
|
||||||
|
|
||||||
|
|
||||||
|
def _flash_form_errors(form) -> None:
|
||||||
|
for errors in form.errors.values():
|
||||||
|
for error in errors:
|
||||||
|
flash(error, "error")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import case, or_, select
|
from sqlalchemy import case, or_, select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -23,7 +24,7 @@ class ForgivingStreak:
|
||||||
def _task_query(user_id: int):
|
def _task_query(user_id: int):
|
||||||
return (
|
return (
|
||||||
select(Task)
|
select(Task)
|
||||||
.where(Task.user_id == user_id)
|
.where(Task.user_id == user_id, Task.cleared_at.is_(None))
|
||||||
.options(selectinload(Task.subtasks))
|
.options(selectinload(Task.subtasks))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -217,7 +218,11 @@ def calculate_forgiving_streak(
|
||||||
current_day = today or datetime.now().astimezone().date()
|
current_day = today or datetime.now().astimezone().date()
|
||||||
|
|
||||||
# The current day is never treated as missed while it is still in progress.
|
# The current day is never treated as missed while it is still in progress.
|
||||||
cursor = current_day if current_day in completion_dates else current_day - timedelta(days=1)
|
cursor = (
|
||||||
|
current_day
|
||||||
|
if current_day in completion_dates
|
||||||
|
else current_day - timedelta(days=1)
|
||||||
|
)
|
||||||
if cursor not in completion_dates:
|
if cursor not in completion_dates:
|
||||||
return ForgivingStreak(days=0, freeze_used=False)
|
return ForgivingStreak(days=0, freeze_used=False)
|
||||||
|
|
||||||
|
|
@ -238,6 +243,45 @@ def calculate_forgiving_streak(
|
||||||
return ForgivingStreak(days=days, freeze_used=freeze_used)
|
return ForgivingStreak(days=days, freeze_used=freeze_used)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_completed_tasks(user_id: int) -> tuple[str | None, int]:
|
||||||
|
tasks = list(
|
||||||
|
db.session.scalars(
|
||||||
|
select(Task).where(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.status == "done",
|
||||||
|
Task.cleared_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not tasks:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
batch_id = str(uuid4())
|
||||||
|
cleared_at = datetime.now(timezone.utc)
|
||||||
|
for task in tasks:
|
||||||
|
task.cleared_at = cleared_at
|
||||||
|
task.clear_batch_id = batch_id
|
||||||
|
db.session.commit()
|
||||||
|
return batch_id, len(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def undo_clear_completed(user_id: int, batch_id: str) -> int:
|
||||||
|
tasks = list(
|
||||||
|
db.session.scalars(
|
||||||
|
select(Task).where(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.clear_batch_id == batch_id,
|
||||||
|
Task.cleared_at.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for task in tasks:
|
||||||
|
task.cleared_at = None
|
||||||
|
task.clear_batch_id = None
|
||||||
|
db.session.commit()
|
||||||
|
return len(tasks)
|
||||||
|
|
||||||
|
|
||||||
def _validate_choices(status: str, priority: str) -> None:
|
def _validate_choices(status: str, priority: str) -> None:
|
||||||
if status not in TASK_STATUSES:
|
if status not in TASK_STATUSES:
|
||||||
raise ValueError("Unknown task status.")
|
raise ValueError("Unknown task status.")
|
||||||
|
|
|
||||||
398
app/tasks/transfer.py
Normal file
398
app/tasks/transfer.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
from .models import TASK_PRIORITIES, TASK_STATUSES, Subtask, Task
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP_SCHEMA_VERSION = 1
|
||||||
|
MAX_MARKDOWN_TASKS = 200
|
||||||
|
MAX_BACKUP_TASKS = 1000
|
||||||
|
MARKDOWN_TASK = re.compile(
|
||||||
|
r"^\s*(?:[-*+]\s+|\d+[.)]\s+)?"
|
||||||
|
r"(?:\[(?P<checked>[ xX])\]\s*)?(?P<title>.+?)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BackupUser(Protocol):
|
||||||
|
id: int
|
||||||
|
theme: str
|
||||||
|
completion_chime: bool
|
||||||
|
dyslexia_font: bool
|
||||||
|
notification_frequency: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImportedTask:
|
||||||
|
title: str
|
||||||
|
status: str = "not_started"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_markdown(content: bytes) -> list[ImportedTask]:
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise TransferValidationError("The text file must use UTF-8 encoding.") from exc
|
||||||
|
|
||||||
|
tasks: list[ImportedTask] = []
|
||||||
|
in_code_block = False
|
||||||
|
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if line.startswith("```"):
|
||||||
|
in_code_block = not in_code_block
|
||||||
|
continue
|
||||||
|
if not line or in_code_block or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = MARKDOWN_TASK.match(line)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
title = match.group("title").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
if len(title) > 200:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Line {line_number} exceeds the 200-character task-title limit."
|
||||||
|
)
|
||||||
|
status = "done" if (match.group("checked") or "").lower() == "x" else "not_started"
|
||||||
|
tasks.append(ImportedTask(title=title, status=status))
|
||||||
|
if len(tasks) > MAX_MARKDOWN_TASKS:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"A single text import may contain at most {MAX_MARKDOWN_TASKS} tasks."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
raise TransferValidationError("No task lines were found in the text file.")
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
def import_markdown(user_id: int, content: bytes) -> int:
|
||||||
|
imported = parse_markdown(content)
|
||||||
|
completed_at = datetime.now(timezone.utc)
|
||||||
|
tasks = [
|
||||||
|
Task(
|
||||||
|
user_id=user_id,
|
||||||
|
title=item.title,
|
||||||
|
status=item.status,
|
||||||
|
priority="normal",
|
||||||
|
completed_at=completed_at if item.status == "done" else None,
|
||||||
|
)
|
||||||
|
for item in imported
|
||||||
|
]
|
||||||
|
db.session.add_all(tasks)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
raise TransferValidationError(
|
||||||
|
"The task list could not be imported safely. No changes were saved."
|
||||||
|
) from exc
|
||||||
|
return len(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def export_backup(user: BackupUser) -> bytes:
|
||||||
|
tasks = list(
|
||||||
|
db.session.scalars(
|
||||||
|
select(Task)
|
||||||
|
.where(Task.user_id == user.id, Task.cleared_at.is_(None))
|
||||||
|
.options(selectinload(Task.subtasks))
|
||||||
|
.order_by(Task.created_at, Task.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
document = {
|
||||||
|
"schema_version": BACKUP_SCHEMA_VERSION,
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"settings": {
|
||||||
|
"theme": user.theme,
|
||||||
|
"completion_chime": user.completion_chime,
|
||||||
|
"dyslexia_font": user.dyslexia_font,
|
||||||
|
"notification_frequency": user.notification_frequency,
|
||||||
|
},
|
||||||
|
"tasks": [_serialize_task(task) for task in tasks],
|
||||||
|
}
|
||||||
|
return json.dumps(
|
||||||
|
document,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def import_backup(
|
||||||
|
user: BackupUser,
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
replace_existing: bool,
|
||||||
|
) -> int:
|
||||||
|
document = _parse_backup(content)
|
||||||
|
tasks = [_build_task(user.id, item) for item in document["tasks"]]
|
||||||
|
|
||||||
|
if replace_existing:
|
||||||
|
existing_tasks = list(
|
||||||
|
db.session.scalars(
|
||||||
|
select(Task)
|
||||||
|
.where(Task.user_id == user.id)
|
||||||
|
.options(selectinload(Task.subtasks))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for task in existing_tasks:
|
||||||
|
db.session.delete(task)
|
||||||
|
|
||||||
|
_apply_settings(user, document["settings"])
|
||||||
|
db.session.add_all(tasks)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
raise TransferValidationError(
|
||||||
|
"The backup could not be restored safely. No changes were saved."
|
||||||
|
) from exc
|
||||||
|
return len(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_task(task: Task) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"title": task.title,
|
||||||
|
"description": task.description,
|
||||||
|
"status": task.status,
|
||||||
|
"priority": task.priority,
|
||||||
|
"created_at": _isoformat(task.created_at),
|
||||||
|
"due_date": _isoformat(task.due_date),
|
||||||
|
"completed_at": _isoformat(task.completed_at),
|
||||||
|
"timer_seconds": task.timer_seconds,
|
||||||
|
"context": task.context,
|
||||||
|
"chunk_sessions": task.chunk_sessions,
|
||||||
|
"subtasks": [
|
||||||
|
{"title": subtask.title, "completed": subtask.completed}
|
||||||
|
for subtask in task.subtasks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_backup(content: bytes) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
document = json.loads(content.decode("utf-8-sig"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise TransferValidationError("The backup is not valid UTF-8 JSON.") from exc
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
raise TransferValidationError("The backup root must be a JSON object.")
|
||||||
|
if document.get("schema_version") != BACKUP_SCHEMA_VERSION:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Only backup schema version {BACKUP_SCHEMA_VERSION} is supported."
|
||||||
|
)
|
||||||
|
raw_tasks = document.get("tasks")
|
||||||
|
if not isinstance(raw_tasks, list):
|
||||||
|
raise TransferValidationError("The backup must contain a tasks list.")
|
||||||
|
if len(raw_tasks) > MAX_BACKUP_TASKS:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"A backup may contain at most {MAX_BACKUP_TASKS} tasks."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"settings": _validate_settings(document.get("settings", {})),
|
||||||
|
"tasks": [
|
||||||
|
_validate_task(item, index)
|
||||||
|
for index, item in enumerate(raw_tasks, start=1)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_task(item: Any, index: int) -> dict[str, Any]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise TransferValidationError(f"Task {index} must be a JSON object.")
|
||||||
|
title = _bounded_string(item.get("title"), f"Task {index} title", 200)
|
||||||
|
description = _optional_string(
|
||||||
|
item.get("description"), f"Task {index} description", 5000
|
||||||
|
)
|
||||||
|
status = item.get("status", "not_started")
|
||||||
|
priority = item.get("priority", "normal")
|
||||||
|
if status not in TASK_STATUSES:
|
||||||
|
raise TransferValidationError(f"Task {index} has an unknown status.")
|
||||||
|
if priority not in TASK_PRIORITIES:
|
||||||
|
raise TransferValidationError(f"Task {index} has an unknown priority.")
|
||||||
|
|
||||||
|
timer_seconds = item.get("timer_seconds", 0)
|
||||||
|
if isinstance(timer_seconds, bool) or not isinstance(timer_seconds, int):
|
||||||
|
raise TransferValidationError(f"Task {index} timer must be whole seconds.")
|
||||||
|
if timer_seconds < 0 or timer_seconds > 10_800:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Task {index} timer is outside 0–10800 seconds."
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_subtasks = item.get("subtasks", [])
|
||||||
|
if not isinstance(raw_subtasks, list) or len(raw_subtasks) > 200:
|
||||||
|
raise TransferValidationError(f"Task {index} has an invalid subtask list.")
|
||||||
|
subtasks = [
|
||||||
|
_validate_subtask(subtask, index, subtask_index)
|
||||||
|
for subtask_index, subtask in enumerate(raw_subtasks, start=1)
|
||||||
|
]
|
||||||
|
chunks = _validate_chunks(item.get("chunk_sessions", []), index)
|
||||||
|
created_at = _optional_datetime(
|
||||||
|
item.get("created_at"), f"Task {index} created_at"
|
||||||
|
)
|
||||||
|
due_date = _optional_datetime(item.get("due_date"), f"Task {index} due_date")
|
||||||
|
completed_at = _optional_datetime(
|
||||||
|
item.get("completed_at"), f"Task {index} completed_at"
|
||||||
|
)
|
||||||
|
if status == "done" and completed_at is None:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Task {index} is done but has no completion date."
|
||||||
|
)
|
||||||
|
if status != "done" and completed_at is not None:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Task {index} is active but has a completion date."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"status": status,
|
||||||
|
"priority": priority,
|
||||||
|
"created_at": created_at,
|
||||||
|
"due_date": due_date,
|
||||||
|
"completed_at": completed_at,
|
||||||
|
"timer_seconds": timer_seconds,
|
||||||
|
"context": _optional_string(
|
||||||
|
item.get("context"), f"Task {index} context", 100
|
||||||
|
),
|
||||||
|
"chunk_sessions": chunks,
|
||||||
|
"subtasks": subtasks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_subtask(item: Any, task_index: int, index: int) -> dict[str, Any]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Subtask {index} of task {task_index} must be an object."
|
||||||
|
)
|
||||||
|
completed = item.get("completed", False)
|
||||||
|
if not isinstance(completed, bool):
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Subtask {index} of task {task_index} has invalid completion state."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"title": _bounded_string(
|
||||||
|
item.get("title"),
|
||||||
|
f"Subtask {index} of task {task_index} title",
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
"completed": completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_chunks(value: Any, task_index: int) -> list[dict[str, int | bool]]:
|
||||||
|
if not isinstance(value, list) or len(value) > 12:
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Task {task_index} has an invalid focus-block list."
|
||||||
|
)
|
||||||
|
chunks = []
|
||||||
|
for index, item in enumerate(value, start=1):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Focus block {index} of task {task_index} must be an object."
|
||||||
|
)
|
||||||
|
minutes = item.get("minutes")
|
||||||
|
completed = item.get("completed", False)
|
||||||
|
if (
|
||||||
|
isinstance(minutes, bool)
|
||||||
|
or not isinstance(minutes, int)
|
||||||
|
or minutes < 1
|
||||||
|
or minutes > 120
|
||||||
|
or not isinstance(completed, bool)
|
||||||
|
):
|
||||||
|
raise TransferValidationError(
|
||||||
|
f"Focus block {index} of task {task_index} is invalid."
|
||||||
|
)
|
||||||
|
chunks.append({"minutes": minutes, "completed": completed})
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_settings(value: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TransferValidationError("Backup settings must be a JSON object.")
|
||||||
|
settings: dict[str, Any] = {}
|
||||||
|
if "theme" in value:
|
||||||
|
if value["theme"] not in {"auto", "light", "dark"}:
|
||||||
|
raise TransferValidationError("The backup contains an unknown theme.")
|
||||||
|
settings["theme"] = value["theme"]
|
||||||
|
for name in ("completion_chime", "dyslexia_font"):
|
||||||
|
if name in value:
|
||||||
|
if not isinstance(value[name], bool):
|
||||||
|
raise TransferValidationError(f"The {name} setting must be true or false.")
|
||||||
|
settings[name] = value[name]
|
||||||
|
if "notification_frequency" in value:
|
||||||
|
if value["notification_frequency"] not in {"quiet", "moderate", "alert"}:
|
||||||
|
raise TransferValidationError(
|
||||||
|
"The backup contains an unknown notification frequency."
|
||||||
|
)
|
||||||
|
settings["notification_frequency"] = value["notification_frequency"]
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _build_task(user_id: int, item: dict[str, Any]) -> Task:
|
||||||
|
task = Task(
|
||||||
|
user_id=user_id,
|
||||||
|
title=item["title"],
|
||||||
|
description=item["description"],
|
||||||
|
status=item["status"],
|
||||||
|
priority=item["priority"],
|
||||||
|
due_date=item["due_date"],
|
||||||
|
completed_at=item["completed_at"],
|
||||||
|
timer_seconds=item["timer_seconds"],
|
||||||
|
context=item["context"],
|
||||||
|
chunk_sessions=item["chunk_sessions"],
|
||||||
|
)
|
||||||
|
if item["created_at"] is not None:
|
||||||
|
task.created_at = item["created_at"]
|
||||||
|
task.subtasks = [Subtask(**subtask) for subtask in item["subtasks"]]
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_settings(user: BackupUser, settings: dict[str, Any]) -> None:
|
||||||
|
for name, value in settings.items():
|
||||||
|
setattr(user, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_string(value: Any, label: str, maximum: int) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise TransferValidationError(f"{label} must be a non-empty string.")
|
||||||
|
cleaned = value.strip()
|
||||||
|
if len(cleaned) > maximum:
|
||||||
|
raise TransferValidationError(f"{label} exceeds {maximum} characters.")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: Any, label: str, maximum: int) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TransferValidationError(f"{label} must be text or null.")
|
||||||
|
if len(value) > maximum:
|
||||||
|
raise TransferValidationError(f"{label} exceeds {maximum} characters.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_datetime(value: Any, label: str) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TransferValidationError(f"{label} must be an ISO date-time or null.")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise TransferValidationError(f"{label} is not a valid ISO date-time.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _isoformat(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<a href="{{ url_for('tasks.focus') }}">Focus</a>
|
<a href="{{ url_for('tasks.focus') }}">Focus</a>
|
||||||
<a href="{{ url_for('tasks.today') }}">Today</a>
|
<a href="{{ url_for('tasks.today') }}">Today</a>
|
||||||
<a href="{{ url_for('tasks.index') }}">Tasks</a>
|
<a href="{{ url_for('tasks.index') }}">Tasks</a>
|
||||||
|
<a href="{{ url_for('tasks.transfer') }}">Data</a>
|
||||||
<span>Hi, {{ current_user.username }}</span>
|
<span>Hi, {{ current_user.username }}</span>
|
||||||
<form action="{{ url_for('auth.logout') }}" method="post">
|
<form action="{{ url_for('auth.logout') }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
|
||||||
90
app/templates/tasks/transfer.html
Normal file
90
app/templates/tasks/transfer.html
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Data & backups · Steady{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Portable and recoverable</p>
|
||||||
|
<h1>Data & backups</h1>
|
||||||
|
<p>Capture a list, download your data, or restore a Steady backup.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if undo_batch %}
|
||||||
|
<aside class="undo-banner" aria-labelledby="undo-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="undo-heading">Completed tasks were cleared</h2>
|
||||||
|
<p>They are safely hidden, not permanently deleted.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('tasks.undo_completed_clear', batch_id=undo_batch) }}">
|
||||||
|
{{ action_form.hidden_tag() }}
|
||||||
|
<button class="button" type="submit">Undo clear</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="transfer-grid">
|
||||||
|
<section class="transfer-card" aria-labelledby="text-import-heading">
|
||||||
|
<h2 id="text-import-heading">Import a task list</h2>
|
||||||
|
<p>Upload UTF-8 Markdown or plain text. Each non-empty line becomes a normal-priority task; headings and code blocks are ignored.</p>
|
||||||
|
<pre class="format-example"><code># Weekend
|
||||||
|
- [ ] Buy groceries
|
||||||
|
- [x] Book appointment
|
||||||
|
1. Water the plants</code></pre>
|
||||||
|
<p class="field__help">Maximum 200 tasks. Checked items import as completed.</p>
|
||||||
|
<form method="post" enctype="multipart/form-data" action="{{ url_for('tasks.import_markdown_file') }}">
|
||||||
|
{{ markdown_form.hidden_tag() }}
|
||||||
|
<div class="field">
|
||||||
|
{{ markdown_form.markdown_file.label }}
|
||||||
|
{{ markdown_form.markdown_file(accept=".md,.markdown,.txt,text/plain,text/markdown") }}
|
||||||
|
</div>
|
||||||
|
<div class="check-field">
|
||||||
|
{{ markdown_form.confirm() }} {{ markdown_form.confirm.label }}
|
||||||
|
</div>
|
||||||
|
{{ markdown_form.submit(class="button") }}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="transfer-card" aria-labelledby="backup-heading">
|
||||||
|
<h2 id="backup-heading">Download a backup</h2>
|
||||||
|
<p>Export active tasks, subtasks, focus setup, completion history, and preferences as versioned JSON.</p>
|
||||||
|
<p class="field__help">Credentials, email, internal IDs, and cleared tasks are never included.</p>
|
||||||
|
<a class="button" href="{{ url_for('tasks.export_json') }}">Download JSON backup</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="transfer-card" aria-labelledby="restore-heading">
|
||||||
|
<h2 id="restore-heading">Restore a backup</h2>
|
||||||
|
<p>Merge a Steady JSON backup into this account. The complete file is validated before anything changes.</p>
|
||||||
|
<form method="post" enctype="multipart/form-data" action="{{ url_for('tasks.import_json') }}">
|
||||||
|
{{ backup_form.hidden_tag() }}
|
||||||
|
<div class="field">
|
||||||
|
{{ backup_form.backup_file.label }}
|
||||||
|
{{ backup_form.backup_file(accept=".json,application/json") }}
|
||||||
|
</div>
|
||||||
|
<div class="check-field check-field--warning">
|
||||||
|
{{ backup_form.replace_existing() }} {{ backup_form.replace_existing.label }}
|
||||||
|
<small>Replacement removes this account's current tasks only after the backup passes validation.</small>
|
||||||
|
</div>
|
||||||
|
<div class="check-field">
|
||||||
|
{{ backup_form.confirm() }} {{ backup_form.confirm.label }}
|
||||||
|
</div>
|
||||||
|
{{ backup_form.submit(class="button") }}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="transfer-card" aria-labelledby="clear-heading">
|
||||||
|
<h2 id="clear-heading">Clear completed tasks</h2>
|
||||||
|
<p>Hide completed work from active views. Your streak history remains intact.</p>
|
||||||
|
<details>
|
||||||
|
<summary>Show clear action</summary>
|
||||||
|
<p>You will receive an undo action immediately afterward.</p>
|
||||||
|
<form method="post" action="{{ url_for('tasks.clear_completed') }}">
|
||||||
|
{{ action_form.hidden_tag() }}
|
||||||
|
<button class="button button--quiet" type="submit">Clear completed tasks</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -21,6 +21,7 @@ class Config:
|
||||||
REMEMBER_COOKIE_SAMESITE = "Lax"
|
REMEMBER_COOKIE_SAMESITE = "Lax"
|
||||||
REMEMBER_COOKIE_SECURE = True
|
REMEMBER_COOKIE_SECURE = True
|
||||||
WTF_CSRF_TIME_LIMIT = 3600
|
WTF_CSRF_TIME_LIMIT = 3600
|
||||||
|
MAX_CONTENT_LENGTH = 2 * 1024 * 1024
|
||||||
|
|
||||||
FEATURE_ADMIN = False
|
FEATURE_ADMIN = False
|
||||||
FEATURE_API_V1 = False
|
FEATURE_API_V1 = False
|
||||||
|
|
@ -51,4 +52,3 @@ CONFIGS = {
|
||||||
"testing": TestingConfig,
|
"testing": TestingConfig,
|
||||||
"production": ProductionConfig,
|
"production": ProductionConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,10 +240,10 @@ a,
|
||||||
- [x] Forgiving Streak System (No Guilt) [1]
|
- [x] Forgiving Streak System (No Guilt) [1]
|
||||||
|
|
||||||
### Phase 4: Import/Export & Backup (Week 7)
|
### Phase 4: Import/Export & Backup (Week 7)
|
||||||
- [ ] Markdown File Upload & Bulk Task Import [2]
|
- [x] Markdown File Upload & Bulk Task Import [2]
|
||||||
- [ ] JSON Export (Backup) [2]
|
- [x] JSON Export (Backup) [2]
|
||||||
- [ ] JSON Import (Restore) [2]
|
- [x] JSON Import (Restore) [2]
|
||||||
- [ ] Clear Completed Tasks (Undoable) [2]
|
- [x] Clear Completed Tasks (Undoable) [2]
|
||||||
|
|
||||||
### Phase 5: Settings & Personalization (Week 8)
|
### Phase 5: Settings & Personalization (Week 8)
|
||||||
- [ ] Theme Toggle (Light/Dark/Auto) [2]
|
- [ ] Theme Toggle (Light/Dark/Auto) [2]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Add reversible task clearing
|
||||||
|
|
||||||
|
Revision ID: 5ad737bbca5e
|
||||||
|
Revises: f9ae19443d53
|
||||||
|
Create Date: 2026-08-08 03:11:59.276494
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5ad737bbca5e'
|
||||||
|
down_revision = 'f9ae19443d53'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('task', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('cleared_at', sa.DateTime(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('clear_batch_id', sa.String(length=36), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_task_clear_batch_id'), ['clear_batch_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('task', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_task_clear_batch_id'))
|
||||||
|
batch_op.drop_column('clear_batch_id')
|
||||||
|
batch_op.drop_column('cleared_at')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
72
progress.md
72
progress.md
|
|
@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
- Current phase: Phase 3 — ADHD-Specific Features
|
- Current phase: Phase 4 — Import, Export & Backup
|
||||||
- Status: Complete
|
- Status: Complete
|
||||||
- Last updated: 2026-08-08
|
- Last updated: 2026-08-08
|
||||||
- Next phase: Phase 4 — Import, Export & Backup
|
- Next phase: Phase 5 — Settings & Personalization
|
||||||
|
|
||||||
## Phase 1 — Core Infrastructure
|
## Phase 1 — Core Infrastructure
|
||||||
|
|
||||||
|
|
@ -176,6 +176,72 @@ No broken Python requirements were found.
|
||||||
Line-length, whitespace, and git diff checks completed successfully.
|
Line-length, whitespace, and git diff checks completed successfully.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Phase 4 — Import, Export & Backup
|
||||||
|
|
||||||
|
### Phase 4.1 — Reversible completed-task clearing
|
||||||
|
|
||||||
|
Added soft-clear timestamps and user-scoped batch identifiers to tasks. Normal task queries now exclude cleared records, while streak history intentionally retains their completions. Added services to clear all currently completed tasks in one batch and restore only that user's matching batch. Upload requests are globally limited to 2 MiB.
|
||||||
|
|
||||||
|
Why: “clear” should not mean immediate permanent deletion. Batch identifiers create a precise undo target without exposing sequential database IDs, and ownership is enforced again during recovery. Retaining completion timestamps preserves the user's forgiving streak after tidying the task list.
|
||||||
|
|
||||||
|
Next: migrate the schema, then implement bounded and atomic import/export services.
|
||||||
|
|
||||||
|
The `Add reversible task clearing` migration was generated, reviewed, applied, and followed by a green 26-test regression run.
|
||||||
|
|
||||||
|
### Phase 4.2 — Validated transfer services
|
||||||
|
|
||||||
|
Added UTF-8 Markdown/plain-text parsing for headings, bullets, numbered items, and checklists, with a 200-task and 200-character-title limit. Bulk creation commits once after the entire document validates.
|
||||||
|
|
||||||
|
Added a versioned JSON backup format containing active tasks, subtasks, focus setup, completion history, and user preferences—but no password hash, email address, database IDs, or ownership fields. Restore validates the complete document, bounds nested collections and values, assigns every task to the current user, and supports merge or replace behavior in one transaction.
|
||||||
|
|
||||||
|
Why: uploads are untrusted input. Full validation before mutation prevents partial imports, explicit schema versions allow safe evolution, and omitting identity/security fields prevents a backup from changing account ownership or credentials. Atomic restore ensures failure leaves existing data intact.
|
||||||
|
|
||||||
|
Next: add CSRF-protected forms and authenticated routes for import, export, clear, and undo.
|
||||||
|
|
||||||
|
### Phase 4.3 — Protected transfer routes
|
||||||
|
|
||||||
|
Added an authenticated data-transfer hub plus separate routes for Markdown import, JSON download, JSON restore, clearing completed tasks, and undoing a specific clear batch. All uploads and state changes require validated Flask-WTF forms and CSRF; downloads contain only the current user's active data. Undo route identifiers use Flask's UUID converter and are rechecked against the current user in the service.
|
||||||
|
|
||||||
|
Why: separating each mutation keeps permissions and error handling explicit. Downloads remain GET because they do not change server state; import, replace, clear, and undo remain POST-only. File names and ownership values from uploads are never used to select database records.
|
||||||
|
|
||||||
|
Next: build the instructional transfer interface and visible undo recovery control.
|
||||||
|
|
||||||
|
### Phase 4.4 — Transfer and recovery interface
|
||||||
|
|
||||||
|
Added a responsive Data & Backups hub with the supported Markdown format, explicit limits, JSON privacy scope, merge/replace distinction, confirmation controls, and a disclosed completed-task clear action. After clearing, a prominent recovery banner explains that tasks are hidden and provides a CSRF-protected undo button. Authenticated navigation now links to the hub.
|
||||||
|
|
||||||
|
Why: import and replacement have different risk profiles, so their consequences are explained next to the action. File inputs advertise accepted formats without treating browser hints as security validation. Clear remains visually available but not effortless to trigger accidentally, and recovery is surfaced immediately.
|
||||||
|
|
||||||
|
Next: test parser limits, atomic restore, privacy, ownership, replacement, clear/undo, and upload routes.
|
||||||
|
|
||||||
|
### Phase 4.5 — Automated coverage
|
||||||
|
|
||||||
|
Added tests for Markdown syntax, encoding and size limits, confirmed upload behavior, versioned/private JSON output, nested backup round trips, JSON upload ownership, invalid-replace atomicity, user-isolated replacement, streak-preserving soft clear, foreign-user undo rejection, and browser-route recovery.
|
||||||
|
|
||||||
|
During the quality pass, Markdown database failures gained explicit rollback handling, and backup validation now rejects inconsistent task states such as completed tasks without completion timestamps.
|
||||||
|
|
||||||
|
Why: rollback behavior keeps the SQLAlchemy session usable after failure, while state/date consistency preserves streak and status meaning after restore. Testing the upload route proves ownership is assigned from the active session rather than merely working in direct service calls.
|
||||||
|
|
||||||
|
Next: run the complete regression, migration, route, dependency, compilation, JavaScript, line-length, whitespace, and patch checks.
|
||||||
|
|
||||||
|
### Phase 4.6 — Final verification
|
||||||
|
|
||||||
|
The first full verification exposed a misplaced date-validation block in the JSON serializer. Four transfer tests failed while 33 passed. The validation was moved into `_validate_task`, the focused 11-test transfer suite passed, and then the complete verification was repeated successfully.
|
||||||
|
|
||||||
|
Why: structurally similar dictionary-return blocks made a broad patch match the wrong function. Running focused tests after the correction provided fast evidence for the affected subsystem before the final regression. Recording this makes the progress log reflect corrective work as well as completed features.
|
||||||
|
|
||||||
|
Final results:
|
||||||
|
|
||||||
|
```text
|
||||||
|
37 tests passed in 3.79s
|
||||||
|
All 11 focused transfer tests passed.
|
||||||
|
Alembic detected no pending model operations.
|
||||||
|
Flask discovered all expected routes.
|
||||||
|
Python and JavaScript syntax validation completed successfully.
|
||||||
|
No broken Python requirements were found.
|
||||||
|
Line-length, whitespace, and git diff checks completed successfully.
|
||||||
|
```
|
||||||
|
|
||||||
## Next Work
|
## Next Work
|
||||||
|
|
||||||
Phase 4 will add safe Markdown bulk capture, versioned JSON backup export and restore, and undoable clearing of completed tasks. Import formats must be validated, ownership must remain explicit, and destructive operations must provide a recovery path.
|
Phase 5 will expose the existing theme, dyslexia-font, completion-chime, and notification preferences through an authenticated settings feature. It will also add the browser-reminder placeholder without requesting notification permission prematurely.
|
||||||
|
|
|
||||||
278
tests/tasks/test_transfer.py
Normal file
278
tests/tasks/test_transfer.py
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.auth.services import create_user
|
||||||
|
from app.tasks.models import Subtask, Task
|
||||||
|
from app.tasks.services import (
|
||||||
|
calculate_forgiving_streak,
|
||||||
|
clear_completed_tasks,
|
||||||
|
complete_task,
|
||||||
|
create_task,
|
||||||
|
get_task,
|
||||||
|
list_tasks,
|
||||||
|
undo_clear_completed,
|
||||||
|
)
|
||||||
|
from app.tasks.transfer import (
|
||||||
|
TransferValidationError,
|
||||||
|
export_backup,
|
||||||
|
import_backup,
|
||||||
|
parse_markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def login(client, email: str) -> None:
|
||||||
|
client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"email": email, "password": "secure password"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_task(user_id: int, title: str, *, status: str = "not_started") -> Task:
|
||||||
|
return create_task(
|
||||||
|
user_id,
|
||||||
|
title=title,
|
||||||
|
description=None,
|
||||||
|
status=status,
|
||||||
|
priority="normal",
|
||||||
|
due_date=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_parser_supports_lists_checkboxes_and_plain_text():
|
||||||
|
content = b"""# Weekend
|
||||||
|
- [ ] Buy groceries
|
||||||
|
- [x] Book appointment
|
||||||
|
1. Water the plants
|
||||||
|
```
|
||||||
|
- Ignore example code
|
||||||
|
```
|
||||||
|
Call a friend
|
||||||
|
"""
|
||||||
|
|
||||||
|
tasks = parse_markdown(content)
|
||||||
|
|
||||||
|
assert [task.title for task in tasks] == [
|
||||||
|
"Buy groceries",
|
||||||
|
"Book appointment",
|
||||||
|
"Water the plants",
|
||||||
|
"Call a friend",
|
||||||
|
]
|
||||||
|
assert [task.status for task in tasks] == [
|
||||||
|
"not_started",
|
||||||
|
"done",
|
||||||
|
"not_started",
|
||||||
|
"not_started",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_parser_rejects_invalid_or_oversized_input():
|
||||||
|
with pytest.raises(TransferValidationError, match="UTF-8"):
|
||||||
|
parse_markdown(b"\xff")
|
||||||
|
with pytest.raises(TransferValidationError, match="200-character"):
|
||||||
|
parse_markdown(("- " + "a" * 201).encode())
|
||||||
|
with pytest.raises(TransferValidationError, match="at most 200"):
|
||||||
|
parse_markdown("\n".join(f"- Task {i}" for i in range(201)).encode())
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_upload_imports_atomically(client, db):
|
||||||
|
user = create_user("text_user", "text@example.com", "secure password")
|
||||||
|
login(client, user.email)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/tasks/import/markdown",
|
||||||
|
data={
|
||||||
|
"markdown_file": (BytesIO(b"- First\n- [x] Second"), "tasks.md"),
|
||||||
|
"confirm": "y",
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
tasks = list(db.session.scalars(select(Task).order_by(Task.id)))
|
||||||
|
|
||||||
|
assert b"Imported 2 tasks" in response.data
|
||||||
|
assert [task.user_id for task in tasks] == [user.id, user.id]
|
||||||
|
assert [task.status for task in tasks] == ["not_started", "done"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_upload_requires_confirmation(client, db):
|
||||||
|
user = create_user("confirm_user", "confirm@example.com", "secure password")
|
||||||
|
login(client, user.email)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/tasks/import/markdown",
|
||||||
|
data={"markdown_file": (BytesIO(b"- Not imported"), "tasks.md")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert b"This field is required" in response.data
|
||||||
|
assert db.session.scalar(select(Task)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_export_is_versioned_complete_and_private(client, db):
|
||||||
|
user = create_user("backup_user", "backup@example.com", "secure password")
|
||||||
|
user.theme = "dark"
|
||||||
|
task = make_task(user.id, "Portable task")
|
||||||
|
task.description = "Useful notes"
|
||||||
|
task.timer_seconds = 600
|
||||||
|
task.context = "Quiet desk"
|
||||||
|
task.chunk_sessions = [{"minutes": 10, "completed": True}]
|
||||||
|
task.subtasks.append(Subtask(title="Portable step", completed=True))
|
||||||
|
db.session.commit()
|
||||||
|
login(client, user.email)
|
||||||
|
|
||||||
|
response = client.get("/tasks/export/json")
|
||||||
|
document = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == "application/json"
|
||||||
|
assert "attachment" in response.headers["Content-Disposition"]
|
||||||
|
assert document["schema_version"] == 1
|
||||||
|
assert document["settings"]["theme"] == "dark"
|
||||||
|
assert document["tasks"][0]["title"] == "Portable task"
|
||||||
|
assert document["tasks"][0]["subtasks"][0]["title"] == "Portable step"
|
||||||
|
serialized = response.data.decode()
|
||||||
|
assert user.email not in serialized
|
||||||
|
assert "password" not in serialized
|
||||||
|
assert "user_id" not in serialized
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_backup_round_trip_restores_nested_data_and_settings(db):
|
||||||
|
source = create_user("source", "source@example.com", "secure password")
|
||||||
|
source.completion_chime = False
|
||||||
|
task = make_task(source.id, "Round-trip task")
|
||||||
|
task.chunk_sessions = [{"minutes": 5, "completed": True}]
|
||||||
|
task.subtasks.append(Subtask(title="Nested step", completed=True))
|
||||||
|
db.session.commit()
|
||||||
|
content = export_backup(source)
|
||||||
|
|
||||||
|
target = create_user("target", "target@example.com", "secure password")
|
||||||
|
count = import_backup(target, content, replace_existing=False)
|
||||||
|
restored = db.session.scalar(select(Task).where(Task.user_id == target.id))
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert restored.title == "Round-trip task"
|
||||||
|
assert restored.chunk_sessions == [{"minutes": 5, "completed": True}]
|
||||||
|
assert restored.subtasks[0].title == "Nested step"
|
||||||
|
assert target.completion_chime is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_upload_route_assigns_tasks_to_current_user(client, db):
|
||||||
|
user = create_user("route_user", "route@example.com", "secure password")
|
||||||
|
login(client, user.email)
|
||||||
|
document = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"settings": {"theme": "light"},
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"title": "Uploaded backup task",
|
||||||
|
"status": "not_started",
|
||||||
|
"priority": "important",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/tasks/import/json",
|
||||||
|
data={
|
||||||
|
"backup_file": (
|
||||||
|
BytesIO(json.dumps(document).encode()),
|
||||||
|
"steady.json",
|
||||||
|
),
|
||||||
|
"confirm": "y",
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
restored = db.session.scalar(select(Task))
|
||||||
|
|
||||||
|
assert b"Merged 1 tasks from backup" in response.data
|
||||||
|
assert restored.user_id == user.id
|
||||||
|
assert restored.priority == "important"
|
||||||
|
assert user.theme == "light"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_replace_backup_leaves_existing_data_unchanged(db):
|
||||||
|
user = create_user("atomic_user", "atomic@example.com", "secure password")
|
||||||
|
existing = make_task(user.id, "Must survive")
|
||||||
|
invalid = json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"settings": {"theme": "unknown"},
|
||||||
|
"tasks": [{"title": "Would replace"}],
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
with pytest.raises(TransferValidationError, match="unknown theme"):
|
||||||
|
import_backup(user, invalid, replace_existing=True)
|
||||||
|
|
||||||
|
assert db.session.get(Task, existing.id) is not None
|
||||||
|
assert user.theme == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_backup_affects_only_current_user(db):
|
||||||
|
source = create_user("replace_source", "replace-source@example.com", "secure password")
|
||||||
|
make_task(source.id, "Replacement task")
|
||||||
|
backup = export_backup(source)
|
||||||
|
|
||||||
|
target = create_user("replace_target", "replace-target@example.com", "secure password")
|
||||||
|
old_target = make_task(target.id, "Old target task")
|
||||||
|
other = create_user("other_user", "other@example.com", "secure password")
|
||||||
|
other_task = make_task(other.id, "Other user's task")
|
||||||
|
|
||||||
|
import_backup(target, backup, replace_existing=True)
|
||||||
|
|
||||||
|
assert db.session.get(Task, old_target.id) is None
|
||||||
|
assert db.session.get(Task, other_task.id) is not None
|
||||||
|
assert [task.title for task in list_tasks(target.id)] == ["Replacement task"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_and_undo_are_scoped_and_preserve_streak(db):
|
||||||
|
user = create_user("clear_user", "clear@example.com", "secure password")
|
||||||
|
completed = make_task(user.id, "Completed work")
|
||||||
|
complete_task(completed, completed_at=datetime(2026, 8, 8, 12, 0))
|
||||||
|
active = make_task(user.id, "Active work")
|
||||||
|
other = create_user("clear_other", "clear-other@example.com", "secure password")
|
||||||
|
other_completed = make_task(other.id, "Other completion", status="done")
|
||||||
|
|
||||||
|
batch_id, count = clear_completed_tasks(user.id)
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert [task.id for task in list_tasks(user.id)] == [active.id]
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
get_task(completed.id, user.id)
|
||||||
|
assert calculate_forgiving_streak(
|
||||||
|
user.id, today=date(2026, 8, 8)
|
||||||
|
).days == 1
|
||||||
|
assert db.session.get(Task, other_completed.id).cleared_at is None
|
||||||
|
|
||||||
|
assert undo_clear_completed(other.id, batch_id) == 0
|
||||||
|
assert undo_clear_completed(user.id, batch_id) == 1
|
||||||
|
assert db.session.get(Task, completed.id).cleared_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_route_surfaces_working_undo_action(client, db):
|
||||||
|
user = create_user("undo_user", "undo@example.com", "secure password")
|
||||||
|
completed = make_task(user.id, "Undoable completion", status="done")
|
||||||
|
login(client, user.email)
|
||||||
|
|
||||||
|
clear_response = client.post(
|
||||||
|
"/tasks/completed/clear",
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
db.session.refresh(completed)
|
||||||
|
|
||||||
|
assert b"Undo clear" in clear_response.data
|
||||||
|
assert completed.cleared_at is not None
|
||||||
|
|
||||||
|
undo_response = client.post(
|
||||||
|
f"/tasks/completed/undo/{completed.clear_batch_id}",
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
db.session.refresh(completed)
|
||||||
|
assert b"Restored 1 completed tasks" in undo_response.data
|
||||||
|
assert completed.cleared_at is None
|
||||||
Loading…
Add table
Reference in a new issue