feat: tasks app, security hardening, mobile fixes, iOS app shell

- Custom SQLite task manager replacing TickTick wrapper
- 73 tasks migrated from TickTick across 15 projects
- RRULE recurrence engine with lazy materialization
- Dashboard tasks widget (desktop sidebar + mobile card)
- Tasks page with project tabs, add/edit/complete/delete
- Security: locked ports to localhost, removed old containers
- Gitea Actions runner configured and all 3 CI jobs passing
- Fixed mobile overflow on dashboard cards
- iOS Capacitor app shell (Second Brain)
- Frontend/backend guide docs for adding new services
- TickTick Google Calendar sync re-authorized

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-03-30 15:35:57 -05:00
parent 877021ff20
commit 6023ebf9d0
49 changed files with 5207 additions and 23 deletions

View File

@@ -0,0 +1,176 @@
"""One-time migration: pull all TickTick tasks into the custom task manager SQLite DB."""
import json
import os
import sqlite3
import uuid
import urllib.request
from pathlib import Path
TICKTICK_TOKEN = os.environ.get("TICKTICK_ACCESS_TOKEN", "")
# Handle JSON-wrapped token
try:
parsed = json.loads(TICKTICK_TOKEN)
TICKTICK_TOKEN = parsed.get("access_token", TICKTICK_TOKEN)
except (json.JSONDecodeError, TypeError):
pass
TICKTICK_BASE = "https://api.ticktick.com/open/v1"
DB_PATH = Path(os.environ.get("DB_PATH", "/app/data/tasks.db"))
GATEWAY_USER_ID = os.environ.get("GATEWAY_USER_ID", "3") # Yusuf's gateway user ID
GATEWAY_USER_NAME = os.environ.get("GATEWAY_USER_NAME", "Yusuf")
def tt_request(path):
url = f"{TICKTICK_BASE}/{path.lstrip('/')}"
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {TICKTICK_TOKEN}")
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
def get_db():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
return conn
def migrate():
if not TICKTICK_TOKEN:
print("ERROR: TICKTICK_ACCESS_TOKEN not set")
return
conn = get_db()
c = conn.cursor()
# Ensure user exists
existing = c.execute("SELECT id FROM users WHERE id = ?", (GATEWAY_USER_ID,)).fetchone()
if not existing:
c.execute("INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)",
(GATEWAY_USER_ID, GATEWAY_USER_NAME.lower(), GATEWAY_USER_NAME))
conn.commit()
# Ensure Inbox exists
inbox = c.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (GATEWAY_USER_ID,)).fetchone()
if not inbox:
inbox_id = str(uuid.uuid4())
c.execute("INSERT INTO projects (id, user_id, name, is_inbox, sort_order) VALUES (?, ?, 'Inbox', 1, -1)",
(inbox_id, GATEWAY_USER_ID))
conn.commit()
inbox = c.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (GATEWAY_USER_ID,)).fetchone()
inbox_id = inbox["id"]
# Fetch TickTick projects
print("Fetching TickTick projects...")
tt_projects = tt_request("/project")
print(f" Found {len(tt_projects)} projects")
# Map TickTick project IDs to our project IDs
project_map = {} # tt_project_id -> our_project_id
for tp in tt_projects:
tt_id = tp["id"]
name = tp.get("name", "Untitled")
# Check if we already migrated this project (by name match)
existing_proj = c.execute("SELECT id FROM projects WHERE user_id = ? AND name = ? AND is_inbox = 0",
(GATEWAY_USER_ID, name)).fetchone()
if existing_proj:
project_map[tt_id] = existing_proj["id"]
print(f" Project '{name}' already exists, skipping creation")
else:
new_id = str(uuid.uuid4())
is_shared = 1 if any(kw in name.lower() for kw in ["family", "shared"]) else 0
c.execute("INSERT INTO projects (id, user_id, name, is_shared, sort_order) VALUES (?, ?, ?, ?, ?)",
(new_id, GATEWAY_USER_ID, name, is_shared, tp.get("sortOrder", 0)))
project_map[tt_id] = new_id
print(f" Created project '{name}' (shared={is_shared})")
conn.commit()
# Fetch all tasks from each project + inbox
all_tasks = []
# Inbox
print("Fetching Inbox tasks...")
try:
inbox_data = tt_request("/project/inbox/data")
inbox_tasks = inbox_data.get("tasks", [])
for t in inbox_tasks:
t["_our_project_id"] = inbox_id
t["_project_name"] = "Inbox"
all_tasks.extend(inbox_tasks)
print(f" Inbox: {len(inbox_tasks)} tasks")
except Exception as e:
print(f" Inbox error: {e}")
# Other projects
for tp in tt_projects:
tt_id = tp["id"]
name = tp.get("name", "?")
try:
data = tt_request(f"/project/{tt_id}/data")
tasks = data.get("tasks", [])
for t in tasks:
t["_our_project_id"] = project_map.get(tt_id, inbox_id)
t["_project_name"] = name
all_tasks.extend(tasks)
print(f" {name}: {len(tasks)} tasks")
except Exception as e:
print(f" {name} error: {e}")
print(f"\nTotal tasks to migrate: {len(all_tasks)}")
# Insert tasks
migrated = 0
skipped = 0
for t in all_tasks:
title = t.get("title", "").strip()
if not title:
skipped += 1
continue
# Check for duplicate by title + project
existing_task = c.execute(
"SELECT id FROM tasks WHERE title = ? AND project_id = ? AND user_id = ?",
(title, t["_our_project_id"], GATEWAY_USER_ID)).fetchone()
if existing_task:
skipped += 1
continue
task_id = str(uuid.uuid4())
status = t.get("status", 0)
completed_at = None
if status != 0:
completed_at = t.get("completedTime") or t.get("modifiedTime")
c.execute("""INSERT INTO tasks (id, project_id, user_id, title, content, status, priority,
start_date, due_date, is_all_day, completed_at, repeat_flag, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(task_id, t["_our_project_id"], GATEWAY_USER_ID, title,
t.get("content", ""),
status,
t.get("priority", 0),
t.get("startDate"),
t.get("dueDate"),
1 if t.get("isAllDay", True) else 0,
completed_at,
t.get("repeatFlag"),
t.get("sortOrder", 0),
t.get("createdTime") or t.get("modifiedTime")))
migrated += 1
conn.commit()
conn.close()
print(f"\nMigration complete!")
print(f" Migrated: {migrated} tasks")
print(f" Skipped: {skipped} (duplicates or empty)")
print(f" Projects: {len(project_map) + 1} (including Inbox)")
if __name__ == "__main__":
migrate()