This commit is contained in:
patsy 2026-08-08 03:26:12 +02:00
parent e651369587
commit 67e35bcffd
14 changed files with 1174 additions and 14 deletions

View file

@ -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
seven-item Today view, status filters, priorities, progress indicators, and a
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
@ -40,3 +41,15 @@ python -m pytest
Tests use an isolated in-memory SQLite database and never write to the local
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.

View file

@ -538,6 +538,60 @@ input:focus-visible {
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 {
from {
opacity: 0;
@ -628,4 +682,13 @@ input {
.streak-card {
max-width: none;
}
.transfer-grid {
grid-template-columns: 1fr;
}
.undo-banner {
align-items: flex-start;
flex-direction: column;
}
}

View file

@ -1,7 +1,21 @@
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.validators import Length, NumberRange, Optional, ValidationError
from wtforms.validators import (
DataRequired,
Length,
NumberRange,
Optional,
ValidationError,
)
from .models import TASK_PRIORITIES, TASK_STATUSES
@ -100,3 +114,33 @@ class FocusSettingsForm(FlaskForm):
int(value.strip())
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")

View file

@ -34,6 +34,8 @@ class Task(db.Model):
)
due_date: 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(
ForeignKey("user.id", ondelete="CASCADE"), index=True
)

View file

@ -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 . import bp
from .forms import (
ActionForm,
BackupImportForm,
FocusSettingsForm,
MarkdownImportForm,
STATUS_LABELS,
SubtaskForm,
TaskForm,
@ -14,6 +28,7 @@ from .services import (
TaskNotFoundError,
add_subtask,
calculate_forgiving_streak,
clear_completed_tasks,
complete_task,
configure_focus,
create_task,
@ -23,8 +38,15 @@ from .services import (
list_today_tasks,
toggle_chunk_session,
toggle_subtask,
undo_clear_completed,
update_task,
)
from .transfer import (
TransferValidationError,
export_backup,
import_backup,
import_markdown,
)
def _owned_task_or_404(task_id: int):
@ -233,3 +255,106 @@ def complete(task_id: int):
complete_task(task)
flash(f"You completed {completed_title}. That counts.", "success")
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")

View file

@ -1,6 +1,7 @@
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import case, or_, select
from sqlalchemy.orm import selectinload
@ -23,7 +24,7 @@ class ForgivingStreak:
def _task_query(user_id: int):
return (
select(Task)
.where(Task.user_id == user_id)
.where(Task.user_id == user_id, Task.cleared_at.is_(None))
.options(selectinload(Task.subtasks))
)
@ -217,7 +218,11 @@ def calculate_forgiving_streak(
current_day = today or datetime.now().astimezone().date()
# 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:
return ForgivingStreak(days=0, freeze_used=False)
@ -238,6 +243,45 @@ def calculate_forgiving_streak(
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:
if status not in TASK_STATUSES:
raise ValueError("Unknown task status.")

398
app/tasks/transfer.py Normal file
View 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 010800 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

View file

@ -18,6 +18,7 @@
<a href="{{ url_for('tasks.focus') }}">Focus</a>
<a href="{{ url_for('tasks.today') }}">Today</a>
<a href="{{ url_for('tasks.index') }}">Tasks</a>
<a href="{{ url_for('tasks.transfer') }}">Data</a>
<span>Hi, {{ current_user.username }}</span>
<form action="{{ url_for('auth.logout') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View 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 &amp; 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 %}

View file

@ -21,6 +21,7 @@ class Config:
REMEMBER_COOKIE_SAMESITE = "Lax"
REMEMBER_COOKIE_SECURE = True
WTF_CSRF_TIME_LIMIT = 3600
MAX_CONTENT_LENGTH = 2 * 1024 * 1024
FEATURE_ADMIN = False
FEATURE_API_V1 = False
@ -51,4 +52,3 @@ CONFIGS = {
"testing": TestingConfig,
"production": ProductionConfig,
}

View file

@ -240,10 +240,10 @@ a,
- [x] Forgiving Streak System (No Guilt) [1]
### Phase 4: Import/Export & Backup (Week 7)
- [ ] Markdown File Upload & Bulk Task Import [2]
- [ ] JSON Export (Backup) [2]
- [ ] JSON Import (Restore) [2]
- [ ] Clear Completed Tasks (Undoable) [2]
- [x] Markdown File Upload & Bulk Task Import [2]
- [x] JSON Export (Backup) [2]
- [x] JSON Import (Restore) [2]
- [x] Clear Completed Tasks (Undoable) [2]
### Phase 5: Settings & Personalization (Week 8)
- [ ] Theme Toggle (Light/Dark/Auto) [2]

View file

@ -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 ###

View file

@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf
## Current Status
- Current phase: Phase 3 — ADHD-Specific Features
- Current phase: Phase 4 — Import, Export & Backup
- Status: Complete
- Last updated: 2026-08-08
- Next phase: Phase 4 — Import, Export & Backup
- Next phase: Phase 5 — Settings & Personalization
## Phase 1 — Core Infrastructure
@ -176,6 +176,72 @@ No broken Python requirements were found.
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
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.

View 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