commit 71364b2fc0613cb78f010c28ec8b5674bdb13da8 Author: retoor Date: Sun Jun 22 22:13:38 2025 +0200 Initial commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd57478 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +# Makefile for Adano Project + +# Variables +VENV_DIR := .venv +ACTIVATE := source $(VENV_DIR)/bin/activate +PYTHON := $(VENV_DIR)/bin/python +REQUIREMENTS := requirements.txt + +# Default target +.PHONY: all +all: help + +# Create virtual environment and install dependencies +.PHONY: install +install: + @echo "Setting up virtual environment..." + @test -d $(VENV_DIR) || python3 -m venv $(VENV_DIR) + @echo "Installing dependencies..." + $(PYTHON) -m pip install --upgrade pip + $(PYTHON) -m pip install -r $(REQUIREMENTS) + +# Run the FastAPI app +.PHONY: run +run: + @echo "Running the FastAPI server..." + $(PYTHON) -m uvicorn main:app + +# Run tests (assuming you have tests set up) +.PHONY: test +test: + @echo "Running tests..." + # Add your test command here, e.g., pytest + pytest + +# Clean up virtual environment and __pycache__ +.PHONY: clean +clean: + @echo "Cleaning up..." + rm -rf $(VENV_DIR) + find . -type d -name "__pycache__" -exec rm -rf {} + + +# Help message +.PHONY: help +help: + @echo "Makefile targets:" + @echo " install - Create virtual environment and install dependencies" + @echo " run - Run the FastAPI server" + @echo " test - Run tests" + @echo " clean - Remove virtual environment and cache files" diff --git a/README.md b/README.md new file mode 100644 index 0000000..58caea2 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Adano Notes & Tags Template + +This repository is a template for a notes and tags application. +**Note:** This is not a standalone application for end-users. It serves as a clean, minimal base system. + +The ADA project used this system as the foundation for its notes-taking application. +Due to its simplicity and clarity, I decided to give it its own dedicated repository. + +## Functionality + +- **URL Endpoints:** + - `GET /api/notes` — Retrieve all notes + - `POST /api/notes` — Create a new note + - `GET /api/notes/{id}` — Retrieve a specific note + - `PUT /api/notes/{id}` — Update a note + - `DELETE /api/notes/{id}` — Delete a note + - `GET /api/tags` — Retrieve all tags + - `POST /api/tags` — Create a new tag + - `GET /api/tags/{name}` — Retrieve a specific tag + - `PUT /api/tags/{name}` — Update a tag + - `DELETE /api/tags/{name}` — Delete a tag + - `POST /api/upload` — Upload files + - `GET /` — Serve the frontend application + - `GET /api/health` — Health check endpoint + +*Note:* The above endpoints are indicative. The actual implementation may vary. + +## Usage + +*This is a template. Customize and extend as needed for your projects.* \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e9d044a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,442 @@ + + + + + + + + + + + + + + Ada Notes + + + +
+ + +
+ +
+
+
+ + + + + + + + + + + + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..039f804 --- /dev/null +++ b/main.py @@ -0,0 +1,177 @@ +# Written by retoor@molodetz.nl + +# This FastAPI application provides a backend API for a note-taking service called Ada Notes. The application allows for creating, listing, and managing notes, as well as handling file uploads and serving a frontend application for the service. + +# The external libraries used in this code include FastAPI for the web framework, Uvicorn as the ASGI server, Dataset for database operations, and additional packages for multipart file handling. + +# MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import asyncio +from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +import dataset +from datetime import datetime +from uuid import uuid4 +import os +from typing import List, Optional, Dict, Any +import pathlib +from pathlib import Path +from contextlib import asynccontextmanager +BASE_DIR = Path(__file__).parent +DB_URL = "sqlite:///" + str(BASE_DIR / "notes.db") +UPLOAD_DIR = pathlib.Path(".").joinpath("uploads") +FRONTEND_DIR = BASE_DIR / "frontend" +FRONTEND_INDEX = FRONTEND_DIR / "index.html" + +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +_semaphore = asyncio.Semaphore(10) + +@asynccontextmanager +async def db_session(transaction=False): + if transaction: + async with _semaphore: + db_ = dataset.connect(DB_URL) + db_.begin() + try: + yield db_ + finally: + db_.commit() + db_.close() + else: + db_ = dataset.connect(DB_URL) + try: + yield db_ + finally: + db_.close() + +app = FastAPI(title="Ada Notes API", version="1.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.mount("/static", StaticFiles(directory=UPLOAD_DIR), name="static") +if pathlib.Path(FRONTEND_DIR).exists(): + app.mount("/assets", StaticFiles(directory=FRONTEND_DIR), name="assets") + + +async def _serialize_note(row: Dict[str, Any]) -> Dict[str, Any]: + if not row: + return {} + note_id = row["id"] + + async with db_session() as db: + atts = list(db['attachments'].find(note_id=note_id)) + tags = [rt["tag"] for rt in db['note_tags'].find(note_id=note_id)] + return { + "id": note_id, + "title": row.get("title", ""), + "body": row.get("body", ""), + "created_at": row.get("created_at"), + "updated_at": row.get("updated_at"), + "attachments": atts, + "tags": tags, + } + + +@app.get("/api/notes") +async def list_notes(tag: Optional[str] = None): + async with db_session() as db: + if tag: + note_ids = [nt["note_id"] for nt in db['note_tags'].find(tag=tag)] + rows = [db['notes'].find_one(id=nid) for nid in note_ids] + else: + rows = list(db['notes'].all()) + rows.sort(key=lambda r: r["created_at"], reverse=True) + return [await _serialize_note(r) for r in rows if r] + +@app.post("/api/notes") +async def create_note(payload: Dict[str, Any]): + return await _upsert_note(None, payload) + +@app.put("/api/notes/{note_id}") +async def update_note(note_id: int, payload: Dict[str, Any]): + async with db_session() as db: + if not db['notes'].find_one(id=note_id): + raise HTTPException(404, "Note not found") + return await _upsert_note(note_id, payload) + +async def _upsert_note(note_id: Optional[int], payload: Dict[str, Any]): + async with db_session() as db: + title = payload.get("title", "").strip() + body = payload.get("body", "") + tags: List[str] = payload.get("tags", []) + atts: List[Dict[str, str]] = payload.get("attachments", []) + now = datetime.utcnow().isoformat() + + if note_id is None: + note_id = db['notes'].insert({"title": title, "body": body, "created_at": now, "updated_at": now}) + else: + db['notes'].update({"id": note_id, "title": title, "body": body, "updated_at": now}, ["id"]) + db['attachments'].delete(note_id=note_id) + db['note_tags'].delete(note_id=note_id) + + for t in tags: + t = t.strip() + if not t: + continue + if not db['tags'].find_one(name=t): + db['tags'].insert({"name": t}) + db['note_tags'].insert({"note_id": note_id, "tag": t}) + + for att in atts: + db['attachments'].insert({"note_id": note_id, "url": att.get("url"), "type": att.get("type", "file")}) + + return await _serialize_note(db['notes'].find_one(id=note_id)) + +@app.get("/api/tags") +async def list_tags(): + async with db_session() as db: + return [{"name": row["name"]} for row in db['tags'].all()] + + +@app.post("/api/upload") +async def upload(file: UploadFile = File(...)): + filename = f"{uuid4().hex}_{file.filename}" + filepath = UPLOAD_DIR.joinpath(filename) + with filepath.open("wb") as buffer: + while chunk := await file.read(1024 * 1024): + buffer.write(chunk) + content_type = file.content_type or "application/octet-stream" + ftype = "image" if content_type.startswith("image/") else "file" + return {"url": f"/static/{filename}", "type": ftype} + + +@app.get("/", response_class=FileResponse, include_in_schema=False) +async def index(): + if not pathlib.Path(FRONTEND_INDEX).exists(): + raise HTTPException(status_code=404, detail="index.html not found. Place your frontend build in the 'frontend' directory.") + return FileResponse(FRONTEND_INDEX, media_type="text/html") + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7de0f7a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-multipart +fastapi +dataset +uvicorn