Initial commit: memo app with FastAPI, SQLite, APScheduler, Discord notifications

This commit is contained in:
2026-04-30 14:03:11 +02:00
commit 748ee8ae75
11 changed files with 727 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.pyo
data/
*.db
.env
+18
View File
@@ -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"]
View File
+39
View File
@@ -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)
+28
View File
@@ -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
+130
View File
@@ -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}
+24
View File
@@ -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()
+5
View File
@@ -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
+202
View File
@@ -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 = `<span>🔔 ${fmt(m.reminder_time)}</span><span>📅 ${fmtDate(m.created_at)}</span>`;
} else {
meta.innerHTML = `<span>✅ Fait le ${fmtDate(m.done_at)}</span>`;
}
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 = '<div class="empty-state">Aucun mémo en cours 🎉</div>';
} else {
pending.forEach(m => listPending.appendChild(renderMemo(m)));
}
if (done.length === 0) {
listDone.innerHTML = '<div class="empty-state">Aucun mémo terminé</div>';
} 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();
+198
View File
@@ -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; }
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mémos</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="app">
<header>
<h1>📝 Mémos</h1>
<div class="header-actions">
<button class="btn btn-ghost" id="btn-settings" title="Paramètres">⚙️</button>
</div>
</header>
<!-- Add memo form -->
<section class="add-form card">
<div class="form-row">
<input type="text" id="input-title" placeholder="Nouveau mémo…" maxlength="200" />
</div>
<div class="form-row form-row-sub">
<textarea id="input-desc" placeholder="Description (optionnelle)" rows="2" maxlength="1000"></textarea>
</div>
<div class="form-row form-row-sub form-row-bottom">
<label>🔔 Rappel quotidien à <input type="time" id="input-time" value="09:00" /></label>
<button class="btn btn-primary" id="btn-add">Ajouter</button>
</div>
</section>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="pending">En cours <span id="badge-pending" class="badge"></span></button>
<button class="tab" data-tab="done">Terminés</button>
</div>
<!-- Memo list -->
<section id="list-pending" class="memo-list"></section>
<section id="list-done" class="memo-list hidden"></section>
<!-- Settings modal -->
<div id="modal-settings" class="modal hidden">
<div class="modal-box card">
<h2>Paramètres</h2>
<label>Webhook Discord</label>
<input type="text" id="input-webhook" placeholder="https://discord.com/api/webhooks/…" />
<div class="modal-actions">
<button class="btn btn-ghost" id="btn-settings-cancel">Annuler</button>
<button class="btn btn-primary" id="btn-settings-save">Enregistrer</button>
</div>
</div>
</div>
<!-- Edit modal -->
<div id="modal-edit" class="modal hidden">
<div class="modal-box card">
<h2>Modifier le mémo</h2>
<input type="hidden" id="edit-id" />
<label>Titre</label>
<input type="text" id="edit-title" maxlength="200" />
<label>Description</label>
<textarea id="edit-desc" rows="3" maxlength="1000"></textarea>
<label>Rappel quotidien à</label>
<input type="time" id="edit-time" />
<div class="modal-actions">
<button class="btn btn-ghost" id="btn-edit-cancel">Annuler</button>
<button class="btn btn-danger" id="btn-edit-delete">Supprimer</button>
<button class="btn btn-primary" id="btn-edit-save">Enregistrer</button>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>