feat: add support for SQLite alongside MySQL #5

Merged
jamie merged 1 commits from v1.2.0 into master 2026-01-08 17:32:39 +00:00
7 changed files with 219 additions and 160 deletions
Showing only changes of commit 898ad38303 - Show all commits
+1
View File
@@ -2,3 +2,4 @@ tailwindcss
static/output.css
.env
__pycache__
echolog.db
+28 -111
View File
@@ -14,62 +14,42 @@ A Flask-based web application for personal homelab journaling. Track your daily
- **PWA Support**: Progressive Web App making this installable on mobile devices
- **Modern UI**: Beautiful gradient design with Tailwind CSS and responsive layout
- **Optional Authentication**: Enable login protection with environment variables
- **MySQL Backend**: Robust data persistence with MySQL/MariaDB
- **Multiple Database Support**: Use MySQL/MariaDB or SQLite for data persistence
- **Docker Ready**: Easy deployment with Docker and Docker Compose
- **Kubernetes Support**: Includes Kubernetes deployment manifest
## Quick Start with Docker
### Docker Run
```bash
docker run -d \
--name echolog \
-p 5000:5000 \
-e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=echolog \
-e MYSQL_PASSWORD=your_password \
-e MYSQL_DATABASE=echolog \
-e SECRET_KEY=your_secret_key \
-e TZ=Europe/London \
-e LOGIN_ENABLED=true \
-e LOGIN_USERNAME=admin \
-e LOGIN_PASSWORD=your_password \
cr.jdbnet.co.uk/public/echolog:latest
```
### Docker Compose
```yaml
version: '3.8'
services:
echolog:
image: cr.jdbnet.co.uk/public/echolog:latest
container_name: echolog
restart: unless-stopped
ports:
- "5000:5000"
environment:
- MYSQL_HOST=10.10.2.27
- MYSQL_USER=echolog
- MYSQL_PASSWORD=your_password
- MYSQL_DATABASE=echolog
- SECRET_KEY=your_secret_key
- TZ=Europe/London
- LOGIN_ENABLED=true
- LOGIN_USERNAME=admin
- LOGIN_PASSWORD=your_password
```
For deployment examples, see the [examples folder](examples/).
## Configuration
### Database Selection
EchoLog supports both MySQL/MariaDB and SQLite:
- SQLite (default for quick start)
- MySQL/MariaDB
See the [examples folder](examples/) for docker-compose files for both database types.
### Environment Variables
#### Database Configuration
- `DB_TYPE`: Database type - `mysql` (default) or `sqlite`
**MySQL/MariaDB options:**
- `MYSQL_HOST`: MySQL/MariaDB host (default: localhost)
- `MYSQL_USER`: Database user (default: root)
- `MYSQL_PASSWORD`: Database password (default: empty)
- `MYSQL_DATABASE`: Database name (default: echolog)
**SQLite options:**
- `SQLITE_DB`: Path to SQLite database file (default: echolog.db)
#### Application Configuration
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `TZ`: Timezone for date handling (default: UTC)
- `LOGIN_ENABLED`: Enable login protection (default: false)
@@ -78,6 +58,8 @@ services:
### Database Setup
#### MySQL/MariaDB
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate permissions:
```sql
@@ -87,6 +69,10 @@ GRANT ALL PRIVILEGES ON echolog.* TO 'echolog'@'%';
FLUSH PRIVILEGES;
```
#### SQLite
SQLite requires no setup - the database file is created automatically on first run. Ensure the directory where the database file is stored is writable by the application.
## Usage
### Creating Entries
@@ -112,75 +98,6 @@ FLUSH PRIVILEGES;
The streak counter automatically tracks consecutive days with journal entries. The streak includes today or yesterday to start, and continues as long as you have entries on consecutive days.
## Kubernetes Deployment
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
**Example Kubernetes deployment:**
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: echolog
namespace: echolog
spec:
replicas: 1
template:
spec:
containers:
- name: echolog
image: cr.jdbnet.co.uk/public/echolog:latest
ports:
- containerPort: 5000
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: echolog-secrets
key: secret-key
- name: MYSQL_HOST
value: "mysql-service"
- name: MYSQL_USER
value: "echolog"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: echolog-secrets
key: mysql-password
- name: MYSQL_DATABASE
value: "echolog"
- name: TZ
value: "Europe/London"
- name: LOGIN_ENABLED
value: "true"
- name: LOGIN_USERNAME
valueFrom:
secretKeyRef:
name: echolog-secrets
key: username
- name: LOGIN_PASSWORD
valueFrom:
secretKeyRef:
name: echolog-secrets
key: password
```
## Progressive Web App (PWA)
EchoLog can be installed as a Progressive Web App on supported devices:
- **Mobile**: Add to home screen from browser menu
- **Desktop**: Install from browser address bar
## Security Notes
- **ENABLE LOGIN** in production by setting `LOGIN_ENABLED=true`
- **CHANGE THE DEFAULT CREDENTIALS** if using authentication
- **CHANGE THE SECRET_KEY** in production - use a strong random string (e.g., `openssl rand -hex 32`)
- Use strong passwords for database access
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
## Troubleshooting
### Database Connection Issues
+87 -17
View File
@@ -1,9 +1,9 @@
import os
import pytz
import logging
import sqlite3
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
from datetime import datetime, date as datedate, datetime as dt
import mysql.connector
from dotenv import load_dotenv
load_dotenv()
@@ -18,21 +18,43 @@ tz = pytz.timezone(TIMEZONE)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'defaultsecret')
app.config['LOGIN_ENABLED'] = os.getenv('LOGIN_ENABLED', 'false').lower() == 'true'
mysql_config = {
# Database configuration
DB_TYPE = os.getenv('DB_TYPE', 'mysql').lower()
if DB_TYPE == 'mysql':
import mysql.connector
mysql_config = {
'user': os.getenv('MYSQL_USER', 'root'),
'password': os.getenv('MYSQL_PASSWORD', ''),
'host': os.getenv('MYSQL_HOST', 'localhost'),
'database': os.getenv('MYSQL_DATABASE', 'echolog')
}
}
elif DB_TYPE == 'sqlite':
SQLITE_DB = os.getenv('SQLITE_DB', 'echolog.db')
else:
raise ValueError(f"Unsupported DB_TYPE: {DB_TYPE}. Use 'sqlite' or 'mysql'")
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
def get_db_connection():
if DB_TYPE == 'mysql':
return mysql.connector.connect(**mysql_config)
elif DB_TYPE == 'sqlite':
conn = sqlite3.connect(SQLITE_DB)
conn.row_factory = sqlite3.Row
return conn
def dict_from_row(row):
"""Convert database row to dictionary"""
if DB_TYPE == 'mysql':
return row
elif DB_TYPE == 'sqlite':
return dict(row) if row else None
def init_db():
conn = get_db_connection()
cursor = conn.cursor()
if DB_TYPE == 'mysql':
cursor.execute("""
CREATE TABLE IF NOT EXISTS journal_entry (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -40,6 +62,14 @@ def init_db():
content TEXT NOT NULL
);
""")
elif DB_TYPE == 'sqlite':
cursor.execute("""
CREATE TABLE IF NOT EXISTS journal_entry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
content TEXT NOT NULL
);
""")
conn.commit()
cursor.close()
conn.close()
@@ -48,6 +78,9 @@ def calculate_streak():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT date FROM journal_entry ORDER BY date DESC")
if DB_TYPE == 'mysql':
dates = [row[0] for row in cursor.fetchall()]
elif DB_TYPE == 'sqlite':
dates = [row[0] for row in cursor.fetchall()]
cursor.close()
conn.close()
@@ -84,14 +117,25 @@ def index():
per_page = 7
offset = (page - 1) * per_page
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor()
if DB_TYPE == 'mysql':
cursor.execute("SELECT SQL_CALC_FOUND_ROWS * FROM journal_entry ORDER BY date DESC LIMIT %s OFFSET %s", (per_page, offset))
entries = cursor.fetchall()
cursor.execute("SELECT FOUND_ROWS() as total")
total = cursor.fetchone()['total']
elif DB_TYPE == 'sqlite':
cursor.execute("SELECT COUNT(*) as total FROM journal_entry")
total = cursor.fetchone()[0]
cursor.execute("SELECT * FROM journal_entry ORDER BY date DESC LIMIT ? OFFSET ?", (per_page, offset))
entries = [dict_from_row(row) for row in cursor.fetchall()]
today = datetime.now(tz).date().isoformat()
cursor.execute("SELECT * FROM journal_entry WHERE date = %s", (today,))
cursor.execute("SELECT * FROM journal_entry WHERE date = ?" if DB_TYPE == 'sqlite' else "SELECT * FROM journal_entry WHERE date = %s", (today,))
todays_entry = cursor.fetchone()
if DB_TYPE == 'sqlite' and todays_entry:
todays_entry = dict_from_row(todays_entry)
cursor.close()
conn.close()
has_prev = page > 1
@@ -107,12 +151,13 @@ def add_entry():
if content.strip():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM journal_entry WHERE date = %s", (date,))
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"SELECT id FROM journal_entry WHERE date = {param_marker}", (date,))
existing = cursor.fetchone()
if existing:
cursor.execute("UPDATE journal_entry SET content = %s WHERE date = %s", (content, date))
cursor.execute(f"UPDATE journal_entry SET content = {param_marker} WHERE date = {param_marker}", (content, date))
else:
cursor.execute("INSERT INTO journal_entry (date, content) VALUES (%s, %s)", (date, content))
cursor.execute(f"INSERT INTO journal_entry (date, content) VALUES ({param_marker}, {param_marker})", (date, content))
conn.commit()
cursor.close()
conn.close()
@@ -124,19 +169,25 @@ def entry_for_date():
if not date:
return jsonify({'content': ''})
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT content FROM journal_entry WHERE date = %s", (date,))
cursor = conn.cursor()
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"SELECT content FROM journal_entry WHERE date = {param_marker}", (date,))
entry = cursor.fetchone()
cursor.close()
conn.close()
return jsonify({'content': entry['content'] if entry else ''})
if DB_TYPE == 'sqlite':
content = entry[0] if entry else ''
else:
content = entry['content'] if entry else ''
return jsonify({'content': content})
@app.route('/search', methods=['GET'])
def search():
query = request.args.get('query', '')
date = request.args.get('date', None)
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor()
if DB_TYPE == 'mysql':
sql = "SELECT * FROM journal_entry WHERE 1=1"
params = []
if query:
@@ -148,6 +199,19 @@ def search():
sql += " ORDER BY date DESC"
cursor.execute(sql, params)
entries = cursor.fetchall()
elif DB_TYPE == 'sqlite':
sql = "SELECT * FROM journal_entry WHERE 1=1"
params = []
if query:
sql += " AND content LIKE ?"
params.append(f"%{query}%")
if date:
sql += " AND date = ?"
params.append(date)
sql += " ORDER BY date DESC"
cursor.execute(sql, params)
entries = [dict_from_row(row) for row in cursor.fetchall()]
cursor.close()
conn.close()
today = datetime.now(tz).date().isoformat()
@@ -186,22 +250,26 @@ def require_login():
@app.route('/edit/<int:entry_id>', methods=['GET', 'POST'])
def edit_entry(entry_id):
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor()
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
if request.method == 'POST':
date = request.form.get('date')
content = request.form.get('content')
cursor.execute("UPDATE journal_entry SET date=%s, content=%s WHERE id=%s", (date, content, entry_id))
cursor.execute(f"UPDATE journal_entry SET date={param_marker}, content={param_marker} WHERE id={param_marker}", (date, content, entry_id))
conn.commit()
cursor.close()
conn.close()
return redirect(url_for('index'))
else:
cursor.execute("SELECT * FROM journal_entry WHERE id=%s", (entry_id,))
cursor.execute(f"SELECT * FROM journal_entry WHERE id={param_marker}", (entry_id,))
entry = cursor.fetchone()
cursor.close()
conn.close()
if not entry:
return redirect(url_for('index'))
if DB_TYPE == 'sqlite':
entry = dict_from_row(entry)
return render_template('edit.html', entry=entry)
@app.route('/edit_modal', methods=['POST'])
@@ -212,7 +280,8 @@ def edit_entry_modal():
if entry_id and date and content:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("UPDATE journal_entry SET date=%s, content=%s WHERE id=%s", (date, content, entry_id))
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"UPDATE journal_entry SET date={param_marker}, content={param_marker} WHERE id={param_marker}", (date, content, entry_id))
conn.commit()
cursor.close()
conn.close()
@@ -222,7 +291,8 @@ def edit_entry_modal():
def delete_entry(entry_id):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM journal_entry WHERE id=%s", (entry_id,))
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"DELETE FROM journal_entry WHERE id={param_marker}", (entry_id,))
conn.commit()
cursor.close()
conn.close()
+44
View File
@@ -0,0 +1,44 @@
version: '3.8'
services:
mysql:
image: mariadb:latest
container_name: echolog-mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=echolog
- MYSQL_USER=echolog
- MYSQL_PASSWORD=echolog_password
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
echolog:
image: cr.jdbnet.co.uk/public/echolog:latest
container_name: echolog
restart: unless-stopped
ports:
- "5000:5000"
depends_on:
mysql:
condition: service_healthy
environment:
- DB_TYPE=mysql
- MYSQL_HOST=mysql
- MYSQL_USER=echolog
- MYSQL_PASSWORD=echolog_password
- MYSQL_DATABASE=echolog
- SECRET_KEY=change-me-in-production
- TZ=Europe/London
- LOGIN_ENABLED=false
# Uncomment below to enable login protection
# - LOGIN_ENABLED=true
# - LOGIN_USERNAME=admin
# - LOGIN_PASSWORD=change-me
volumes:
mysql-data:
+24
View File
@@ -0,0 +1,24 @@
version: '3.8'
services:
echolog:
image: cr.jdbnet.co.uk/public/echolog:latest
container_name: echolog
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- echolog-data:/data
environment:
- DB_TYPE=sqlite
- SQLITE_DB=/data/echolog.db
- SECRET_KEY=change-me-in-production
- TZ=Europe/London
- LOGIN_ENABLED=false
# Uncomment below to enable login protection
# - LOGIN_ENABLED=true
# - LOGIN_USERNAME=admin
# - LOGIN_PASSWORD=change-me
volumes:
echolog-data:
+4 -1
View File
@@ -1,5 +1,8 @@
Flask
mysql-connector-python
python-dotenv
pytz
gunicorn
# Optional database drivers:
# For MySQL/MariaDB support, uncomment the line below:
# mysql-connector-python
# SQLite is built-in with Python
+1 -1
View File
@@ -213,7 +213,7 @@
<footer class="mt-8 py-6 border-t border-gray-800 text-center text-gray-500 text-sm">
<span class="mr-1"><a href="https://www.jdbnet.co.uk" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">JDB-NET</a> &copy; {{ now.year }}</span>
&middot;
<span class="mx-1"><a href="https://github.com/JDB-NET/echolog" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">{{ version }}</a></span>
<span class="mx-1"><a href="https://git.jdbnet.co.uk/jamie/echolog" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">{{ version }}</a></span>
</footer>
<script>