Skip to content

Instantly share code, notes, and snippets.

@JupyterJones
Created June 20, 2025 18:57
Show Gist options
  • Save JupyterJones/ccbc90c79c39476e003a081b2bdecd67 to your computer and use it in GitHub Desktop.
Save JupyterJones/ccbc90c79c39476e003a081b2bdecd67 to your computer and use it in GitHub Desktop.
The Project Planner is a personal assistant built for developers who think deeply, create often, and need a space to remember everything they build. It runs quietly in your browser using Flask, capturing the lifeblood of your creative process including your ideas, your goals, your documentation, your notes, and even your code.
import os
from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, make_response
from flask_sqlalchemy import SQLAlchemy
import chromadb
from sentence_transformers import SentenceTransformer
from datetime import datetime
import mistune # New library for Markdown
from markupsafe import escape, Markup
# --- App & Database Configuration ---
app = Flask(__name__)
app.config['SECRET_KEY'] = 'a-very-secret-key-for-single-file'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///projects.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# --- Markdown Setup ---
# Create a filter to use in our template strings
def markdown_filter(s):
# Using a simple mistune setup. Can be customized with plugins.
return mistune.html(s) if s else ''
def nl2br(value):
"""Converts newlines in a string to HTML <br> tags."""
if not isinstance(value, str):
return value
# Escape the value to prevent XSS, then wrap the result of replacing newlines
# in Markup() to tell Jinja2 it's safe to render as HTML.
escaped_value = escape(value)
return Markup(escaped_value.replace('\n', '<br>\n'))
app.jinja_env.filters['markdown'] = markdown_filter
app.jinja_env.filters['nl2br'] = nl2br
# --- Database Model (UPDATED) ---
class Project(db.Model):
id = db.Column(db.Integer, primary_key=True)
project_name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text) # NEW: Project description
start_date = db.Column(db.Date)
status = db.Column(db.String(50))
languages = db.Column(db.String(100))
project_directory = db.Column(db.String(200))
problems = db.Column(db.Text)
github_url = db.Column(db.String(200))
documentation = db.Column(db.Text) # CHANGED: From documentation_link
notes = db.Column(db.Text)
tags = db.Column(db.String(100))
last_updated = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
main_file_name = db.Column(db.String(200))
main_file_content = db.Column(db.Text)
def __repr__(self):
return f'<Project {self.project_name}>'
# --- ChromaDB & Sentence Transformer Setup ---
client = chromadb.PersistentClient(path="chroma_db_single_file")
collection = client.get_or_create_collection("projects")
model = SentenceTransformer('all-mpnet-base-v2')
def embed_text(text):
if not text or not isinstance(text, str): return None
return model.encode(text).tolist()
def upsert_to_chroma(project):
# UPDATED: Add new fields to the semantic search index
project_summary = (
f"Project Name: {project.project_name}. Status: {project.status}. "
f"Description: {project.description}. "
f"Languages: {project.languages}. Problems: {project.problems}. "
f"Notes: {project.notes}. Tags: {project.tags}. "
f"Documentation: {project.documentation}. "
f"File Content: {project.main_file_content}"
)
embedding = embed_text(project_summary)
if embedding:
collection.upsert(
documents=[project_summary],
metadatas=[{"id": project.id, "name": project.project_name}],
ids=[str(project.id)]
)
def search_chroma(query):
query_embedding = embed_text(query)
if query_embedding is None: return [], []
results = collection.query(query_embeddings=[query_embedding], n_results=5)
return results.get('ids', [[]])[0], results.get('distances', [[]])[0]
# --- HTML TEMPLATES STORED AS STRINGS (UPDATED) ---
BASE_TEMPLATE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} - Project Planner</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background-color: darkred; } .navbar { margin-bottom: 2rem; } .card { margin-bottom: 1.5rem; }
pre { background: #e9ecef; padding: 15px; border-radius: 5px; white-space: pre-wrap; word-wrap: break-word; max-height: 400px; overflow-y: auto; }
.form-text { font-size: 0.875em; } code { color: #d63384; }
.markdown-content img { max-width: 100%; height: auto; } /* Style for rendered markdown */
h1{ color:orange; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('index') }}">Project Planner</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{{ url_for('index') }}">Home</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('add_project') }}">Add Project</a></li>
</ul>
</div>
</div>
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ content | safe }}
</main>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
{{ scripts | safe }}
</body>
</html>
"""
INDEX_PAGE_CONTENT = """
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Project Dashboard</h1>
<a href="{{ url_for('add_project') }}" class="btn btn-primary">Add New Project</a>
</div>
<div class="card bg-light mb-4">
<div class="card-body">
<h5 class="card-title">Semantic Search</h5>
<p class="card-text">Find projects based on description, problems, notes, documentation or code.</p>
<form id="search-form"><div class="input-group">
<input type="text" id="search-query" class="form-control" placeholder="e.g., 'data visualization app with d3'">
<button class="btn btn-outline-secondary" type="submit">Search</button>
</div></form>
<div id="search-results" class="mt-3"></div>
</div>
</div>
<h2>All Projects</h2>
<div class="row">
{% for project in projects %}
<div class="col-md-6 col-lg-4"><div class="card">
<div class="card-body">
<h5 class="card-title">{{ project.project_name }}</h5>
<p class="card-text text-muted" style="font-size: 0.9em;">{{ project.description | truncate(100) }}</p>
<h6 class="card-subtitle mb-2 text-muted">{{ project.status }}</h6>
<p class="card-text">
<strong>Languages:</strong> {{ project.languages or 'N/A' }}<br>
<strong>Last Updated:</strong> {{ project.last_updated.strftime('%Y-%m-%d %H:%M') }}
</p>
<a href="{{ url_for('project_detail', project_id=project.id) }}" class="btn btn-sm btn-info">View Details</a>
</div>
</div></div>
{% else %}
<p>No projects yet. <a href="{{ url_for('add_project') }}">Add one now!</a></p>
{% endfor %}
</div>
"""
INDEX_PAGE_SCRIPTS = """
<script>
document.getElementById('search-form').addEventListener('submit', async function (e) {
e.preventDefault();
const query = document.getElementById('search-query').value;
const resultsDiv = document.getElementById('search-results');
resultsDiv.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div>';
if (!query) { resultsDiv.innerHTML = '<div class="alert alert-warning">Please enter a search query.</div>'; return; }
const response = await fetch('{{ url_for("search_projects_api") }}', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: query })
});
const data = await response.json();
if (data.results && data.results.length > 0) {
let html = '<h6>Search Results:</h6><ul class="list-group">';
data.results.forEach(result => {
html += `<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="/project/${result.id}">${result.name} (${result.status})</a>
<span class="badge bg-primary rounded-pill">Score: ${result.score}%</span>
</li>`;
});
html += '</ul>';
resultsDiv.innerHTML = html;
} else { resultsDiv.innerHTML = '<div class="alert alert-info">No semantic matches found.</div>'; }
});
</script>
"""
PROJECT_FORM_CONTENT = """
<h1>{{ "Edit" if project else "Add New" }} Project</h1>
<form method="POST" action="" enctype="multipart/form-data">
<div class="mb-3">
<label for="project_name" class="form-label">Project Name*</label>
<input type="text" class="form-control" id="project_name" name="project_name" value="{{ project.project_name if project else '' }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Project Description</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ project.description or '' }}</textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label for="start_date" class="form-label">Start Date</label>
<input type="date" class="form-control" id="start_date" name="start_date" value="{{ project.start_date.strftime('%Y-%m-%d') if project and project.start_date else '' }}"></div>
<div class="col-md-6 mb-3"><label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
{% set statuses = ['Planning', 'In Progress', 'Completed', 'On Hold', 'Archived'] %}
{% for s in statuses %}<option {% if project and s == project.status %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select></div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label for="languages" class="form-label">Languages/Frameworks</label>
<input type="text" class="form-control" id="languages" name="languages" value="{{ project.languages or '' }}" placeholder="e.g., Python, Flask, JavaScript"></div>
<div class="col-md-6 mb-3"><label for="tags" class="form-label">Tags</label>
<input type="text" class="form-control" id="tags" name="tags" value="{{ project.tags or '' }}" placeholder="e.g., web-app, data-science, utility"></div>
</div>
<div class="mb-3">
<label for="project_directory" class="form-label">Project Directory</label>
<input type="text" class="form-control" id="project_directory" name="project_directory" value="{{ project.project_directory or '' }}" placeholder="e.g., C:\\\\Users\\\\YourUser\\\\dev\\\\project-alpha">
</div>
<div class="mb-3"><label for="github_url" class="form-label">GitHub URL</label>
<input type="url" class="form-control" id="github_url" name="github_url" value="{{ project.github_url or '' }}"></div>
<div class="mb-3">
<label for="problems" class="form-label">Problems/Goals</label>
<textarea class="form-control" id="problems" name="problems" rows="3">{{ project.problems or '' }}</textarea>
</div>
<div class="mb-3">
<label for="notes" class="form-label">Notes</label>
<textarea class="form-control" id="notes" name="notes" rows="5">{{ project.notes or '' }}</textarea>
</div>
<div class="mb-3">
<label for="documentation" class="form-label">Documentation (Markdown enabled)</label>
<textarea class="form-control" id="documentation" name="documentation" rows="10">{{ project.documentation or '' }}</textarea>
</div>
<div class="mb-3">
<label for="code_file" class="form-label">Upload Main Code File (for semantic search)</label>
<input class="form-control" type="file" id="code_file" name="code_file">
<div class="form-text">{% if project and project.main_file_name %}Current file: <strong>{{ project.main_file_name }}</strong>. Uploading a new file will overwrite it.{% else %}Optional. The content will be indexed for search.{% endif %}</div>
</div>
<button type="submit" class="btn btn-primary">{{ "Save Changes" if project else "Add Project" }}</button>
<a href="{{ url_for('index') }}" class="btn btn-secondary">Cancel</a>
</form>
"""
PROJECT_DETAIL_CONTENT = """
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>{{ project.project_name }}</h1>
<div>
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-warning">Edit</a>
<form action="{{ url_for('delete_project', project_id=project.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this project?');">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
<div class="card"><div class="card-header">Details</div><div class="card-body">
<div class="row">
<div class="col-md-4"><strong>Status:</strong> {{ project.status or 'N/A' }}</div>
<div class="col-md-4"><strong>Start Date:</strong> {{ project.start_date.strftime('%Y-%m-%d') if project.start_date else 'N/A' }}</div>
<div class="col-md-4"><strong>Last Updated:</strong> {{ project.last_updated.strftime('%Y-%m-%d %H:%M') }}</div>
</div><hr>
<p><strong>Languages:</strong> {{ project.languages or 'N/A' }}</p>
<p><strong>Tags:</strong> {{ project.tags or 'N/A' }}</p>
<p><strong>Directory:</strong> <code>{{ project.project_directory or 'N/A' }}</code></p>
<p><strong>GitHub:</strong> <a href="{{ project.github_url or '#' }}" target="_blank">{{ project.github_url or 'N/A' }}</a></p>
</div></div>
{% if project.description %}<div class="card"><div class="card-header">Description</div>
<div class="card-body">{{ project.description | nl2br | safe }}</div></div>{% endif %}
{% if project.documentation %}<div class="card"><div class="card-header d-flex justify-content-between align-items-center">
Documentation
<a href="{{ url_for('download_documentation', project_id=project.id) }}" class="btn btn-sm btn-outline-secondary">Download .md</a>
</div><div class="card-body markdown-content">{{ project.documentation | markdown | safe }}</div></div>{% endif %}
<div class="card"><div class="card-header">Problems & Notes</div><div class="card-body">
<h5>Problems / Goals</h5><p>{{ project.problems | nl2br | safe if project.problems else 'No problems specified.' }}</p><hr>
<h5>Notes</h5><p>{{ project.notes | nl2br | safe if project.notes else 'No notes available.' }}</p>
</div></div>
{% if project.main_file_content %}<div class="card"><div class="card-header d-flex justify-content-between align-items-center">
Indexed File: {{ project.main_file_name }}
<a href="{{ url_for('download_code_file', project_id=project.id) }}" class="btn btn-sm btn-outline-secondary">Download File</a>
</div><div class="card-body"><pre><code>{{ project.main_file_content }}</code></pre></div></div>{% endif %}
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary mt-3">Back to Dashboard</a>
"""
# --- Helper function to render pages ---
def _render_page(title, content_template, scripts_template="", **kwargs):
return render_template_string(BASE_TEMPLATE, title=title,
content=render_template_string(content_template, **kwargs),
scripts=render_template_string(scripts_template, **kwargs))
# --- Flask Routes ---
@app.route("/")
def index():
projects = Project.query.order_by(Project.last_updated.desc()).all()
return _render_page("Project Dashboard", INDEX_PAGE_CONTENT, INDEX_PAGE_SCRIPTS, projects=projects)
def _populate_project_from_form(project, form, files):
"""Helper to populate project fields from form data."""
project.project_name = form["project_name"]
project.description = form.get("description") # NEW
project.start_date = datetime.strptime(form["start_date"], '%Y-%m-%d').date() if form["start_date"] else None
project.status = form.get("status")
project.languages = form.get("languages")
project.project_directory = form.get("project_directory")
project.problems = form.get("problems")
project.github_url = form.get("github_url")
project.documentation = form.get("documentation") # CHANGED
project.notes = form.get("notes")
project.tags = form.get("tags")
file = files.get('code_file')
if file and file.filename != '':
project.main_file_name = file.filename
try:
project.main_file_content = file.read().decode('utf-8')
except Exception as e:
flash(f"Could not read uploaded file: {e}", "danger")
return project
@app.route("/add", methods=["GET", "POST"])
def add_project():
if request.method == "POST":
new_project = Project()
_populate_project_from_form(new_project, request.form, request.files)
db.session.add(new_project)
db.session.commit()
upsert_to_chroma(new_project)
flash(f"Project '{new_project.project_name}' added successfully!", "success")
return redirect(url_for("index"))
return _render_page("Add Project", PROJECT_FORM_CONTENT, project=None)
@app.route("/project/<int:project_id>")
def project_detail(project_id):
project = Project.query.get_or_404(project_id)
return _render_page(project.project_name, PROJECT_DETAIL_CONTENT, project=project)
@app.route("/edit/<int:project_id>", methods=["GET", "POST"])
def edit_project(project_id):
project = Project.query.get_or_404(project_id)
if request.method == "POST":
_populate_project_from_form(project, request.form, request.files)
db.session.commit()
upsert_to_chroma(project)
flash(f"Project '{project.project_name}' updated successfully!", "success")
return redirect(url_for("project_detail", project_id=project.id))
return _render_page(f"Edit {project.project_name}", PROJECT_FORM_CONTENT, project=project)
@app.route("/delete/<int:project_id>", methods=["POST"])
def delete_project(project_id):
project = Project.query.get_or_404(project_id)
project_name = project.project_name
collection.delete(ids=[str(project.id)])
db.session.delete(project)
db.session.commit()
flash(f"Project '{project_name}' has been deleted.", "success")
return redirect(url_for("index"))
@app.route("/search", methods=["POST"])
def search_projects_api():
query = request.json.get("query")
if not query: return jsonify({"error": "No query provided"}), 400
ids, distances = search_chroma(query)
results = []
if ids:
for i, project_id in enumerate(ids):
project = Project.query.get(int(project_id))
if project:
results.append({"id": project.id, "name": project.project_name, "status": project.status,
"score": round((1 - distances[i]) * 100, 2)})
return jsonify({"results": results})
# --- NEW: File Download Routes ---
@app.route("/download/code_file/<int:project_id>")
def download_code_file(project_id):
project = Project.query.get_or_404(project_id)
if not project.main_file_content:
flash("No code file available for this project.", "warning")
return redirect(url_for('project_detail', project_id=project_id))
response = make_response(project.main_file_content)
filename = project.main_file_name or "download.txt"
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
response.headers["Content-Type"] = "text/plain; charset=utf-8"
return response
@app.route("/download/documentation/<int:project_id>")
def download_documentation(project_id):
project = Project.query.get_or_404(project_id)
if not project.documentation:
flash("No documentation available for this project.", "warning")
return redirect(url_for('project_detail', project_id=project_id))
response = make_response(project.documentation)
# Sanitize project name for use in a filename
safe_name = "".join(c for c in project.project_name if c.isalnum() or c in (' ', '_')).rstrip()
filename = f"{safe_name}_documentation.md"
response.headers["Content-Disposition"] = f"attachment; filename=\"{filename}\""
response.headers["Content-Type"] = "text/markdown; charset=utf-8"
return response
if __name__ == "__main__":
with app.app_context():
db.create_all()
app.run(debug=True,host='0.0.0.0',port=5000)
# --- AI & Machine Learning ---
# For CPU-only, torch must be installed with the specific index-url.
# Run: pip install -r requirements.txt --index-url https://download.pytorch.org/whl/cpu
torch==2.7.1
transformers==4.46.3
sentence-transformers==2.7.0
scikit-learn==1.6.1
onnxruntime==1.19.2
safetensors==0.5.3
tokenizers==0.20.3
# --- LangChain Stack & Vector DB ---
langchain==0.1.16
langchain-community==0.0.36
langchain-core==0.1.53
langchain-chroma==0.2.4
chromadb==0.5.23
langchain-google-genai==0.0.9
tavily-python==0.7.5
langsmith==0.1.147
# --- Web Framework (Choose ONE) ---
# You have both Flask and FastAPI. This is unusual.
# Keep the one you are actually using and delete the other.
fastapi==0.115.9
uvicorn==0.34.3
# Flask==3.0.3
# --- Data & Utilities ---
numpy==1.26.4
pandas==2.3.0
beautifulsoup4==4.13.4
requests==2.32.3
python-dotenv==1.1.0
tqdm==4.67.1
icecream==2.1.4
# --- Media & GUI Automation ---
# These are heavy dependencies. Remove if not needed.
PyAutoGUI==0.9.54
SpeechRecognition==3.14.3
PyAudio==0.2.14
moviepy==1.0.3
opencv-contrib-python==4.11.0.86
# --- Potential Unnecessary Heavy Dependency ---
# Do you really need to interact with a Kubernetes cluster?
# If not, you can safely remove this.
# kubernetes==32.0.1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment