From 748ee8ae7563be18786b5d03948e0254e656fcd7 Mon Sep 17 00:00:00 2001 From: perco Date: Thu, 30 Apr 2026 14:03:11 +0200 Subject: [PATCH] Initial commit: memo app with FastAPI, SQLite, APScheduler, Discord notifications --- .gitignore | 6 ++ Dockerfile | 18 ++++ app/__init__.py | 0 app/database.py | 39 ++++++++ app/discord_notify.py | 28 ++++++ app/main.py | 130 +++++++++++++++++++++++++++ app/scheduler.py | 24 +++++ requirements.txt | 5 ++ static/app.js | 202 ++++++++++++++++++++++++++++++++++++++++++ static/style.css | 198 +++++++++++++++++++++++++++++++++++++++++ templates/index.html | 77 ++++++++++++++++ 11 files changed, 727 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/database.py create mode 100644 app/discord_notify.py create mode 100644 app/main.py create mode 100644 app/scheduler.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..928f501 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.pyo +data/ +*.db +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..86a8bf2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY static/ ./static/ +COPY templates/ ./templates/ + +VOLUME ["/data"] + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..6586873 --- /dev/null +++ b/app/database.py @@ -0,0 +1,39 @@ +import sqlite3 +import os +from contextlib import contextmanager + +DB_PATH = os.environ.get("DB_PATH", "/data/memo.db") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS memos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT DEFAULT '', + reminder_time TEXT NOT NULL DEFAULT '09:00', + created_at TEXT DEFAULT (datetime('now', 'localtime')), + done_at TEXT, + is_done INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); + +INSERT OR IGNORE INTO settings (key, value) VALUES ('discord_webhook', ''); +INSERT OR IGNORE INTO settings (key, value) VALUES ('timezone', 'Europe/Paris'); +""" + +@contextmanager +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + +def init_db(): + with get_db() as conn: + conn.executescript(SCHEMA) diff --git a/app/discord_notify.py b/app/discord_notify.py new file mode 100644 index 0000000..613121f --- /dev/null +++ b/app/discord_notify.py @@ -0,0 +1,28 @@ +import requests +from .database import get_db + +def get_webhook_url() -> str: + with get_db() as conn: + row = conn.execute("SELECT value FROM settings WHERE key = 'discord_webhook'").fetchone() + return row["value"] if row else "" + +def send_reminder(memo: dict): + url = get_webhook_url() + if not url: + return + + description = memo["description"] or "" + desc_line = f"\n> {description}" if description else "" + + payload = { + "embeds": [{ + "title": f"🔔 Rappel : {memo['title']}", + "description": f"{desc_line}\n📅 Créé le {memo['created_at'][:10]}", + "color": 0xf59e0b, + "footer": {"text": "memo.nas.percolouco.com"} + }] + } + try: + requests.post(url, json=payload, timeout=5) + except Exception: + pass diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ba34b3e --- /dev/null +++ b/app/main.py @@ -0,0 +1,130 @@ +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +from typing import Optional +import os + +from .database import init_db, get_db +from . import scheduler as sched + +app = FastAPI(title="Memo") + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") +templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) + +@app.on_event("startup") +def startup(): + init_db() + sched.start() + +@app.on_event("shutdown") +def shutdown(): + sched.stop() + +@app.get("/", response_class=HTMLResponse) +def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + + +# --- Memo models --- + +class MemoCreate(BaseModel): + title: str + description: Optional[str] = "" + reminder_time: str = "09:00" + +class MemoUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + reminder_time: Optional[str] = None + + +# --- Memo routes --- + +@app.get("/api/memos") +def list_memos(done: Optional[str] = None): + with get_db() as conn: + if done == "true": + rows = conn.execute("SELECT * FROM memos WHERE is_done = 1 ORDER BY done_at DESC").fetchall() + elif done == "false": + rows = conn.execute("SELECT * FROM memos WHERE is_done = 0 ORDER BY reminder_time, created_at").fetchall() + else: + rows = conn.execute("SELECT * FROM memos ORDER BY is_done, reminder_time, created_at").fetchall() + return [dict(r) for r in rows] + +@app.post("/api/memos", status_code=201) +def create_memo(memo: MemoCreate): + with get_db() as conn: + cur = conn.execute( + "INSERT INTO memos (title, description, reminder_time) VALUES (?, ?, ?)", + (memo.title.strip(), memo.description.strip(), memo.reminder_time) + ) + row = conn.execute("SELECT * FROM memos WHERE id = ?", (cur.lastrowid,)).fetchone() + return dict(row) + +@app.put("/api/memos/{memo_id}") +def update_memo(memo_id: int, memo: MemoUpdate): + with get_db() as conn: + row = conn.execute("SELECT * FROM memos WHERE id = ?", (memo_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Memo introuvable") + fields, values = [], [] + if memo.title is not None: + fields.append("title = ?"); values.append(memo.title.strip()) + if memo.description is not None: + fields.append("description = ?"); values.append(memo.description.strip()) + if memo.reminder_time is not None: + fields.append("reminder_time = ?"); values.append(memo.reminder_time) + if not fields: + return dict(row) + values.append(memo_id) + conn.execute(f"UPDATE memos SET {', '.join(fields)} WHERE id = ?", values) + return dict(conn.execute("SELECT * FROM memos WHERE id = ?", (memo_id,)).fetchone()) + +@app.post("/api/memos/{memo_id}/done") +def mark_done(memo_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM memos WHERE id = ?", (memo_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Memo introuvable") + conn.execute( + "UPDATE memos SET is_done = 1, done_at = datetime('now', 'localtime') WHERE id = ?", + (memo_id,) + ) + return dict(conn.execute("SELECT * FROM memos WHERE id = ?", (memo_id,)).fetchone()) + +@app.post("/api/memos/{memo_id}/undone") +def mark_undone(memo_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM memos WHERE id = ?", (memo_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Memo introuvable") + conn.execute("UPDATE memos SET is_done = 0, done_at = NULL WHERE id = ?", (memo_id,)) + return dict(conn.execute("SELECT * FROM memos WHERE id = ?", (memo_id,)).fetchone()) + +@app.delete("/api/memos/{memo_id}", status_code=204) +def delete_memo(memo_id: int): + with get_db() as conn: + conn.execute("DELETE FROM memos WHERE id = ?", (memo_id,)) + + +# --- Settings --- + +class SettingsUpdate(BaseModel): + discord_webhook: Optional[str] = None + +@app.get("/api/settings") +def get_settings(): + with get_db() as conn: + rows = conn.execute("SELECT key, value FROM settings").fetchall() + return {r["key"]: r["value"] for r in rows} + +@app.post("/api/settings") +def update_settings(data: SettingsUpdate): + with get_db() as conn: + if data.discord_webhook is not None: + conn.execute("UPDATE settings SET value = ? WHERE key = 'discord_webhook'", (data.discord_webhook,)) + return {"ok": True} diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..cb1e17b --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,24 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from datetime import datetime +from .database import get_db +from .discord_notify import send_reminder + +scheduler = BackgroundScheduler(timezone="Europe/Paris") + +def check_reminders(): + now = datetime.now().strftime("%H:%M") + with get_db() as conn: + memos = conn.execute( + "SELECT * FROM memos WHERE is_done = 0 AND reminder_time = ?", + (now,) + ).fetchall() + for memo in memos: + send_reminder(dict(memo)) + +def start(): + scheduler.add_job(check_reminders, CronTrigger(minute="*"), id="check_reminders", replace_existing=True) + scheduler.start() + +def stop(): + scheduler.shutdown() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8fdccef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +jinja2==3.1.4 +apscheduler==3.10.4 +requests==2.32.3 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..7b1a700 --- /dev/null +++ b/static/app.js @@ -0,0 +1,202 @@ +const api = { + async get(path) { + const r = await fetch(path); return r.json(); + }, + async post(path, body) { + const r = await fetch(path, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + return r.status === 204 ? null : r.json(); + }, + async put(path, body) { + const r = await fetch(path, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + return r.json(); + }, + async del(path) { + await fetch(path, { method: 'DELETE' }); + } +}; + +let memos = []; +let currentTab = 'pending'; + +function fmt(time) { return time ? time.slice(0,5) : ''; } +function fmtDate(dt) { return dt ? dt.slice(0,10) : ''; } + +function renderMemo(m) { + const isDone = !!m.is_done; + const div = document.createElement('div'); + div.className = 'memo-item' + (isDone ? ' done' : ''); + div.dataset.id = m.id; + + const check = document.createElement('button'); + check.className = 'memo-check' + (isDone ? ' checked' : ''); + check.title = isDone ? 'Rouvrir' : 'Marquer comme fait'; + check.innerHTML = isDone ? '✓' : ''; + check.onclick = () => toggleDone(m.id, isDone); + + const body = document.createElement('div'); + body.className = 'memo-body'; + + const title = document.createElement('div'); + title.className = 'memo-title' + (isDone ? ' done-text' : ''); + title.textContent = m.title; + + body.appendChild(title); + + if (m.description) { + const desc = document.createElement('div'); + desc.className = 'memo-desc'; + desc.textContent = m.description; + body.appendChild(desc); + } + + const meta = document.createElement('div'); + meta.className = 'memo-meta'; + if (!isDone) { + meta.innerHTML = `🔔 ${fmt(m.reminder_time)}📅 ${fmtDate(m.created_at)}`; + } else { + meta.innerHTML = `✅ Fait le ${fmtDate(m.done_at)}`; + } + body.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'memo-actions'; + const editBtn = document.createElement('button'); + editBtn.className = 'btn-icon'; + editBtn.title = 'Modifier'; + editBtn.textContent = '✏️'; + editBtn.onclick = () => openEdit(m); + actions.appendChild(editBtn); + + div.appendChild(check); + div.appendChild(body); + div.appendChild(actions); + return div; +} + +function render() { + const pending = memos.filter(m => !m.is_done); + const done = memos.filter(m => m.is_done); + + const badge = document.getElementById('badge-pending'); + badge.textContent = pending.length > 0 ? pending.length : ''; + + const listPending = document.getElementById('list-pending'); + const listDone = document.getElementById('list-done'); + listPending.innerHTML = ''; + listDone.innerHTML = ''; + + if (pending.length === 0) { + listPending.innerHTML = '
Aucun mémo en cours 🎉
'; + } else { + pending.forEach(m => listPending.appendChild(renderMemo(m))); + } + + if (done.length === 0) { + listDone.innerHTML = '
Aucun mémo terminé
'; + } else { + done.forEach(m => listDone.appendChild(renderMemo(m))); + } +} + +async function load() { + memos = await api.get('/api/memos'); + render(); +} + +async function toggleDone(id, isDone) { + const path = isDone ? `/api/memos/${id}/undone` : `/api/memos/${id}/done`; + const updated = await api.post(path, {}); + const idx = memos.findIndex(m => m.id === id); + if (idx !== -1) memos[idx] = updated; + render(); +} + +// Add form +document.getElementById('btn-add').onclick = async () => { + const title = document.getElementById('input-title').value.trim(); + if (!title) { document.getElementById('input-title').focus(); return; } + const desc = document.getElementById('input-desc').value.trim(); + const time = document.getElementById('input-time').value || '09:00'; + const memo = await api.post('/api/memos', { title, description: desc, reminder_time: time }); + memos.unshift(memo); + document.getElementById('input-title').value = ''; + document.getElementById('input-desc').value = ''; + render(); + // Switch to pending tab + setTab('pending'); +}; + +document.getElementById('input-title').addEventListener('keydown', e => { + if (e.key === 'Enter') document.getElementById('btn-add').click(); +}); + +// Tabs +function setTab(tab) { + currentTab = tab; + document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + document.getElementById('list-pending').classList.toggle('hidden', tab !== 'pending'); + document.getElementById('list-done').classList.toggle('hidden', tab !== 'done'); +} + +document.querySelectorAll('.tab').forEach(t => { + t.onclick = () => setTab(t.dataset.tab); +}); + +// Settings modal +document.getElementById('btn-settings').onclick = async () => { + const settings = await api.get('/api/settings'); + document.getElementById('input-webhook').value = settings.discord_webhook || ''; + document.getElementById('modal-settings').classList.remove('hidden'); +}; +document.getElementById('btn-settings-cancel').onclick = () => { + document.getElementById('modal-settings').classList.add('hidden'); +}; +document.getElementById('btn-settings-save').onclick = async () => { + const webhook = document.getElementById('input-webhook').value.trim(); + await api.post('/api/settings', { discord_webhook: webhook }); + document.getElementById('modal-settings').classList.add('hidden'); +}; + +// Edit modal +function openEdit(m) { + document.getElementById('edit-id').value = m.id; + document.getElementById('edit-title').value = m.title; + document.getElementById('edit-desc').value = m.description || ''; + document.getElementById('edit-time').value = fmt(m.reminder_time); + document.getElementById('modal-edit').classList.remove('hidden'); +} + +document.getElementById('btn-edit-cancel').onclick = () => { + document.getElementById('modal-edit').classList.add('hidden'); +}; + +document.getElementById('btn-edit-save').onclick = async () => { + const id = parseInt(document.getElementById('edit-id').value); + const updated = await api.put(`/api/memos/${id}`, { + title: document.getElementById('edit-title').value.trim(), + description: document.getElementById('edit-desc').value.trim(), + reminder_time: document.getElementById('edit-time').value + }); + const idx = memos.findIndex(m => m.id === id); + if (idx !== -1) memos[idx] = updated; + document.getElementById('modal-edit').classList.add('hidden'); + render(); +}; + +document.getElementById('btn-edit-delete').onclick = async () => { + const id = parseInt(document.getElementById('edit-id').value); + if (!confirm('Supprimer ce mémo ?')) return; + await api.del(`/api/memos/${id}`); + memos = memos.filter(m => m.id !== id); + document.getElementById('modal-edit').classList.add('hidden'); + render(); +}; + +// Close modals on backdrop click +document.querySelectorAll('.modal').forEach(modal => { + modal.addEventListener('click', e => { + if (e.target === modal) modal.classList.add('hidden'); + }); +}); + +load(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..4ccdffa --- /dev/null +++ b/static/style.css @@ -0,0 +1,198 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --surface2: #293548; + --border: #334155; + --text: #f1f5f9; + --text-muted: #94a3b8; + --primary: #6366f1; + --primary-hover: #4f46e5; + --success: #22c55e; + --danger: #ef4444; + --warning: #f59e0b; + --radius: 10px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.app { + max-width: 720px; + margin: 0 auto; + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; +} + +header h1 { font-size: 1.5rem; font-weight: 700; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} + +/* Form */ +.add-form { display: flex; flex-direction: column; gap: 10px; } + +.form-row { display: flex; gap: 10px; } +.form-row-sub { padding-left: 0; } +.form-row-bottom { align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; } + +input[type="text"], input[type="time"], textarea { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 8px 12px; + font-size: 0.95rem; + width: 100%; + outline: none; + transition: border-color 0.15s; +} +input[type="text"]:focus, input[type="time"]:focus, textarea:focus { + border-color: var(--primary); +} +textarea { resize: vertical; font-family: inherit; } +input[type="time"] { width: auto; min-width: 110px; } + +label { font-size: 0.875rem; color: var(--text-muted); display: flex; align-items: center; gap: 8px; } + +/* Buttons */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + white-space: nowrap; +} +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-hover); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { opacity: 0.85; } +.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--surface2); color: var(--text); } +.btn-icon { background: transparent; border: none; cursor: pointer; font-size: 1rem; padding: 4px 6px; border-radius: 6px; color: var(--text-muted); } +.btn-icon:hover { background: var(--surface2); color: var(--text); } + +/* Tabs */ +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); } +.tab { + padding: 8px 16px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; + display: flex; + align-items: center; + gap: 6px; +} +.tab.active { color: var(--primary); border-bottom-color: var(--primary); } + +.badge { + background: var(--warning); + color: #000; + font-size: 0.75rem; + font-weight: 700; + padding: 1px 6px; + border-radius: 99px; + min-width: 20px; + text-align: center; +} +.badge:empty { display: none; } + +/* Memo list */ +.memo-list { display: flex; flex-direction: column; gap: 10px; } +.memo-list.hidden { display: none; } + +.memo-item { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + display: flex; + align-items: flex-start; + gap: 12px; + transition: border-color 0.15s; +} +.memo-item:hover { border-color: var(--primary); } +.memo-item.done { opacity: 0.55; } + +.memo-check { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid var(--border); + background: transparent; + cursor: pointer; + flex-shrink: 0; + margin-top: 1px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, background 0.15s; + font-size: 0.8rem; +} +.memo-check:hover { border-color: var(--success); } +.memo-check.checked { background: var(--success); border-color: var(--success); color: #fff; } + +.memo-body { flex: 1; min-width: 0; } +.memo-title { font-weight: 600; font-size: 0.95rem; word-break: break-word; } +.memo-title.done-text { text-decoration: line-through; } +.memo-desc { font-size: 0.85rem; color: var(--text-muted); margin-top: 4px; word-break: break-word; white-space: pre-wrap; } +.memo-meta { display: flex; gap: 12px; margin-top: 6px; font-size: 0.78rem; color: var(--text-muted); flex-wrap: wrap; } +.memo-meta span { display: flex; align-items: center; gap: 4px; } + +.memo-actions { display: flex; gap: 4px; flex-shrink: 0; } + +.empty-state { + text-align: center; + color: var(--text-muted); + padding: 40px 0; + font-size: 0.95rem; +} + +/* Modals */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 16px; +} +.modal.hidden { display: none; } + +.modal-box { + width: 100%; + max-width: 480px; + display: flex; + flex-direction: column; + gap: 12px; +} +.modal-box h2 { font-size: 1.1rem; font-weight: 700; } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; flex-wrap: wrap; } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ad56e2e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,77 @@ + + + + + + Mémos + + + +
+
+

📝 Mémos

+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + + + + + +
+ + + +